diff --git a/AGENTS.md b/AGENTS.md index 42d91891b..13d16a674 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ Adding a cross-boundary feature: **Local Linux CI (optional):** Container mode — `act:ci`, `act:tests`, `act:pr`, … (needs a Docker-compatible engine + act; Podman preferred). Host mode — `act:ci:native`, `act:tests:native`, … (no container engine). See [docs/ci-cd.md](docs/ci-cd.md). macOS/Windows packaging uses native `dist:mac` / `dist:win`. **`dist:mac`** / **`dist:mac:publish`** always run **`scripts/verify-mac-packaging.mjs`** (ZIP + DMG symlink asserts, no raw `.app` CI uploads). macOS signing env (`CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`, `CSC_IDENTITY_AUTO_DISCOVERY`) is scoped to **`macos-latest`** jobs in `release.yaml` / `build.yaml`; partial-secret validation fails the release job when `CSC_LINK` is set but notarization secrets are missing. -> **Update script sync:** When adding or removing packages from `patchedDependencies` in `pnpm-workspace.yaml`, keep `WATCH_ENTRIES` in `scripts/update.sh` in sync so the script warns on version changes to every patched dependency. When adding or removing Ratspeak overlays under `reticulum-sidecar/patches/`, keep `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh` (`check_ratspeak_patches`) in sync — `pnpm run update` queries upstream PRs (rsReticulum / rsLXMF) and warns when a local overlay can be removed. It also runs `check_ratspeak_upstream` (watched releases for rsLXST / lrgp-rs / Ratspeak / LXMFace, plus new `ratspeak` org repos) — keep `RATSPEAK_RELEASE_WATCH_ENTRIES` / `RATSPEAK_KNOWN_ORG_REPOS` in sync when adopting libs. `scripts/clone-ratspeak-stack.sh` floats **rsReticulum** / **rsLXMF** / **rsNomad** / **rsLXST** to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF`); overlays must apply or the clone fails. Peer default avatars use vendored **LXMFace** (`src/renderer/lib/reticulum/lxmface.ts`). `pnpm run update` also runs `rustup update` (or Homebrew `rust` on macOS without rustup) and `cargo build` in `reticulum-sidecar/` when `cargo` is on `PATH` (full-feature build includes `nomad-core` / rsNomad). +> **Update script sync:** When adding or removing packages from `patchedDependencies` in `pnpm-workspace.yaml`, keep `WATCH_ENTRIES` in `scripts/update.sh` in sync so the script warns on version changes to every patched dependency. When adding or removing Ratspeak overlays under `reticulum-sidecar/patches/`, keep `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh` (`check_ratspeak_patches`) in sync — `pnpm run update` queries upstream PRs (rsReticulum / rsLXMF) and warns when a local overlay can be removed. It also runs `check_ratspeak_upstream` (watched releases for rsLXST / lrgp-rs / Ratspeak / LXMFace, plus new `ratspeak` org repos) — keep `RATSPEAK_RELEASE_WATCH_ENTRIES` / `RATSPEAK_KNOWN_ORG_REPOS` in sync when adopting libs. `scripts/clone-ratspeak-stack.sh` floats **rsReticulum** / **rsLXMF** / **rsNomad** / **rsLXST** / **lrgp-rs** to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF`); overlays must apply or the clone fails. Ratspeak release watch uses stub-kind `games-parity` to nudge Games tab review (`docs/reticulum-games-parity.md`). Peer default avatars use vendored **LXMFace** (`src/renderer/lib/reticulum/lxmface.ts`). `pnpm run update` also runs `rustup update` (or Homebrew `rust` on macOS without rustup) and `cargo build` in `reticulum-sidecar/` when `cargo` is on `PATH` (full-feature build includes `nomad-core` / rsNomad). **Pre-commit hook order:** @@ -166,7 +166,8 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`). - **Self label / header:** `reticulumSelfNodeLabel.ts` (`resolveReticulumSelfHeaderLabel` — Network display name in app header) - **Nomad errors:** `lib/nomad/nomadPageErrorHumanize.ts` (sidecar error codes → i18n); LinkClient Nomad overlay in `reticulum-sidecar/patches/` - **LXST voice:** `hasLxstVoice` gates Call buttons (Peers + Chat DM). Session helpers in `reticulumVoiceSession.ts` (dial/answer/hangup + mic PCM); UI store `reticulumVoiceStore.ts`; overlay `ReticulumVoiceOverlay` (App mount). Dedicated IPC `reticulum:voiceSendAudio` + `reticulum:onVoiceAudio` (`/ws/voice`); control via `electronAPI.reticulum.voice.*`. Runtime WS: `voice.update` / `voice.incoming` / `voice.stats` / `voice.terminated` / `voice.error` (errors should carry `link_id` when known; match by link/generation/remote). **Establish-only media:** Answer warms AudioContext; mic capture/TX starts only after `established`; sidecar soft-drops pre-establish PCM (`not_established`). Outbound progress tones: dial → peer DTMF fold → UK double-ring (`reticulumVoiceCallTones.ts` / `reticulumVoiceOutcome.ts` / `reticulumVoiceFeedback.ts`); media-start coalesces by `callGeneration` to avoid Answer mic thrash. Terminal reasons: treat sidecar `established`/`terminated` as completed (not fail). -- **Gating:** `hasReticulumDiscoveryMap` (Map tab); `hasReticulumRemotePanel` / `hasRncpTransfer` (Remote tab + Chat DM rncp); `hasRrcPanel` (RRC tab); `hasLxstVoice` (LXST Call); `hasReticulumInterfaceConfig` / `hasReticulumNetworkPanel` / `ProtocolCapabilities` +- **LRGP games:** `hasLrgpGames` gates Games tab + Challenge (Peers / Chat DM). Sidecar `games_session` + `LrgpStore`; dedicated IPC `electronAPI.reticulum.games.*` / `reticulum:games*` (proxy rejects `/api/v1/games/*`); WS `games.update` / `games.action_result`. Parity: [docs/reticulum-games-parity.md](docs/reticulum-games-parity.md). +- **Gating:** `hasReticulumDiscoveryMap` (Map tab); `hasReticulumRemotePanel` / `hasRncpTransfer` (Remote tab + Chat DM rncp); `hasRrcPanel` (RRC tab); `hasLxstVoice` (LXST Call); `hasLrgpGames` (Games); `hasReticulumInterfaceConfig` / `hasReticulumNetworkPanel` / `ProtocolCapabilities` - **rnsh/rncp:** sidecar `stack/{rnsh_session,rncp_transfer,path_speed,link_task}.rs` + HTTP `/api/v1/rnsh/*`, `/api/v1/rncp/*`, `/api/v1/remote/*`; typed `electronAPI.reticulum.rnsh|rncp|remote`; picker-gated send/fetch paths in `reticulum-remote-paths.ts`; LXMF enable-request sentinel `mesh-client:request-rncp-receive:v1` (`rncpRequestEnable.ts`); peer reply `mesh-client:rncp-receive-dest:v1:` autofills via `applyRncpReceiveDestShare` (prefer pending from `markRncpReceiveDestSharePending` / `sendRncpRequestEnable`; still apply without pending for older peers); enable-request modal + dest-share side effects deduped by LXMF `message_hash` (`rncpLxmfControlSideEffectDedup`) so catch-up cannot re-fire; already-listening auto-share is once per peer per request-enable cooldown; inbound listener config persists (`rncp_listener_*` in `mesh_client_stack.json`) and restores on live stack start - **Runtime:** `useReticulumRuntime`, `lib/sessions/reticulumSession.ts`, `lib/ingest/reticulumIngest.ts`; connect starts sidecar, not `ConnectionDriver` RF. Sidecar RRC: `rrc_codec` / `rrc_link` / `rrc_session` / `api/rrc.rs` - **Diagnostics:** `ReticulumDiagnosticEngine.ts` (Reticulum-native rows; no LoRa hop-goblin semantics) — includes `reticulum/sidecar-unhealthy` (60s grace), `reticulum/propagation-sync-stuck`, `reticulum/propagation-sync-failing` (1h TTL) diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 2d04188ef..0d22d7e55 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -76,7 +76,7 @@ Path-filtered on `reticulum-sidecar/**` and related scripts: 1. **`lint` job (ubuntu-latest)** — `cargo fmt --check` + `cargo clippy` with `rns-stack,rns-ble,rns-rnode-tcp` (`-D warnings`) 2. **Build matrix** — stub + full-stack `cargo test` and release builds on Linux, macOS, and Windows (including WoA arm64 jobs) -CI clones Ratspeak siblings via `scripts/clone-ratspeak-stack.sh` and **no longer hardcodes `RS_RETICULUM_REF`** — rsReticulum / rsLXMF / rsNomad float to `origin/main` (overlays must apply). Override with `RS_*_REF` only for local bisect. +CI clones Ratspeak siblings via `scripts/clone-ratspeak-stack.sh` and **no longer hardcodes `RS_RETICULUM_REF`** — rsReticulum / rsLXMF / rsNomad / rsLXST / lrgp-rs float to `origin/main` (overlays must apply). Override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF` only for local bisect. Local parity: `pnpm run reticulum:sidecar:clippy:full`, `pnpm run check:reticulum-sidecar` (pre-commit full-feature). See [development-environment.md](development-environment.md#reticulum-sidecar-optional). @@ -153,8 +153,9 @@ Automated dependency updates are configured in `.github/dependabot.yml`: - **Open PRs:** `open-pull-requests-limit: 0` — Dependabot scans but does **not** open PRs. Dependency bumps are applied manually via `pnpm run update` (`scripts/update.sh`), which also runs dedupe, Ratspeak overlay PR checks, and an upstream release / new-org-repo watch - (rsLXST, lrgp-rs, Ratspeak, LXMFace). Sibling **rsReticulum** / **rsLXMF** / **rsNomad** - float to `origin/main` via `clone-ratspeak-stack.sh` (overlays must apply). See AGENTS.md §6. + (rsLXST, lrgp-rs, Ratspeak with Games-parity nudge, LXMFace). Sibling **rsReticulum** / + **rsLXMF** / **rsNomad** / **rsLXST** / **lrgp-rs** float to `origin/main` via + `clone-ratspeak-stack.sh` (overlays must apply). See AGENTS.md §6. ### Testing Dependabot PRs locally diff --git a/docs/development-environment.md b/docs/development-environment.md index bd0963fb9..94ca27100 100644 --- a/docs/development-environment.md +++ b/docs/development-environment.md @@ -119,7 +119,7 @@ pnpm run reticulum:sidecar:build This writes `reticulum-sidecar/target/debug/mesh-client-reticulum` (macOS/Linux) or `.exe` on Windows. -**First-time / recover siblings:** from the mesh-client repo root, run `./scripts/clone-ratspeak-stack.sh`. That script clones (or updates) sibling checkouts `../rsReticulum`, `../rsLXMF`, `../rsNomad`, and `../rsLXST`, floats each to **`origin/main`** by default, and applies mesh-client overlays (fails if a patch will not apply). For bisect or a known-good pin, set `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` to a SHA or ref before running the clone script. +**First-time / recover siblings:** from the mesh-client repo root, run `./scripts/clone-ratspeak-stack.sh`. That script clones (or updates) sibling checkouts `../rsReticulum`, `../rsLXMF`, `../rsNomad`, `../rsLXST`, and `../lrgp-rs`, floats each to **`origin/main`** by default, and applies mesh-client overlays (fails if a patch will not apply). For bisect or a known-good pin, set `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF` to a SHA or ref before running the clone script. When those siblings already exist, `pnpm run reticulum:sidecar:build` applies required overlays via `scripts/ensure-rsReticulum-patches.sh` before compiling with `rns-stack,rns-ble,rns-rnode-tcp`. See [`reticulum-sidecar/patches/README.md`](../reticulum-sidecar/patches/README.md) for overlay details. diff --git a/docs/reticulum-games-parity.md b/docs/reticulum-games-parity.md new file mode 100644 index 000000000..764f171b3 --- /dev/null +++ b/docs/reticulum-games-parity.md @@ -0,0 +1,50 @@ +# Reticulum Games — Ratspeak parity checklist + +Living matrix for [issue #773](https://github.com/Colorado-Mesh/mesh-client/issues/773). Wire protocol is [lrgp-rs](https://github.com/ratspeak/lrgp-rs) (LRGP v1). Product surface reference is Ratspeak: + +- `crates/ratspeak-tauri/src/commands/games.rs` +- `dashboard/static/js/games_tab.js` + +Update this file when Games PRs land. `pnpm run update` warns on new Ratspeak releases with stub-kind `games-parity`. + +Status: `done` | `partial` | `wontfix` | `todo` + +## Commands / API + +| Ratspeak command | mesh-client | Status | Notes | +| ------------------------- | ----------------------------------------------------- | ------ | --------------------- | +| `send_game_action` | `POST /api/v1/games/action` + `reticulum:gamesAction` | done | Direct-preferred send | +| `get_available_games` | `GET /api/v1/games/apps` | done | | +| `get_all_game_sessions` | `GET /api/v1/games/sessions` | done | optional `?peer=` | +| `get_active_games` | `GET /api/v1/games/sessions?peer=` | done | peer filter | +| `get_game_session_detail` | `GET /api/v1/games/sessions/:id` | done | | +| `mark_game_read` | `POST …/read` | done | | +| `delete_game_session` | `DELETE …/:id` | done | | +| `resend_last_game_action` | `POST …/resend` | done | same envelope/nonce | + +## UI + +| Ratspeak UI | mesh-client | Status | Notes | +| ----------------------------------- | ------------------------------- | ------- | ----------------------------------------- | +| Games tab | Left-rail Games (`Gamepad2`) | done | Reticulum-only via `hasLrgpGames` | +| Session list filters | GamesPanel filters | done | | +| Unread badge | session unread + tab affordance | partial | confirm badge wiring vs Chat | +| TTT board | `TicTacToeBoard` | done | | +| Chess board | `ChessBoard` | done | | +| Challenge from contacts | Peers / Chat DM Challenge | done | | +| Draw / resign | session actions | done | | +| Delivery state / resend | resend IPC + UI | partial | match Ratspeak delivery UX | +| Notification route `lrgp:` | deep-link | todo | follow MeshClientDeepLinkHost later | +| Optimistic rollback UI | local reject + action_result | partial | sidecar rollback; polish UI | +| Win celebration | — | wontfix | optional polish; not required for interop | + +## Wire interop + +| Scenario | Status | +| ------------------------------- | ------ | +| mesh-client ↔ mesh-client TTT | done | +| mesh-client ↔ mesh-client Chess | done | +| mesh-client ↔ Ratspeak TTT | done | +| mesh-client ↔ Ratspeak Chess | done | + +Manual gold test: two clients on a TCP hub — challenge → accept → play → resign/draw. diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index 7031a6f18..3bb0b996f 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -221,22 +221,29 @@ Listener persistence: a successful `POST /api/v1/rncp/listener` stores the confi ### System -| Method | Path | Body / notes | Response | -| ------ | ------------------------------ | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| GET | `/api/v1/diagnostics` | | Reticulum-native health snapshot — includes `announce_ws` coalesce pressure (`last_window_ingress` / `unique` / `overflow`, `last_storm_at_ms`, `last_flush_at_ms`). Renderer Diagnostics emits `reticulum/announce-bus-pressure` when lag/storm/overflow is recent. | -| POST | `/api/v1/system/factory-reset` | | `{ ok }` — Electron UI must call `electronAPI.reticulum.factoryReset` (generic `proxyPost` blocks this path) | -| GET | `/api/v1/voice/status` | | LXST telephony status (`available`, `enabled`, `running`, `microphone_muted`, `active_call`) | -| POST | `/api/v1/voice/call` | `{ identity_hash }` | Place Opus call (`QualityHigh`, ~15s discovery). `identity_hash` is 32-hex identity (not LXMF dest). | -| POST | `/api/v1/voice/answer` | | Answer incoming call | -| POST | `/api/v1/voice/reject` | | Reject ringing call | -| POST | `/api/v1/voice/hangup` | | End active call | -| POST | `/api/v1/voice/mute` | `{ muted }` | Renderer mute flag (sidecar drops PCM ingest) | -| POST | `/api/v1/voice/audio` | `{ profile?, channels, samples_b64 }` | Push one PCM frame (LE f32 base64) for Opus TX. **Only established calls transmit**; earlier frames are accepted-and-dropped as `not_established` (soft-drop — do not fatal). Renderer must defer capture/TX until `voice.update` status `established` (Answer only warms `AudioContext`). Use dedicated IPC `reticulum:voiceSendAudio` (own ~2000/min budget); generic `reticulum:proxyPost` rejects this path so realtime PCM does not starve the shared 300/min proxy ceiling. | -| GET | `/api/v1/games/status` | | LRGP stub status | -| GET | `/api/v1/identities` | | `{ identities: […] }` — slots under `config/identities//` + `active_identity`; working key remains `config/identity`; flat identity migrates to `identities/default/` | -| POST | `/api/v1/identities` | `{ display_name? }` | `{ ok, id, identity }` or `{ ok: false, error }` — `rns-stack` only; stage slot → apply working key → commit pointer last (rollback on failure). Cap 16 slots. Errors: `identity_slot_limit_reached`, `display_name_*`. Emits restart. | -| POST | `/api/v1/identities/switch` | `{ identity_id }` | `{ ok }` or `{ ok: false, error }` — stash, install target → working, reconcile, then commit pointer. Errors: `identity_slot_not_configured`, `identity_not_found` | -| POST | `/api/v1/identities/delete` | `{ identity_id }` | `{ ok }` or `{ ok: false, error }` — refuses `cannot_delete_active_identity` / `cannot_delete_last_identity` | +| Method | Path | Body / notes | Response | +| ------ | ----------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GET | `/api/v1/diagnostics` | | Reticulum-native health snapshot — includes `announce_ws` coalesce pressure (`last_window_ingress` / `unique` / `overflow`, `last_storm_at_ms`, `last_flush_at_ms`). Renderer Diagnostics emits `reticulum/announce-bus-pressure` when lag/storm/overflow is recent. | +| POST | `/api/v1/system/factory-reset` | | `{ ok }` — Electron UI must call `electronAPI.reticulum.factoryReset` (generic `proxyPost` blocks this path) | +| GET | `/api/v1/voice/status` | | LXST telephony status (`available`, `enabled`, `running`, `microphone_muted`, `active_call`) | +| POST | `/api/v1/voice/call` | `{ identity_hash }` | Place Opus call (`QualityHigh`, ~15s discovery). `identity_hash` is 32-hex identity (not LXMF dest). | +| POST | `/api/v1/voice/answer` | | Answer incoming call | +| POST | `/api/v1/voice/reject` | | Reject ringing call | +| POST | `/api/v1/voice/hangup` | | End active call | +| POST | `/api/v1/voice/mute` | `{ muted }` | Renderer mute flag (sidecar drops PCM ingest) | +| POST | `/api/v1/voice/audio` | `{ profile?, channels, samples_b64 }` | Push one PCM frame (LE f32 base64) for Opus TX. **Only established calls transmit**; earlier frames are accepted-and-dropped as `not_established` (soft-drop — do not fatal). Renderer must defer capture/TX until `voice.update` status `established` (Answer only warms `AudioContext`). Use dedicated IPC `reticulum:voiceSendAudio` (own ~2000/min budget); generic `reticulum:proxyPost` rejects this path so realtime PCM does not starve the shared 300/min proxy ceiling. | +| GET | `/api/v1/games/status` | | LRGP live status (`available`, `enabled`, `running`, registered apps). Use dedicated IPC `reticulum:gamesStatus` — generic `proxyGet` rejects `/api/v1/games/*` | +| GET | `/api/v1/games/apps` | | Registered game manifests (ttt, chess) | +| GET | `/api/v1/games/sessions` | optional `?peer=` | Session list (sidecar `LrgpStore`) | +| GET | `/api/v1/games/sessions/:id` | | Session detail + board metadata | +| POST | `/api/v1/games/action` | `{ dest_hash, app_id, command, session_id?, payload? }` | Send LRGP action (challenge/accept/move/…). Dedicated IPC `reticulum:gamesAction` (~600/min own bucket) | +| POST | `/api/v1/games/sessions/:id/resend` | | Resend last envelope (same nonce) | +| POST | `/api/v1/games/sessions/:id/read` | | Mark session read | +| DELETE | `/api/v1/games/sessions/:id` | | Delete session | +| GET | `/api/v1/identities` | | `{ identities: […] }` — slots under `config/identities//` + `active_identity`; working key remains `config/identity`; flat identity migrates to `identities/default/` | +| POST | `/api/v1/identities` | `{ display_name? }` | `{ ok, id, identity }` or `{ ok: false, error }` — `rns-stack` only; stage slot → apply working key → commit pointer last (rollback on failure). Cap 16 slots. Errors: `identity_slot_limit_reached`, `display_name_*`. Emits restart. | +| POST | `/api/v1/identities/switch` | `{ identity_id }` | `{ ok }` or `{ ok: false, error }` — stash, install target → working, reconcile, then commit pointer. Errors: `identity_slot_not_configured`, `identity_not_found` | +| POST | `/api/v1/identities/delete` | `{ identity_id }` | `{ ok }` or `{ ok: false, error }` — refuses `cannot_delete_active_identity` / `cannot_delete_last_identity` | ## WebSocket @@ -246,7 +253,7 @@ Listener persistence: a successful `POST /api/v1/rncp/listener` stores the confi { "type": "lxmf_message", "payload": { ... } } ``` -Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscriber skipped N broadcast frames — client should `GET /api/v1/lxmf/recent`), `announce.received`, `peers_updated`, `path_medium_preference` (global preference changed; payload `{ preference }`), `stats_update`, `interface.state`, `stack_restart_requested`, `propagation_sync`, `propagation.discovered` (heard `lxmf.propagation` announce), `resource.received`, `rmap.discovery` (payload `{ discovered: RmapDiscoveredWireRow[] }`), `nomadnetwork.node` (Nomad peer announce heard), `nomad.serving_start` / `nomad.serving_stop` (local hosting lifecycle; payload includes `destination_hash` / `display_name` on start — renderer currently polls serving status via HTTP), RRC: `rrc.hub`, `rrc.connected`, `rrc.disconnected`, `rrc.room.joined`, `rrc.room.parted`, `rrc.message`, `rrc.error`, plus Remote: `rnsh.stdout` / `rnsh.stderr` / `rnsh.status` / `rnsh.closed` / `rnsh.error`, `rncp.offer` / `rncp.progress` / `rncp.completed` / `rncp.failed` / `rncp.cancelled`, plus LXST voice signalling: `voice.update` / `voice.incoming` / `voice.terminated` / `voice.error` / `voice.stats`. +Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscriber skipped N broadcast frames — client should `GET /api/v1/lxmf/recent`), `announce.received`, `peers_updated`, `path_medium_preference` (global preference changed; payload `{ preference }`), `stats_update`, `interface.state`, `stack_restart_requested`, `propagation_sync`, `propagation.discovered` (heard `lxmf.propagation` announce), `resource.received`, `rmap.discovery` (payload `{ discovered: RmapDiscoveredWireRow[] }`), `nomadnetwork.node` (Nomad peer announce heard), `nomad.serving_start` / `nomad.serving_stop` (local hosting lifecycle; payload includes `destination_hash` / `display_name` on start — renderer currently polls serving status via HTTP), RRC: `rrc.hub`, `rrc.connected`, `rrc.disconnected`, `rrc.room.joined`, `rrc.room.parted`, `rrc.message`, `rrc.error`, plus Remote: `rnsh.stdout` / `rnsh.stderr` / `rnsh.status` / `rnsh.closed` / `rnsh.error`, `rncp.offer` / `rncp.progress` / `rncp.completed` / `rncp.failed` / `rncp.cancelled`, plus LXST voice signalling: `voice.update` / `voice.incoming` / `voice.terminated` / `voice.error` / `voice.stats`, plus LRGP games: `games.update` / `games.action_result` (turn-based; stay on shared `/ws`). **Note:** Live `wire_packet` frames are **not** pushed on `/ws` (they starved critical `lxmf_message` events on large meshes). Sniffer/Stats poll `GET /api/v1/packets` while those panels are mounted. PacketTap rows still feed the sidecar packet log and LXMF egress evidence. @@ -266,25 +273,27 @@ Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscri Renderer calls `electronAPI.reticulum.*`; main process proxies to this API (sandboxed renderer cannot reach localhost directly). Lifecycle / proxy / Remote / factory-reset handlers live in `src/main/ipc/reticulum-handlers.ts`. Reticulum destination / Remote address / inbound-policy DB handlers are in `src/main/ipc/reticulum-db-handlers.ts`; RRC room history uses `src/main/ipc/rrc-db-handlers.ts`. -| IPC channel | Role | -| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | -| `reticulum:start` / `stop` / `getStatus` | Sidecar lifecycle | -| `reticulum:syncInterfaceIssueScope` | Drop TCP/TX latch entries for disabled/removed interfaces; sticky enabled-name filter for later log lines | -| `reticulum:proxyGet` / `proxyPost` / `proxyPut` / `proxyDelete` | HTTP proxy to paths above | -| `reticulum:factoryReset` | Factory reset (generic `proxyPost` blocks `/api/v1/system/factory-reset`; UI must use this channel) | -| `reticulum:validateConfig` | One-shot `validate-config --json` against `userData/reticulum/config` (read-only; safe while stack runs) | -| `reticulum:readDefaultConfigFile` | Read first existing system rnsd config path | -| `reticulum:showConfigImportDialog` | Native file picker for config import | -| `reticulum:showIdentityImportDialog` | Native file picker for 64-byte private key (`.retid`, `.key`, …) | -| `reticulum:showNomadContentSourceDialog` | Native folder picker for Nomad My Pages content source (site root or `pages/` dir); records picker allowlist | -| `reticulum:setNomadContentSource` | Apply Nomad watched content source; path must match last folder-picker result (blocks arbitrary proxyPut) | -| `reticulum:rncpSend` / `rncpFetch` / `setRncpListener` | Picker-gated rncp send/fetch/listener (path must match `reticulum-remote-paths` allowlist) | -| `reticulum:showRncpOpenFileDialog` / `showRncpSaveDirectoryDialog` | Native pickers that seed the rncp send-file / save-dir+fetch-jail allowlists | -| `reticulum:revealInFolder` | Reveal a path in the OS file manager when it matches an rncp picker allowlist | -| `reticulum:onEvent` / `onStatus` | Shared `/ws` events and sidecar status | -| `reticulum:voiceSendAudio` | Dedicated PCM TX ingest (`POST /api/v1/voice/audio`); own ~2000/min budget (not generic `proxyPost`) | -| `reticulum:onVoiceAudio` | Dedicated `/ws/voice` → `reticulum:voiceAudio` PCM frames (`voice.audio`) | -| `electronAPI.reticulum.voice.*` | Preload surface: `getStatus` / `call` / `answer` / `reject` / `hangup` / `mute` / `sendAudio` | +| IPC channel | Role | +| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | +| `reticulum:start` / `stop` / `getStatus` | Sidecar lifecycle | +| `reticulum:syncInterfaceIssueScope` | Drop TCP/TX latch entries for disabled/removed interfaces; sticky enabled-name filter for later log lines | +| `reticulum:proxyGet` / `proxyPost` / `proxyPut` / `proxyDelete` | HTTP proxy to paths above | +| `reticulum:factoryReset` | Factory reset (generic `proxyPost` blocks `/api/v1/system/factory-reset`; UI must use this channel) | +| `reticulum:validateConfig` | One-shot `validate-config --json` against `userData/reticulum/config` (read-only; safe while stack runs) | +| `reticulum:readDefaultConfigFile` | Read first existing system rnsd config path | +| `reticulum:showConfigImportDialog` | Native file picker for config import | +| `reticulum:showIdentityImportDialog` | Native file picker for 64-byte private key (`.retid`, `.key`, …) | +| `reticulum:showNomadContentSourceDialog` | Native folder picker for Nomad My Pages content source (site root or `pages/` dir); records picker allowlist | +| `reticulum:setNomadContentSource` | Apply Nomad watched content source; path must match last folder-picker result (blocks arbitrary proxyPut) | +| `reticulum:rncpSend` / `rncpFetch` / `setRncpListener` | Picker-gated rncp send/fetch/listener (path must match `reticulum-remote-paths` allowlist) | +| `reticulum:showRncpOpenFileDialog` / `showRncpSaveDirectoryDialog` | Native pickers that seed the rncp send-file / save-dir+fetch-jail allowlists | +| `reticulum:revealInFolder` | Reveal a path in the OS file manager when it matches an rncp picker allowlist | +| `reticulum:onEvent` / `onStatus` | Shared `/ws` events and sidecar status | +| `reticulum:voiceSendAudio` | Dedicated PCM TX ingest (`POST /api/v1/voice/audio`); own ~2000/min budget (not generic `proxyPost`) | +| `reticulum:onVoiceAudio` | Dedicated `/ws/voice` → `reticulum:voiceAudio` PCM frames (`voice.audio`) | +| `electronAPI.reticulum.voice.*` | Preload surface: `getStatus` / `call` / `answer` / `reject` / `hangup` / `mute` / `sendAudio` | +| `reticulum:gamesStatus` / `gamesApps` / `gamesSessions` / … | Dedicated LRGP games IPC (~600/min); generic proxy rejects `/api/v1/games/*` | +| `electronAPI.reticulum.games.*` | Preload: `getStatus` / `listApps` / `listSessions` / `getSession` / `sendAction` / `resend` / `markRead` / `deleteSession` | `getStatus` / `onStatus` may include `interfaceIssueAlert` (TCP connect failures, TX queue drops, link-delivery timeouts, transport saturation / slow queries, **`bleBondRemoved`** stale RNode bonds, **`blePairingTimedOut`** OS passkey / TX-read timeouts). Per-entry latch timestamps use a **5-minute** stale window (`RETICULUM_INTERFACE_ISSUE_ALERT_STALE_MS`). Connection syncs **enabled** interface names via `syncInterfaceIssueScope` so disabling or removing an interface clears that name immediately and rejects re-latch from lagging log lines. Stopping the stack (or unexpected process exit) clears the tracker. diff --git a/docs/reticulum.md b/docs/reticulum.md index a3c5c65f3..6c180d6d2 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -6,7 +6,7 @@ The MIT TypeScript UI talks to an **AGPL Rust sidecar** (`mesh-client-reticulum` **Primary interop:** [Ratspeak](https://github.com/ratspeak/Ratspeak) peers on [rsReticulum](https://github.com/ratspeak/rsReticulum) / [rsLXMF](https://github.com/ratspeak/rsLXMF). Nomad page hosting uses sibling [Colorado-Mesh/rsNomad](https://github.com/Colorado-Mesh/rsNomad) (`nomad-core`). -Related docs: [README — Reticulum Features](../README.md#reticulum-features), [Sidecar IPC contract](reticulum-sidecar-ipc.md), [Development — Reticulum sidecar](development-environment.md#reticulum-sidecar-optional), [Troubleshooting — Reticulum](troubleshooting.md#reticulum). +Related docs: [README — Reticulum Features](../README.md#reticulum-features), [Sidecar IPC contract](reticulum-sidecar-ipc.md), [Games parity (Ratspeak)](reticulum-games-parity.md), [Development — Reticulum sidecar](development-environment.md#reticulum-sidecar-optional), [Troubleshooting — Reticulum](troubleshooting.md#reticulum). --- @@ -16,7 +16,7 @@ Related docs: [README — Reticulum Features](../README.md#reticulum-features), 2. **Connection** → **Start stack** (optional **Auto-start** for next launch). 3. **Network** → generate or import your LXMF identity (stack must be running). 4. **Connection → Interfaces** → add and enable transports (TCP hub, I2P, Auto, or RNode over USB / BLE / Wi‑Fi). Use **Add default backbones** to sync community backbone presets by region (adds missing rows disabled, repairs mismatched endpoints, disables decommissioned official testnet hubs, skips correct ones) after identity is configured. **Enable 1 to 3 backbone gateways at most** (2 is the sweet spot; local RNodes/LAN do not count). -5. **Chat** → LXMF direct messages. **Remote** → rnsh shell + rncp file send/receive/fetch (high-speed paths). **RRC** → multi-hub relay chat. **Peers** and **Topology** for path-table visibility. **Nomad Network** → browse announced nodes or open **My Pages** to host a static Nomad site. +5. **Chat** → LXMF direct messages. **Games** → Tic-Tac-Toe / Chess over LRGP (or Challenge from Peers / Chat DM). **Remote** → rnsh shell + rncp file send/receive/fetch (high-speed paths). **RRC** → multi-hub relay chat. **Peers** and **Topology** for path-table visibility. **Nomad Network** → browse announced nodes or open **My Pages** to host a static Nomad site. After changing interfaces on a live network, **restart the stack** so RNS picks up transport changes. @@ -33,7 +33,8 @@ After changing interfaces on a live network, **restart the stack** so RNS picks | Remote | **rnsh** multi-session shell + **rncp** send/receive/fetch under one tab (Shell / Transfer / Saved / Settings); Chat DM send-file convenience; path-speed gate (TCP/network); inbound Ask/allow-list; auto-reconnect / auto-retry; LXMF “request enable receive” prompt between mesh-client peers | | RRC | Reticulum Relay Chat — discovered/manual/favourite hubs, up to **8** concurrent sessions, hub/room auto-join, rooms, nicklists, slash commands (`/list`, `/who`, `/join`, …), @mention unread badges, toasts when the RRC tab is inactive, automatic reconnect with backoff | | Delivery | **Direct** when destination is in path table (then **one-shot fallback** to preferred **remote** PN on Direct fail); **Propagated (PN)** when offline and a preferred remote PN is set. Path/transport badges (RF/BLE/TCP/NET, multi, PN) are egress evidence — UI stays **Sending** until `lxmf_outbound_status` (`delivered` / `failed`); Propagated Completes show **Stored at propagation node**. Terminal `delivery_status` + `delivery_method` persist in SQLite. Local PN hosting ≠ remote store-and-forward. Inbound `received_via` / TCP badges use local interface **config type**, not display name. | -| Peers | RNS path table + messaged History + saved Contacts + Favorites (Peers tab sub-tabs); LXMFace avatars; probe; **LXST Call** on rows; peer detail modal (Save as contact is manual) | +| Peers | RNS path table + messaged History + saved Contacts + Favorites (Peers tab sub-tabs); LXMFace avatars; probe; **LXST Call** and **LRGP Challenge** on rows; peer detail modal (Save as contact is manual) | +| Games | LRGP Tic-Tac-Toe + Chess via sibling [lrgp-rs](https://github.com/ratspeak/lrgp-rs); Games tab + Challenge from Peers/Chat; wire-compatible with Ratspeak ([parity checklist](reticulum-games-parity.md)) | | Topology | Best-effort graph from path-table next hops (not a full multi-hop trace) | | Map | Local RMAP v4 discovery map (heard opt-in interfaces with GPS); link to rmap.world for global view | | Nomad Network | Favourites / announces list (collapsible sidebar, default Favourites sub-tab) plus **My Pages** watched-folder hosting; **lazy-mount after first visit**; Micron (.mu) browser in a **dual-axis scroll shell**; **fit-width wrap default** with open-width toggle for ASCII pages; in-page navigation, back/forward, session page cache, `/file/` downloads, source toggle, and lxmf:// DM links; page/file errors humanized via `nomadPageErrorHumanize.ts`. Local hosting uses sibling [rsNomad](https://github.com/Colorado-Mesh/rsNomad) (`nomad-core`) for static `/page` + `/file` serving and `nomadnetwork.node` announces (no CGI). Choose a site root (`pages/`) or pages directory; FS watcher reloads routes; `nomad_serving_enabled` auto-restores after stack start. | @@ -53,6 +54,7 @@ After changing interfaces on a live network, **restart the stack** so RNS picks | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Connection | Stack start/stop, auto-start, interfaces CRUD, interface health, sidecar interface-issue banner (clears when hubs are disabled/removed), **Pick device** (serial / BLE) | | Chat | LXMF DMs (+ rncp Send file when peer has a receive destination) | +| Games | LRGP Tic-Tac-Toe + Chess (`GamesPanel`, `Gamepad2` icon); challenge/accept/play; Challenge also on Peers / Chat DM | | Remote | rnsh shell + rncp transfer (`ReticulumRemotePanel`): multi-session terminals, send/fetch/receive, saved addresses, inbound policy | | RRC | Multi-hub relay chat (`RrcPanel`): favourites/discovered hubs, rooms, nicklist, slash commands, reconnect | | Nomad Network | Favourites, announces, **My Pages** (watched-folder static host via rsNomad; auto-restore when stack starts), Micron page browser (dual-axis scroll shell, fit-width default + open-width toggle, navigation, cache, file downloads); lazy-mount keep-alive after first visit | @@ -401,7 +403,7 @@ Firmware `.zip` files are selected locally (no in-app GitHub download). Disconne ## Building the sidecar (development) -`rns-stack` builds need siblings `rsReticulum`, `rsLXMF`, `rsNomad`, and `rsLXST` (see `scripts/clone-ratspeak-stack.sh`). That script floats **rsReticulum** / **rsLXMF** / **rsNomad** / **rsLXST** to `origin/main` by default (bisect with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF`) and applies mesh-client overlays (fails if a patch will not apply). Peer list / detail default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) (`src/renderer/lib/reticulum/lxmface.ts`) when no custom Lucide icon is set. +`rns-stack` builds need siblings `rsReticulum`, `rsLXMF`, `rsNomad`, `rsLXST`, and `lrgp-rs` (see `scripts/clone-ratspeak-stack.sh`). That script floats each to `origin/main` by default (bisect with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF`) and applies mesh-client overlays for rsReticulum/rsLXMF (fails if a patch will not apply). Peer list / detail default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) (`src/renderer/lib/reticulum/lxmface.ts`) when no custom Lucide icon is set. End users of **GitHub Releases** or **Flatpak** do not need Rust. Developers and contributors do. @@ -411,7 +413,7 @@ End users of **GitHub Releases** or **Flatpak** do not need Rust. Developers and pnpm run reticulum:sidecar:build ``` -When sibling checkouts `../rsReticulum`, `../rsLXMF`, `../rsNomad`, and `../rsLXST` exist, the build script applies required patches and compiles with **`rns-stack,rns-ble,rns-rnode-tcp`** (live path table, BLE, RNode USB/Wi‑Fi, Nomad hosting, LXST voice). Without siblings, Cargo builds the **stub** stack (file-backed API for UI/tests — not for real mesh I/O). +When sibling checkouts `../rsReticulum`, `../rsLXMF`, `../rsNomad`, `../rsLXST`, and `../lrgp-rs` exist, the build script applies required patches and compiles with **`rns-stack,rns-ble,rns-rnode-tcp`** (live path table, BLE, RNode USB/Wi‑Fi, Nomad hosting, LXST voice, LRGP games). Without siblings, Cargo builds the **stub** stack (file-backed API for UI/tests — not for real mesh I/O). **Electron dev:** **Start stack** auto-runs `cargo build` when the debug binary is missing, when `reticulum-sidecar/src/**/*.rs` or `Cargo.toml` is newer than the binary, or when a stub binary is present but full-stack siblings exist. First compile can take several minutes — pre-build with the command above. @@ -472,8 +474,8 @@ Transfers require a **high-speed** path (TCP/network); LoRa/BLE-only destination - **Clear announces** — path table may refill from the live network on the next refresh - **Topology** — next-hop only; not a full end-to-end trace - **AGPL sidecar** — separate process and license from the MIT Electron shell -- **LRGP games** — not integrated (status endpoint may exist in sidecar; no UI) - **LXST voice calls** — integrated via rsLXST `TelephonyService` in the sidecar (`/api/v1/voice/*` + WS `voice.*`). Renderer owns mic/speaker (`getUserMedia` / Web Audio); Call controls live on Peers rows and Chat DM (no separate Voice tab). Live interop with Ratspeak / Python LXST should be verified manually on a real mesh. +- **LRGP games** — integrated via sibling [lrgp-rs](https://github.com/ratspeak/lrgp-rs) (`LrgpRouter` + `LrgpStore` in the sidecar). Reticulum **Games** tab (`Gamepad2`) for Tic-Tac-Toe and Chess; Challenge from Peers / Chat DM. Dedicated IPC `reticulum:games*` (generic proxy rejects `/api/v1/games/*`). WS `games.update` / `games.action_result`. Wire-compatible with Ratspeak; see [reticulum-games-parity.md](reticulum-games-parity.md). - **Hardware identity (YubiKey/PIV)** — not wired - **In-app firmware download** — local `.zip` pick only diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c3cfbc7e6..234575971 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -216,6 +216,15 @@ If microphone permission is denied when placing or answering an LXST voice call: - **Windows:** Settings → Privacy & security → Microphone — allow desktop apps / Mesh-client. The app opens this page when OS status is `denied`. - **Linux:** Ensure PulseAudio or PipeWire can capture; Flatpak builds already include `--socket=pulseaudio`. AppImage/deb use the host audio stack. +### Reticulum Games challenge fails or board does not update + +- **Stack not running / games disabled:** Games need a live `rns-stack` sidecar with sibling `lrgp-rs`. Check Connection → Start stack and `GET` status via Games tab (or logs for `games requires live rns-stack`). +- **`unsupported_app`:** Peer lacks that LRGP app (mesh-client and Ratspeak ship Tic-Tac-Toe + Chess). Challenge with `ttt` or `chess`. +- **`not_your_turn` / `invalid_move`:** Local validation rejected the move before send; wait for opponent or pick a legal cell/UCI move. +- **Challenge never arrives:** Path/Direct delivery required for reliable LRGP; ensure a path to the peer (Peers → Probe) or preferred PN fallback. Confirm peer Games tab / unread session list. +- **IPC blocked on proxy:** Renderer must use `electronAPI.reticulum.games.*` (`reticulum:games*`); generic `proxyGet`/`proxyPost` to `/api/v1/games/*` is rejected by design. +- **Interop with Ratspeak:** Same LRGP v1 wire (`lrgp.v1` + `0xFB`/`0xFD`). See [reticulum-games-parity.md](reticulum-games-parity.md). + ### Reticulum LXST voice call fails or is silent - **Stack not running:** Call needs a live Reticulum sidecar (`available` + `enabled` + `running` from `/api/v1/voice/status`). Start the stack from Connection. diff --git a/reticulum-sidecar/Cargo.lock b/reticulum-sidecar/Cargo.lock index 131d2b86f..61f745f3c 100644 --- a/reticulum-sidecar/Cargo.lock +++ b/reticulum-sidecar/Cargo.lock @@ -13,6 +13,18 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -343,6 +355,16 @@ dependencies = [ "cipher", ] +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cesu8" version = "1.1.0" @@ -476,6 +498,21 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cozy-chess" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27029a361056fc4f85ee27c02c8b3d219be061cb5ee95392541d4d5fc8aaff68" +dependencies = [ + "cozy-chess-types", +] + +[[package]] +name = "cozy-chess-types" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "276b4804775347af35852c80665ad23d62a9ee742a14656e7da6a7b1cee74ee9" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -769,6 +806,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.4.1" @@ -791,6 +840,12 @@ dependencies = [ "libc", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flagset" version = "0.4.7" @@ -969,6 +1024,9 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] [[package]] name = "hashbrown" @@ -976,6 +1034,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -1306,6 +1373,17 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1327,6 +1405,22 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lrgp" +version = "0.3.1" +dependencies = [ + "cozy-chess", + "hex", + "rand 0.8.6", + "rmp-serde", + "rmpv", + "rusqlite", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "lxmf-core" version = "1.1.0" @@ -1450,6 +1544,7 @@ dependencies = [ "futures-util", "hex", "http", + "lrgp", "lxmf-core", "lxst-core", "lxst-telephony", @@ -2134,6 +2229,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -2335,6 +2444,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2848,6 +2963,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/reticulum-sidecar/Cargo.toml b/reticulum-sidecar/Cargo.toml index 5b205364e..b60d01fd9 100644 --- a/reticulum-sidecar/Cargo.toml +++ b/reticulum-sidecar/Cargo.toml @@ -13,13 +13,14 @@ path = "src/main.rs" [features] default = [] # Full stack: requires sibling checkouts at ../../rsReticulum, ../../rsLXMF, -# ../../rsNomad, and ../../rsLXST (Ratspeak layout). +# ../../rsNomad, ../../rsLXST, and ../../lrgp-rs (Ratspeak layout). rns-stack = [ "dep:rns-runtime", "dep:lxmf-core", "dep:nomad-core", "dep:lxst-telephony", "dep:lxst-core", + "dep:lrgp", "dep:rns-identity", "dep:rns-wire", "dep:rns-ratkey", @@ -105,6 +106,11 @@ package = "lxst-core" path = "../../rsLXST/crates/lxst-core" optional = true +[dependencies.lrgp] +package = "lrgp" +path = "../../lrgp-rs" +optional = true + [dependencies.rns-interface] package = "rns-interface" path = "../../rsReticulum/crates/rns-interface" diff --git a/reticulum-sidecar/README.md b/reticulum-sidecar/README.md index 7abed763a..68e59a508 100644 --- a/reticulum-sidecar/README.md +++ b/reticulum-sidecar/README.md @@ -14,21 +14,22 @@ Install Rust (**1.85+**, edition 2024). Prefer [rustup](https://rustup.rs/). See ./scripts/clone-ratspeak-stack.sh ``` -That floats `rsReticulum` / `rsLXMF` / `rsNomad` / `rsLXST` to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` for bisect). Peer default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) in the **renderer** (`src/renderer/lib/reticulum/lxmface.ts`), not this sidecar. +That floats `rsReticulum` / `rsLXMF` / `rsNomad` / `rsLXST` / `lrgp-rs` to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF` for bisect). Peer default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) in the **renderer** (`src/renderer/lib/reticulum/lxmface.ts`), not this sidecar. -**Default (stub stack)** — builds without `--features rns-stack`; Cargo still requires sibling `rsReticulum`, `rsLXMF`, `rsNomad`, and `rsLXST` directories on disk (CI runs `clone-ratspeak-stack.sh`; locally use the script above): +**Default (stub stack)** — builds without `--features rns-stack`; Cargo still requires sibling `rsReticulum`, `rsLXMF`, `rsNomad`, `rsLXST`, and `lrgp-rs` directories on disk (CI runs `clone-ratspeak-stack.sh`; locally use the script above): ```bash pnpm run reticulum:sidecar:build ``` -**Full rsReticulum + rsLXMF + rsNomad + rsLXST** — sibling checkout (Ratspeak layout + Colorado-Mesh rsNomad + LXST voice): +**Full rsReticulum + rsLXMF + rsNomad + rsLXST + lrgp-rs** — sibling checkout (Ratspeak layout + Colorado-Mesh rsNomad + LXST voice + LRGP games): ``` parent/ rsReticulum/ rsLXMF/ rsLXST/ + lrgp-rs/ rsNomad/ mesh-client/reticulum-sidecar/ ``` @@ -83,7 +84,7 @@ Install coverage tooling once: `cargo install cargo-llvm-cov`. - **Pre-commit** runs sibling `rsNomad` fmt/clippy plus sidecar stub fmt/clippy/test when `cargo` is on `PATH` (no coverage). - **CI lint** (`reticulum-sidecar.yaml`): `rsNomad` fmt/clippy, then full-feature sidecar `fmt --check` + Clippy. - **CI coverage** (`tests.yaml`): `cargo llvm-cov --fail-under-lines 45` when sidecar paths change (ratchet toward ~52%; ignores `rsReticulum`/`rsLXMF`/`rsNomad` path deps). -- **Ratspeak / Nomad / LXST siblings:** `scripts/clone-ratspeak-stack.sh` floats `rsReticulum` / `rsLXMF` / `rsNomad` / `rsLXST` to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF`); overlays must apply. +- **Ratspeak / Nomad / LXST / LRGP siblings:** `scripts/clone-ratspeak-stack.sh` floats `rsReticulum` / `rsLXMF` / `rsNomad` / `rsLXST` / `lrgp-rs` to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF`); overlays must apply for rsReticulum/rsLXMF. ## API diff --git a/reticulum-sidecar/src/api/games.rs b/reticulum-sidecar/src/api/games.rs new file mode 100644 index 000000000..31422e5bf --- /dev/null +++ b/reticulum-sidecar/src/api/games.rs @@ -0,0 +1,141 @@ +//! LRGP (Lightweight Reticulum Gaming Protocol) game HTTP API. + +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Path, Query, State}; +use serde::Deserialize; + +use crate::api::validate::{MAX_DEST_HASH_CHARS, reject_oversize}; +use crate::stack::StackHandle; + +fn reject_oversize_session_id(session_id: &str) -> Option { + reject_oversize("session_id", session_id, MAX_DEST_HASH_CHARS) +} + +fn oversize_rejection(err: &str) -> Json { + Json(serde_json::json!({ "ok": false, "error": err })) +} + +pub async fn games_status(State(stack): State>) -> Json { + Json(stack.games_status().await) +} + +pub async fn games_apps(State(stack): State>) -> Json { + Json(stack.games_apps().await) +} + +#[derive(Debug, Deserialize)] +pub struct GamesSessionsQuery { + #[serde(default)] + pub peer: Option, +} + +pub async fn games_sessions( + State(stack): State>, + Query(query): Query, +) -> Json { + Json(stack.games_sessions(query.peer.as_deref()).await) +} + +pub async fn games_session_detail( + State(stack): State>, + Path(session_id): Path, +) -> Json { + if let Some(err) = reject_oversize_session_id(&session_id) { + return oversize_rejection(&err); + } + Json(stack.games_session_detail(&session_id).await) +} + +#[derive(Debug, Deserialize)] +pub struct GameActionBody { + pub dest_hash: String, + pub app_id: String, + pub command: String, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub payload: Option, + /// Accepted for API forward-compatibility; delivery method is currently + /// auto-selected (Direct-preferred, Propagated fallback) like chat sends. + #[serde(default)] + pub delivery_method: Option, +} + +pub async fn games_action( + State(stack): State>, + Json(body): Json, +) -> Json { + if let Some(err) = reject_oversize("dest_hash", &body.dest_hash, MAX_DEST_HASH_CHARS) { + return oversize_rejection(&err); + } + if let Some(err) = reject_oversize("app_id", &body.app_id, MAX_DEST_HASH_CHARS) { + return oversize_rejection(&err); + } + if let Some(err) = reject_oversize("command", &body.command, MAX_DEST_HASH_CHARS) { + return oversize_rejection(&err); + } + if let Some(session_id) = body.session_id.as_deref() { + if let Some(err) = reject_oversize_session_id(session_id) { + return oversize_rejection(&err); + } + } + tracing::debug!( + target: "games", + requested_delivery_method = ?body.delivery_method, + "game action delivery method hint (auto-selected)" + ); + match stack + .games_send_action( + &body.dest_hash, + &body.app_id, + &body.command, + body.session_id.as_deref(), + body.payload.as_ref(), + ) + .await + { + Ok(payload) => Json(payload), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn games_session_resend( + State(stack): State>, + Path(session_id): Path, +) -> Json { + if let Some(err) = reject_oversize_session_id(&session_id) { + return oversize_rejection(&err); + } + match stack.games_resend_action(&session_id).await { + Ok(payload) => Json(payload), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn games_session_read( + State(stack): State>, + Path(session_id): Path, +) -> Json { + if let Some(err) = reject_oversize_session_id(&session_id) { + return oversize_rejection(&err); + } + match stack.games_mark_read(&session_id).await { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn games_session_delete( + State(stack): State>, + Path(session_id): Path, +) -> Json { + if let Some(err) = reject_oversize_session_id(&session_id) { + return oversize_rejection(&err); + } + match stack.games_delete_session(&session_id).await { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} diff --git a/reticulum-sidecar/src/api/mod.rs b/reticulum-sidecar/src/api/mod.rs index f6888849a..89a353b66 100644 --- a/reticulum-sidecar/src/api/mod.rs +++ b/reticulum-sidecar/src/api/mod.rs @@ -1,6 +1,7 @@ //! HTTP + WebSocket API (Ratspeak-aligned contract; see docs/reticulum-sidecar-ipc.md). mod config; +mod games; mod identity; mod interfaces; mod lxmf; @@ -252,7 +253,22 @@ pub fn router(stack: Arc) -> Router { .route("/api/v1/voice/hangup", post(voice::voice_hangup)) .route("/api/v1/voice/mute", post(voice::voice_mute)) .route("/api/v1/voice/audio", post(voice::voice_audio)) - .route("/api/v1/games/status", get(system::games_status)) + .route("/api/v1/games/status", get(games::games_status)) + .route("/api/v1/games/apps", get(games::games_apps)) + .route("/api/v1/games/sessions", get(games::games_sessions)) + .route( + "/api/v1/games/sessions/{id}", + get(games::games_session_detail).delete(games::games_session_delete), + ) + .route("/api/v1/games/action", post(games::games_action)) + .route( + "/api/v1/games/sessions/{id}/resend", + post(games::games_session_resend), + ) + .route( + "/api/v1/games/sessions/{id}/read", + post(games::games_session_read), + ) .route( "/api/v1/identities", get(system::list_identities).post(system::create_identity), diff --git a/reticulum-sidecar/src/api/system.rs b/reticulum-sidecar/src/api/system.rs index ee9915450..ee1846477 100644 --- a/reticulum-sidecar/src/api/system.rs +++ b/reticulum-sidecar/src/api/system.rs @@ -23,10 +23,6 @@ pub async fn diagnostics(State(stack): State>) -> Json>) -> Json { - Json(stack.games_status().await) -} - pub async fn list_identities(State(stack): State>) -> Json { Json(stack.list_identities().await) } diff --git a/reticulum-sidecar/src/stack/games_session.rs b/reticulum-sidecar/src/stack/games_session.rs new file mode 100644 index 000000000..a87ee375f --- /dev/null +++ b/reticulum-sidecar/src/stack/games_session.rs @@ -0,0 +1,794 @@ +//! LRGP (Lightweight Reticulum Gaming Protocol) game session manager. +//! +//! Bridges `lrgp-rs`'s `LrgpRouter` (game dispatch) and `LrgpStore` (SQLite +//! session mirror) to the sidecar's LXMF transport and WebSocket event bus. +//! Mirrors the voice/`VoiceSessionManager` pattern: constructed once by +//! `LiveBridge::spawn`, held behind an `Arc`, and cloned into the LXMF +//! delivery callback so inbound LRGP envelopes can be intercepted before the +//! normal chat emit path. + +use std::collections::{BTreeMap, HashMap}; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use lrgp::apps::chess::ChessApp; +use lrgp::apps::tictactoe::TicTacToeApp; +use lrgp::constants::{ + CMD_MOVE, ERR_INVALID_MOVE, ERR_NOT_YOUR_TURN, KEY_APP, KEY_COMMAND, KEY_PAYLOAD, KEY_SESSION, +}; +use lrgp::envelope; +use lrgp::router::LrgpRouter; +use lrgp::session::Session; +use lrgp::store::LrgpStore; +use lrgp::transport; +use serde_json::Value as JsonValue; +use tokio::sync::broadcast; + +/// Cap on `last_envelope` so abandoned sessions cannot retain resend bytes forever. +const MAX_LAST_ENVELOPES: usize = 256; + +/// A dispatched-but-not-yet-sent outgoing LRGP action. Callers (LiveBridge) +/// must call [`GamesSessionManager::commit_action`] after a successful LXMF +/// send, or [`GamesSessionManager::rollback_action`] on failure. +#[derive(Debug)] +pub struct PreparedGameAction { + pub app_id: String, + pub session_id: String, + pub dest_hash: String, + pub fields: HashMap>, + pub envelope_bytes: Vec, + pub fallback_text: String, + snapshot: Option, +} + +/// A re-derived outgoing action for resend (same envelope bytes as last time — +/// same nonce, so receiver-side replay-dedup treats it as a retransmit). +#[derive(Debug)] +pub struct PreparedResend { + pub app_id: String, + pub dest_hash: String, + pub fields: HashMap>, + pub fallback_text: String, +} + +pub struct GamesSessionManager { + router: Arc, + store: Option>, + /// LXMF delivery hash hex of the local identity — used as `identity_id` + /// for every router / store call (one games DB per sidecar identity). + identity_id: String, + event_tx: broadcast::Sender, + /// session_id -> last packed outbound envelope bytes, for resend. + last_envelope: Mutex>>, +} + +impl GamesSessionManager { + /// Register built-in games and open the SQLite mirror under + /// `storage_dir/lrgp/games.db`. On store-open failure, the manager stays + /// usable for outgoing dispatch but read/list endpoints report empty. + pub fn spawn( + storage_dir: &Path, + identity_id: String, + event_tx: broadcast::Sender, + ) -> Self { + let router = Arc::new(LrgpRouter::new()); + router.register(Box::new(TicTacToeApp::new())); + router.register(Box::new(ChessApp::new())); + + let games_dir = storage_dir.join("lrgp"); + let store = match std::fs::create_dir_all(&games_dir) { + Ok(()) => match LrgpStore::open(games_dir.join("games.db")) { + Ok(store) => Some(Arc::new(store)), + Err(e) => { + tracing::warn!(target: "games", "failed to open lrgp store: {e}"); + None + } + }, + Err(e) => { + tracing::warn!(target: "games", "failed to create lrgp storage dir: {e}"); + None + } + }; + + Self { + router, + store, + identity_id, + event_tx, + last_envelope: Mutex::new(HashMap::new()), + } + } + + pub fn status(&self) -> JsonValue { + serde_json::json!({ + "available": true, + "enabled": self.store.is_some(), + "app_count": self.router.list_apps().len(), + "reason": if self.store.is_some() { + JsonValue::Null + } else { + JsonValue::String("lrgp store unavailable".into()) + }, + }) + } + + pub fn list_apps(&self) -> JsonValue { + let apps: Vec = self + .router + .list_apps() + .into_iter() + .map(|m| serde_json::to_value(m).unwrap_or(JsonValue::Null)) + .collect(); + serde_json::json!({ "apps": apps }) + } + + pub fn list_sessions(&self, peer: Option<&str>) -> JsonValue { + let Some(store) = &self.store else { + return serde_json::json!({ "sessions": [] }); + }; + let peer_norm = peer + .map(str::trim) + .filter(|p| !p.is_empty()) + .map(str::to_lowercase); + match store.list_sessions(Some(&self.identity_id), None, None) { + Ok(sessions) => { + let rows: Vec = sessions + .into_iter() + .filter(|s| match &peer_norm { + Some(p) => s.contact_hash.eq_ignore_ascii_case(p), + None => true, + }) + .map(|s| serde_json::to_value(s).unwrap_or(JsonValue::Null)) + .collect(); + serde_json::json!({ "sessions": rows }) + } + Err(e) => serde_json::json!({ "sessions": [], "error": e.to_string() }), + } + } + + pub fn session_detail(&self, session_id: &str) -> JsonValue { + let Some(store) = &self.store else { + return serde_json::json!({ "session": null }); + }; + match store.get_session(session_id, &self.identity_id) { + Ok(Some(session)) => { + serde_json::json!({ "session": serde_json::to_value(session).unwrap_or(JsonValue::Null) }) + } + Ok(None) => serde_json::json!({ "session": null }), + Err(e) => serde_json::json!({ "session": null, "error": e.to_string() }), + } + } + + pub fn mark_read(&self, session_id: &str) -> Result<(), String> { + let store = self + .store + .as_ref() + .ok_or_else(|| "lrgp store unavailable".to_string())?; + let mut updates = HashMap::new(); + updates.insert("unread".to_string(), "0".to_string()); + store + .update_session(session_id, &self.identity_id, &updates) + .map_err(|e| e.to_string()) + } + + pub fn delete_session(&self, session_id: &str) -> Result<(), String> { + let store = self + .store + .as_ref() + .ok_or_else(|| "lrgp store unavailable".to_string())?; + store + .delete_session(session_id, &self.identity_id) + .map_err(|e| e.to_string())?; + if let Ok(mut cache) = self.last_envelope.lock() { + cache.remove(session_id); + } + Ok(()) + } + + /// Intercept an inbound LXMF message's raw fields. Returns `true` when the + /// message was a recognized LRGP envelope and has been fully dispatched — + /// the caller must skip the normal chat emit path in that case. + pub fn handle_inbound_lxmf( + &self, + fields: &BTreeMap>, + sender_hash: &str, + _content: &str, + ) -> bool { + if fields.is_empty() { + return false; + } + let fields_map: HashMap> = + fields.iter().map(|(&k, v)| (k, v.clone())).collect(); + let envelope = match transport::extract_envelope(&fields_map) { + Ok(Some(env)) => env, + Ok(None) => return false, + Err(e) => { + tracing::debug!(target: "games", "lrgp envelope invalid, treating as normal message: {e}"); + return false; + } + }; + + let app_ver = envelope + .get(KEY_APP) + .and_then(envelope::value_as_str) + .unwrap_or_default(); + let Some((app_id, _version)) = envelope::parse_app_version(app_ver) else { + return false; + }; + let app_id = app_id.to_string(); + let session_id = envelope + .get(KEY_SESSION) + .and_then(envelope::value_as_str) + .unwrap_or_default() + .to_string(); + let command = envelope + .get(KEY_COMMAND) + .and_then(envelope::value_as_str) + .unwrap_or_default() + .to_string(); + + let result = match self + .router + .dispatch_incoming(&envelope, sender_hash, &self.identity_id) + { + Ok(r) => r, + Err(e) => { + tracing::warn!( + target: "games", + "lrgp dispatch_incoming failed for app_id={app_id} session_id={session_id} command={command}: {e}" + ); + return false; + } + }; + + // Defer SQLite persist so deliver_unpacked_lxmf can release router.lock() + // before blocking I/O. Dispatch + WS emit stay synchronous. + self.schedule_persist_session(&app_id, &session_id); + + let mut payload = serde_json::json!({ + "app_id": app_id, + "session_id": session_id, + "command": command, + "sender_hash": sender_hash, + "direction": "inbound", + }); + if let Some(obj) = payload.as_object_mut() { + let session_json = result + .session + .map(|s| JsonValue::Object(s.into_iter().collect())); + obj.insert("session".into(), session_json.unwrap_or(JsonValue::Null)); + if let Some(emit) = result.emit { + obj.insert( + "event".into(), + JsonValue::Object(emit.into_iter().collect()), + ); + } + if let Some(error) = result.error { + obj.insert( + "error".into(), + JsonValue::Object(error.into_iter().collect()), + ); + } + } + self.emit("games.update", &payload); + + true + } + + /// Local pre-send validation mirroring Ratspeak's `DeliveryProfile::Lrgp` + /// client-side gate: reject before spending an LXMF envelope on a move + /// that is obviously invalid (empty payload) or out of turn. + fn local_reject_reason( + &self, + app_id: &str, + session_id: &str, + command: &str, + payload: &HashMap, + ) -> Option<&'static str> { + if command != CMD_MOVE { + return None; + } + if payload.is_empty() { + return Some(ERR_INVALID_MOVE); + } + if session_id.is_empty() { + return None; + } + let state = self.router.with_app(app_id, |app| { + app.get_session_state(session_id, &self.identity_id) + })?; + let turn = state + .get("metadata") + .and_then(JsonValue::as_object) + .and_then(|m| m.get("turn")) + .and_then(JsonValue::as_str)?; + if turn.is_empty() || turn == self.identity_id { + None + } else { + Some(ERR_NOT_YOUR_TURN) + } + } + + /// Snapshot + dispatch an outgoing action without sending anything over + /// LXMF. On success the caller must send `fields` and then call + /// [`commit_action`](Self::commit_action) or + /// [`rollback_action`](Self::rollback_action). + pub fn prepare_action( + &self, + dest_hash: &str, + app_id: &str, + command: &str, + session_id: Option<&str>, + payload_json: Option<&JsonValue>, + ) -> Result { + let dest_hash = dest_hash.trim().to_lowercase(); + if dest_hash.len() != 32 || !dest_hash.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("invalid_dest_hash".to_string()); + } + + let version = self + .router + .with_app(app_id, |app| app.version()) + .ok_or_else(|| "unknown_app".to_string())?; + + let session_id = match session_id.map(str::trim).filter(|s| !s.is_empty()) { + Some(id) => id.to_string(), + None => generate_session_id(), + }; + + let payload = json_payload_to_rmpv_map(payload_json); + + if let Some(reason) = self.local_reject_reason(app_id, &session_id, command, &payload) { + return Err(reason.to_string()); + } + + let snapshot = self + .router + .snapshot_before_outgoing(app_id, &session_id, &self.identity_id); + + let (envelope, fallback_text) = self + .router + .dispatch_outgoing( + app_id, + version, + command, + &session_id, + &payload, + &self.identity_id, + ) + .map_err(|e| format!("dispatch_error: {e}"))?; + + let fields = + transport::pack_into_fields(&envelope).map_err(|e| format!("encode_error: {e}"))?; + let envelope_bytes = + envelope::pack_to_bytes(&envelope).map_err(|e| format!("encode_error: {e}"))?; + + Ok(PreparedGameAction { + app_id: app_id.to_string(), + session_id, + dest_hash, + fields, + envelope_bytes, + fallback_text, + snapshot, + }) + } + + /// Re-derive the last dispatched envelope for a session so it can be + /// resent verbatim (same nonce — receiver-side dedup treats it as a + /// retransmit rather than a new move). + pub fn prepare_resend(&self, session_id: &str) -> Result { + let envelope_bytes = { + let cache = self + .last_envelope + .lock() + .map_err(|_| "games cache poisoned".to_string())?; + cache + .get(session_id) + .cloned() + .ok_or_else(|| "no_previous_action".to_string())? + }; + let envelope = envelope::unpack_from_bytes(&envelope_bytes) + .map_err(|e| format!("resend_decode_error: {e}"))?; + let app_ver = envelope + .get(KEY_APP) + .and_then(envelope::value_as_str) + .unwrap_or_default(); + let (app_id, _version) = envelope::parse_app_version(app_ver) + .ok_or_else(|| "resend_decode_error: invalid app.version".to_string())?; + let app_id = app_id.to_string(); + let command = envelope + .get(KEY_COMMAND) + .and_then(envelope::value_as_str) + .unwrap_or_default() + .to_string(); + let payload = envelope + .get(KEY_PAYLOAD) + .and_then(envelope::map_from_value) + .unwrap_or_default(); + + let dest_hash = self + .store_contact_hash(session_id) + .ok_or_else(|| "unknown_session".to_string())?; + + let fallback_text = self + .router + .with_app(&app_id, |app| app.render_fallback(&command, &payload)) + .unwrap_or_default(); + let fields = transport::pack_into_fields(&envelope) + .map_err(|e| format!("resend_encode_error: {e}"))?; + + Ok(PreparedResend { + app_id, + dest_hash, + fields, + fallback_text, + }) + } + + fn store_contact_hash(&self, session_id: &str) -> Option { + let store = self.store.as_ref()?; + let session = store.get_session(session_id, &self.identity_id).ok()??; + let contact = session.contact_hash; + if contact.is_empty() { + None + } else { + Some(contact) + } + } + + /// Persist state + cache the envelope for resend + emit WS events after a + /// successful LXMF send. + pub fn commit_action(&self, action: &PreparedGameAction) { + self.persist_session_from_state(&action.app_id, &action.session_id); + if let Ok(mut cache) = self.last_envelope.lock() { + cache.insert(action.session_id.clone(), action.envelope_bytes.clone()); + // Cap resend cache so deleted/abandoned sessions cannot retain + // envelopes for the process lifetime. + while cache.len() > MAX_LAST_ENVELOPES { + let victim = cache.keys().find(|k| *k != &action.session_id).cloned(); + match victim { + Some(k) => { + cache.remove(&k); + } + None => break, + } + } + } + self.emit_action_result(&action.app_id, &action.session_id, true, None); + self.emit_update(&action.app_id, &action.session_id, "outbound"); + } + + /// Reverse a `prepare_action` mutation after a failed LXMF send. + pub fn rollback_action(&self, action: PreparedGameAction) { + if let Err(e) = self.router.rollback_outgoing( + &action.app_id, + &action.session_id, + &self.identity_id, + action.snapshot, + ) { + tracing::warn!(target: "games", "lrgp rollback_outgoing failed: {e}"); + } + } + + pub fn emit_action_result( + &self, + app_id: &str, + session_id: &str, + ok: bool, + error: Option<&str>, + ) { + let mut payload = + serde_json::json!({ "app_id": app_id, "session_id": session_id, "ok": ok }); + if let Some(err) = error { + if let Some(obj) = payload.as_object_mut() { + obj.insert("error".into(), serde_json::json!(err)); + } + } + self.emit("games.action_result", &payload); + } + + fn emit_update(&self, app_id: &str, session_id: &str, direction: &str) { + let detail = self.session_detail(session_id); + let payload = serde_json::json!({ + "app_id": app_id, + "session_id": session_id, + "direction": direction, + "session": detail.get("session").cloned().unwrap_or(JsonValue::Null), + }); + self.emit("games.update", &payload); + } + + fn emit(&self, event_type: &str, payload: &JsonValue) { + let frame = serde_json::json!({ "type": event_type, "payload": payload }); + let _ = self.event_tx.send(frame.to_string()); + } + + /// Schedule SQLite persist off the LXMF delivery callback so it does not + /// run while `deliver_unpacked_lxmf` holds `router.lock()`. + fn schedule_persist_session(&self, app_id: &str, session_id: &str) { + if session_id.is_empty() || self.store.is_none() { + return; + } + let store = match &self.store { + Some(s) => Arc::clone(s), + None => return, + }; + let router = Arc::clone(&self.router); + let identity_id = self.identity_id.clone(); + let app_id = app_id.to_string(); + let session_id = session_id.to_string(); + let persist = move || { + persist_session_from_parts(&router, &store, &identity_id, &app_id, &session_id); + }; + match tokio::runtime::Handle::try_current() { + Ok(handle) => { + handle.spawn_blocking(persist); + } + Err(_) => { + // Unit tests may call handle_inbound_lxmf outside a Tokio runtime. + persist(); + } + } + } + + fn persist_session_from_state(&self, app_id: &str, session_id: &str) { + let Some(store) = &self.store else { + return; + }; + persist_session_from_parts(&self.router, store, &self.identity_id, app_id, session_id); + } +} + +fn persist_session_from_parts( + router: &LrgpRouter, + store: &LrgpStore, + identity_id: &str, + app_id: &str, + session_id: &str, +) { + if session_id.is_empty() { + return; + } + let Some(state) = router.with_app(app_id, |app| app.get_session_state(session_id, identity_id)) + else { + return; + }; + if state.is_empty() { + return; + } + if let Err(e) = save_session_from_state(store, session_id, identity_id, app_id, &state) { + tracing::warn!( + target: "games", + "failed to persist lrgp session {session_id} ({app_id}): {e}" + ); + } +} + +fn generate_session_id() -> String { + use rand::RngCore; + let mut buf = [0u8; 8]; + rand::thread_rng().fill_bytes(&mut buf); + hex::encode(buf) +} + +fn now_secs() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() +} + +/// Mirrors Ratspeak's `save_session_from_state` — persist the app's own +/// in-memory `get_session_state()` JSON snapshot into the SQLite mirror after +/// every dispatch (inbound or outbound) so list/detail endpoints stay current. +fn save_session_from_state( + store: &LrgpStore, + session_id: &str, + identity_id: &str, + app_id: &str, + state: &HashMap, +) -> Result<(), String> { + let app_version = state + .get("app_version") + .and_then(JsonValue::as_u64) + .unwrap_or(1) as u32; + let contact_hash = state + .get("contact_hash") + .and_then(JsonValue::as_str) + .unwrap_or_default(); + let initiator = state + .get("initiator") + .and_then(JsonValue::as_str) + .unwrap_or_default(); + let status = state + .get("status") + .and_then(JsonValue::as_str) + .unwrap_or("pending"); + let metadata: HashMap = state + .get("metadata") + .and_then(JsonValue::as_object) + .map(|m| m.clone().into_iter().collect()) + .unwrap_or_default(); + let unread = state.get("unread").and_then(JsonValue::as_i64).unwrap_or(0); + let created_at = state + .get("created_at") + .and_then(JsonValue::as_f64) + .unwrap_or_else(now_secs); + let updated_at = state + .get("updated_at") + .and_then(JsonValue::as_f64) + .unwrap_or_else(now_secs); + let last_action_at = state + .get("last_action_at") + .and_then(JsonValue::as_f64) + .unwrap_or_else(now_secs); + + store + .save_session( + session_id, + identity_id, + app_id, + app_version, + contact_hash, + initiator, + status, + &metadata, + unread, + created_at, + updated_at, + last_action_at, + ) + .map_err(|e| e.to_string()) +} + +fn json_to_rmpv(value: &JsonValue) -> rmpv::Value { + match value { + JsonValue::Null => rmpv::Value::Nil, + JsonValue::Bool(b) => rmpv::Value::Boolean(*b), + JsonValue::Number(n) => { + if let Some(i) = n.as_i64() { + rmpv::Value::Integer(i.into()) + } else if let Some(u) = n.as_u64() { + rmpv::Value::Integer(u.into()) + } else { + rmpv::Value::F64(n.as_f64().unwrap_or(0.0)) + } + } + JsonValue::String(s) => rmpv::Value::String(s.clone().into()), + JsonValue::Array(arr) => rmpv::Value::Array(arr.iter().map(json_to_rmpv).collect()), + JsonValue::Object(map) => rmpv::Value::Map( + map.iter() + .map(|(k, v)| (rmpv::Value::String(k.clone().into()), json_to_rmpv(v))) + .collect(), + ), + } +} + +/// Convert an HTTP request body's `payload` object into the +/// `HashMap` shape `LrgpRouter` expects. +fn json_payload_to_rmpv_map(value: Option<&JsonValue>) -> HashMap { + match value.and_then(JsonValue::as_object) { + Some(obj) => obj + .iter() + .map(|(k, v)| (k.clone(), json_to_rmpv(v))) + .collect(), + None => HashMap::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lrgp::constants::CMD_CHALLENGE; + + fn test_manager() -> (tempfile::TempDir, GamesSessionManager) { + let dir = tempfile::tempdir().expect("tempdir"); + let (event_tx, _rx) = broadcast::channel(16); + let manager = GamesSessionManager::spawn(dir.path(), "selfidentityhash".into(), event_tx); + (dir, manager) + } + + #[test] + fn router_registers_ttt_and_chess() { + let (_dir, manager) = test_manager(); + let apps = manager.router.list_apps(); + let ids: Vec<&str> = apps.iter().map(|m| m.app_id.as_str()).collect(); + assert!(ids.contains(&"ttt")); + assert!(ids.contains(&"chess")); + } + + #[test] + fn pack_extract_roundtrip_via_transport() { + let env = envelope::pack_envelope("ttt", 1, "challenge", "abc123", None, None); + let fields = transport::pack_into_fields(&env).expect("pack"); + let recovered = transport::extract_envelope(&fields) + .expect("extract") + .expect("some"); + assert_eq!( + envelope::value_as_str(recovered.get(KEY_COMMAND).unwrap()).unwrap(), + "challenge" + ); + } + + #[test] + fn local_reject_maps_invalid_move_on_empty_payload() { + let (_dir, manager) = test_manager(); + let dest = "a".repeat(32); + let err = manager + .prepare_action(&dest, "ttt", CMD_MOVE, Some("sess1"), None) + .expect_err("expected local reject"); + assert_eq!(err, ERR_INVALID_MOVE); + } + + #[test] + fn local_reject_maps_not_your_turn() { + let (_dir, manager) = test_manager(); + let dest = "b".repeat(32); + + // Our own outgoing challenge creates the session (turn unset yet). + let challenge = manager + .prepare_action(&dest, "ttt", CMD_CHALLENGE, None, None) + .expect("challenge prepared"); + manager.commit_action(&challenge); + + // Opponent's accept arrives inbound and hands the turn to them. + let mut payload = serde_json::Map::new(); + payload.insert("b".into(), JsonValue::String("_________".into())); + payload.insert("t".into(), JsonValue::String(dest.clone())); + let accept_payload = json_payload_to_rmpv_map(Some(&JsonValue::Object(payload))); + let accept_env = envelope::pack_envelope( + "ttt", + 1, + "accept", + &challenge.session_id, + Some(accept_payload), + None, + ); + let accept_fields: BTreeMap> = transport::pack_into_fields(&accept_env) + .expect("pack accept") + .into_iter() + .collect(); + assert!(manager.handle_inbound_lxmf(&accept_fields, &dest, "")); + + let err = manager + .prepare_action( + &dest, + "ttt", + CMD_MOVE, + Some(&challenge.session_id), + Some(&serde_json::json!({ "i": 0 })), + ) + .expect_err("expected not_your_turn"); + assert_eq!(err, ERR_NOT_YOUR_TURN); + } + + #[test] + fn handle_inbound_for_non_lrgp_returns_false() { + let (_dir, manager) = test_manager(); + let fields: BTreeMap> = BTreeMap::new(); + assert!(!manager.handle_inbound_lxmf(&fields, "peer", "hello")); + } + + #[test] + fn status_reports_enabled_when_store_opens() { + let (_dir, manager) = test_manager(); + let status = manager.status(); + assert_eq!(status["available"], true); + assert_eq!(status["enabled"], true); + } + + #[test] + fn list_apps_includes_both_builtin_games() { + let (_dir, manager) = test_manager(); + let apps = manager.list_apps(); + let ids: Vec = apps["apps"] + .as_array() + .unwrap() + .iter() + .map(|v| v["app_id"].as_str().unwrap_or_default().to_string()) + .collect(); + assert!(ids.contains(&"ttt".to_string())); + assert!(ids.contains(&"chess".to_string())); + } +} diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 5468a1621..66f48e27c 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -36,6 +36,7 @@ use super::announce_ws_coalesce::{ AnnounceWsCoalescer, AnnounceWsRow, build_announce_received_frame, resolve_announce_aspect, }; use super::config; +use super::games_session::GamesSessionManager; use super::local_rnode_primary; use super::lxmf_delivery::{ LXMF_APP, PROPAGATION_SYNC_ANNOUNCE_SETTLE, send_lxmf_delivery_announce, @@ -135,6 +136,7 @@ pub struct LiveBridge { rnsh_session: Arc, rncp_transfer: Arc, voice_session: Arc, + games_session: Arc, /// Local Nomad page/file host (rsNomad / nomad-core). nomad_server: Arc, /// Shared persisted stack state (Nomad node list, prefs). @@ -309,6 +311,12 @@ impl LiveBridge { apply_pn_hosting_policy_to_router(&mut router, &pn_hosting_policy); router.set_transport(handle.transport_tx.clone()); + let games_session = Arc::new(GamesSessionManager::spawn( + &storage_dir, + lxmf_hash_hex.clone(), + event_tx.clone(), + )); + let cache_for_cb = peer_via_cache.clone(); let name_cache_for_cb = display_name_cache.clone(); let event_tx_cb = event_tx.clone(); @@ -316,11 +324,15 @@ impl LiveBridge { let self_hash_cb = lxmf_hash_hex.clone(); let self_name_cb = display_name.clone(); let config_dir_for_cb = config_dir.clone(); + let games_session_cb = games_session.clone(); router.register_delivery_callback(move |msg| { if !msg.incoming { return; } let sender_hex = hex::encode(msg.source_hash); + if games_session_cb.handle_inbound_lxmf(&msg.fields, &sender_hex, &msg.content) { + return; + } // Match path-table iface name to local config (same as outbound) so // TCP hubs named e.g. "RNS Testnet" classify as tcp, not network. let received_via = cache_for_cb @@ -455,6 +467,7 @@ impl LiveBridge { &identity, event_tx.clone(), )), + games_session, nomad_server: Arc::new(NomadServerHandle::new()), persisted: inner.clone(), #[cfg(feature = "rns-ble")] @@ -2174,6 +2187,36 @@ impl LiveBridge { .await } + #[allow(clippy::unused_async)] // sync GamesSessionManager call awaited by StackHandle API + pub async fn games_status(&self) -> serde_json::Value { + self.games_session.status() + } + + #[allow(clippy::unused_async)] // sync GamesSessionManager call awaited by StackHandle API + pub async fn games_apps(&self) -> serde_json::Value { + self.games_session.list_apps() + } + + #[allow(clippy::unused_async)] // sync GamesSessionManager call awaited by StackHandle API + pub async fn games_sessions(&self, peer: Option<&str>) -> serde_json::Value { + self.games_session.list_sessions(peer) + } + + #[allow(clippy::unused_async)] // sync GamesSessionManager call awaited by StackHandle API + pub async fn games_session_detail(&self, session_id: &str) -> serde_json::Value { + self.games_session.session_detail(session_id) + } + + #[allow(clippy::unused_async)] // sync GamesSessionManager call awaited by StackHandle API + pub async fn games_mark_read(&self, session_id: &str) -> Result<(), String> { + self.games_session.mark_read(session_id) + } + + #[allow(clippy::unused_async)] // sync GamesSessionManager call awaited by StackHandle API + pub async fn games_delete_session(&self, session_id: &str) -> Result<(), String> { + self.games_session.delete_session(session_id) + } + pub async fn rncp_send(&self, destination_hash_hex: &str, path: &str) -> serde_json::Value { match self.rncp_transfer.send(destination_hash_hex, path).await { Ok(transfer_id) => serde_json::json!({ "ok": true, "transfer_id": transfer_id }), @@ -2933,6 +2976,204 @@ impl LiveBridge { Ok((msg, hash_hex)) } + /// Like [`prepare_signed_outbound_lxmf`](Self::prepare_signed_outbound_lxmf), but + /// stamps arbitrary custom fields (e.g. LRGP's `FIELD_CUSTOM_TYPE` / `FIELD_CUSTOM_META` + /// envelope bytes) before signing so they are covered by the message hash. + fn prepare_signed_outbound_lxmf_with_fields( + &self, + dest: [u8; 16], + title: &str, + content: &str, + method: DeliveryMethod, + fields: &HashMap>, + ) -> Result<(LxMessage, String), String> { + let mut msg = LxMessage::new( + dest, + parse_hash16(&self.lxmf_hash_hex)?, + title, + content, + method, + ); + for (&field_id, bytes) in fields { + msg.set_field(field_id, bytes.clone()); + } + let signing_key = self + .identity + .get_signing_key() + .ok_or_else(|| "lxmf sign: identity has no signing key".to_string())?; + msg.sign(&signing_key) + .map_err(|e| format!("lxmf sign: {e:?}"))?; + let hash_hex = msg + .hash + .map(hex::encode) + .ok_or_else(|| "lxmf hash missing after sign".to_string())?; + Ok((msg, hash_hex)) + } + + /// Resolve a Direct-vs-Propagated delivery method for an LRGP action, mirroring + /// [`send_lxmf`](Self::send_lxmf)'s fallback chain. On `Err`, the caller must + /// roll back the prepared action and return the JSON payload as-is. + async fn resolve_game_delivery_method( + &self, + dest_hash: &str, + ) -> Result { + let (mut has_path, mut identity_known) = self + .outbound + .lock() + .map(|d| (d.has_path_to(dest_hash), d.identity_known_for(dest_hash))) + .unwrap_or((false, false)); + + let preferred_pn_hash = { + let router = self.router.lock().await; + router.outbound_propagation_node.map(hex::encode) + }; + let preferred_pn_set = preferred_pn_hash.is_some(); + if let Some(ref pn_hex) = preferred_pn_hash { + let _ = self.refresh_pn_announce_costs(pn_hex).await; + } + + if !has_path { + has_path = self.ensure_path_for_direct(dest_hash, false).await; + } + if has_path && !identity_known { + identity_known = self.ensure_identity_for_direct(dest_hash).await; + } + + match lxmf_outbound::choose_lxmf_send_route(has_path, identity_known, preferred_pn_set) { + lxmf_outbound::LxmfSendRoute::Direct => Ok(DeliveryMethod::Direct), + lxmf_outbound::LxmfSendRoute::Propagated => Ok(DeliveryMethod::Propagated), + lxmf_outbound::LxmfSendRoute::NoPropagationNode => Err(serde_json::json!({ + "ok": false, + "error": "no_propagation_node", + "destination_hash": dest_hash, + })), + } + } + + /// Dispatch an LRGP game action: validate/snapshot via [`GamesSessionManager`], + /// send the resulting envelope over LXMF (Direct-preferred, Propagated fallback), + /// then commit or roll back the session mutation based on send outcome. + pub async fn send_game_action( + &self, + dest_hash: &str, + app_id: &str, + command: &str, + session_id: Option<&str>, + payload: Option<&serde_json::Value>, + ) -> Result { + let action = match self + .games_session + .prepare_action(dest_hash, app_id, command, session_id, payload) + { + Ok(a) => a, + Err(e) => return Ok(serde_json::json!({ "ok": false, "error": e })), + }; + + let dest = match parse_hash16(&action.dest_hash) { + Ok(d) => d, + Err(e) => { + self.games_session.rollback_action(action); + return Err(e); + } + }; + + let delivery_method = match self.resolve_game_delivery_method(&action.dest_hash).await { + Ok(m) => m, + Err(no_route_json) => { + self.games_session.rollback_action(action); + return Ok(no_route_json); + } + }; + + let (msg, message_hash_hex) = match self.prepare_signed_outbound_lxmf_with_fields( + dest, + "", + &action.fallback_text, + delivery_method, + &action.fields, + ) { + Ok(v) => v, + Err(e) => { + self.games_session.rollback_action(action); + return Err(e); + } + }; + + let send_result = { + let mut router = self.router.lock().await; + let res = router.try_send(msg); + if res.is_ok() { + if let Ok(mut driver) = self.outbound.lock() { + driver.process_tick(&mut router, &self.event_tx); + } + } + res + }; + if let Err(e) = send_result { + self.games_session.rollback_action(action); + return Err(format!("lxmf game action send: {e:?}")); + } + + self.games_session.commit_action(&action); + + Ok(serde_json::json!({ + "ok": true, + "app_id": action.app_id, + "session_id": action.session_id, + "destination_hash": action.dest_hash, + "message_hash": message_hash_hex, + })) + } + + /// Resend the last *successfully sent* envelope for a session verbatim + /// (same nonce). Envelopes are only cached after a successful LXMF send + /// (`commit_action`); failed sends are rolled back and leave no resend + /// cache entry. Does not re-dispatch game logic. + pub async fn resend_last_game_action( + &self, + session_id: &str, + ) -> Result { + let resend = match self.games_session.prepare_resend(session_id) { + Ok(r) => r, + Err(e) => return Ok(serde_json::json!({ "ok": false, "error": e })), + }; + + let dest = parse_hash16(&resend.dest_hash)?; + let delivery_method = match self.resolve_game_delivery_method(&resend.dest_hash).await { + Ok(m) => m, + Err(no_route_json) => return Ok(no_route_json), + }; + + let (msg, message_hash_hex) = self.prepare_signed_outbound_lxmf_with_fields( + dest, + "", + &resend.fallback_text, + delivery_method, + &resend.fields, + )?; + + { + let mut router = self.router.lock().await; + router + .try_send(msg) + .map_err(|e| format!("lxmf game resend: {e:?}"))?; + if let Ok(mut driver) = self.outbound.lock() { + driver.process_tick(&mut router, &self.event_tx); + } + } + + self.games_session + .emit_action_result(&resend.app_id, session_id, true, None); + + Ok(serde_json::json!({ + "ok": true, + "app_id": resend.app_id, + "session_id": session_id, + "destination_hash": resend.dest_hash, + "message_hash": message_hash_hex, + })) + } + pub async fn send_reaction( &self, req: &LxmfReactionRequest, diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index baa577704..cc4176743 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -32,6 +32,8 @@ mod topology; mod types; mod via; +#[cfg(feature = "rns-stack")] +mod games_session; #[cfg(feature = "rns-stack")] mod link_task; #[cfg(feature = "rns-stack")] @@ -2590,15 +2592,89 @@ impl StackHandle { serde_json::json!({ "ok": false, "error": "voice requires live rns-stack sidecar" }) } - #[allow(clippy::unused_async)] // async matches StackHandle feature-status API awaited by HTTP handlers pub async fn games_status(&self) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.games_status().await; + } serde_json::json!({ - "available": true, + "available": cfg!(feature = "rns-stack"), "enabled": false, - "reason": "LRGP games pending lrgp-rs integration" + "reason": "LRGP games require a live rns-stack sidecar" }) } + pub async fn games_apps(&self) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.games_apps().await; + } + serde_json::json!({ "apps": [] }) + } + + pub async fn games_sessions(&self, peer: Option<&str>) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.games_sessions(peer).await; + } + let _ = peer; + serde_json::json!({ "sessions": [] }) + } + + pub async fn games_session_detail(&self, session_id: &str) -> serde_json::Value { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.games_session_detail(session_id).await; + } + let _ = session_id; + serde_json::json!({ "session": null }) + } + + pub async fn games_send_action( + &self, + dest_hash: &str, + app_id: &str, + command: &str, + session_id: Option<&str>, + payload: Option<&serde_json::Value>, + ) -> Result { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live + .send_game_action(dest_hash, app_id, command, session_id, payload) + .await; + } + let _ = (dest_hash, app_id, command, session_id, payload); + Err("LRGP games require a live rns-stack sidecar".to_string()) + } + + pub async fn games_resend_action(&self, session_id: &str) -> Result { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.resend_last_game_action(session_id).await; + } + let _ = session_id; + Err("LRGP games require a live rns-stack sidecar".to_string()) + } + + pub async fn games_mark_read(&self, session_id: &str) -> Result<(), String> { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.games_mark_read(session_id).await; + } + let _ = session_id; + Err("LRGP games require a live rns-stack sidecar".to_string()) + } + + pub async fn games_delete_session(&self, session_id: &str) -> Result<(), String> { + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + return live.games_delete_session(session_id).await; + } + let _ = session_id; + Err("LRGP games require a live rns-stack sidecar".to_string()) + } + pub async fn list_identities(&self) -> serde_json::Value { let identity = self.inner.read().await.identity.clone(); let identities = identity_slots::list_slot_rows(&self.config_dir, &identity); diff --git a/scripts/clone-ratspeak-stack.sh b/scripts/clone-ratspeak-stack.sh index d344150bb..001efd92b 100755 --- a/scripts/clone-ratspeak-stack.sh +++ b/scripts/clone-ratspeak-stack.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Clone rsReticulum + rsLXMF + rsNomad + rsLXST (float origin/main by default), +# Clone rsReticulum + rsLXMF + rsNomad + rsLXST + lrgp-rs (float origin/main by default), # then apply mesh-client overlays for rns-stack sidecar builds. set -euo pipefail @@ -13,6 +13,7 @@ RNS_DIR="${WORKSPACE_ROOT}/rsReticulum" LXMF_DIR="${WORKSPACE_ROOT}/rsLXMF" NOMAD_DIR="${WORKSPACE_ROOT}/rsNomad" LXST_DIR="${WORKSPACE_ROOT}/rsLXST" +LRGP_DIR="${WORKSPACE_ROOT}/lrgp-rs" # So apply-*.sh targets the same siblings as this script (WORKSPACE_ROOT may differ from ..). export RS_RETICULUM_DIR="${RNS_DIR}" @@ -23,6 +24,7 @@ RS_RETICULUM_REF="${RS_RETICULUM_REF:-}" RS_LXMF_REF="${RS_LXMF_REF:-}" RS_NOMAD_REF="${RS_NOMAD_REF:-}" RS_LXST_REF="${RS_LXST_REF:-}" +RS_LRGP_REF="${RS_LRGP_REF:-}" # Last selected ref from ensure_repo (origin/main, origin/master, or pin). ENSURE_REPO_SELECTED_REF='' @@ -116,7 +118,7 @@ if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then return 0 fi -echo "Preparing Ratspeak stack (rsReticulum/rsLXMF/rsNomad/rsLXST float origin/main unless RS_*_REF set)..." +echo "Preparing Ratspeak stack (rsReticulum/rsLXMF/rsNomad/rsLXST/lrgp-rs float origin/main unless RS_*_REF set)..." ensure_repo "${RNS_DIR}" 'https://github.com/ratspeak/rsReticulum.git' \ "${RS_RETICULUM_REF}" 'rsReticulum' rns_mode="$(format_repo_mode "${ENSURE_REPO_SELECTED_REF}" "${RS_RETICULUM_REF}")" @@ -137,9 +139,14 @@ nomad_mode="$(format_repo_mode "${ENSURE_REPO_SELECTED_REF}" "${RS_NOMAD_REF}")" ensure_repo "${LXST_DIR}" 'https://github.com/ratspeak/rsLXST.git' "${RS_LXST_REF}" 'rsLXST' lxst_mode="$(format_repo_mode "${ENSURE_REPO_SELECTED_REF}" "${RS_LXST_REF}")" +# lrgp-rs (LRGP games) — required for rns-stack games; float origin/main unless RS_LRGP_REF set. +ensure_repo "${LRGP_DIR}" 'https://github.com/ratspeak/lrgp-rs.git' "${RS_LRGP_REF}" 'lrgp-rs' +lrgp_mode="$(format_repo_mode "${ENSURE_REPO_SELECTED_REF}" "${RS_LRGP_REF}")" + rns_sha="$(git -C "${RNS_DIR}" rev-parse HEAD)" lxmf_sha="$(git -C "${LXMF_DIR}" rev-parse HEAD)" nomad_sha="$(git -C "${NOMAD_DIR}" rev-parse HEAD)" lxst_sha="$(git -C "${LXST_DIR}" rev-parse HEAD)" -echo "Ratspeak stack ready: rsReticulum @ ${rns_sha:0:12} (${rns_mode}), rsLXMF @ ${lxmf_sha:0:12} (${lxmf_mode}), rsNomad @ ${nomad_sha:0:12} (${nomad_mode}), rsLXST @ ${lxst_sha:0:12} (${lxst_mode})" -echo "Ratspeak stack SHAs (full): rsReticulum=${rns_sha} rsLXMF=${lxmf_sha} rsNomad=${nomad_sha} rsLXST=${lxst_sha}" +lrgp_sha="$(git -C "${LRGP_DIR}" rev-parse HEAD)" +echo "Ratspeak stack ready: rsReticulum @ ${rns_sha:0:12} (${rns_mode}), rsLXMF @ ${lxmf_sha:0:12} (${lxmf_mode}), rsNomad @ ${nomad_sha:0:12} (${nomad_mode}), rsLXST @ ${lxst_sha:0:12} (${lxst_mode}), lrgp-rs @ ${lrgp_sha:0:12} (${lrgp_mode})" +echo "Ratspeak stack SHAs (full): rsReticulum=${rns_sha} rsLXMF=${lxmf_sha} rsNomad=${nomad_sha} rsLXST=${lxst_sha} lrgp-rs=${lrgp_sha}" diff --git a/scripts/clone-ratspeak-stack.test.mjs b/scripts/clone-ratspeak-stack.test.mjs index c8bf84c36..2c3a6584e 100644 --- a/scripts/clone-ratspeak-stack.test.mjs +++ b/scripts/clone-ratspeak-stack.test.mjs @@ -142,6 +142,21 @@ describe('clone-ratspeak-stack.sh float policy', () => { expect(git(dest, 'rev-parse', 'HEAD')).toBe(pinSha); }); + it('clones lrgp-rs with optional RS_LRGP_REF pin support', () => { + expect(cloneScript).toContain('LRGP_DIR='); + expect(cloneScript).toMatch(/RS_LRGP_REF="\$\{RS_LRGP_REF:-\}"/); + expect(cloneScript).toContain('https://github.com/ratspeak/lrgp-rs.git'); + expect(cloneScript).toContain( + `ensure_repo "\${LRGP_DIR}" 'https://github.com/ratspeak/lrgp-rs.git' "\${RS_LRGP_REF}" 'lrgp-rs'`, + ); + expect(cloneScript).toContain('lrgp-rs @'); + const { remote, tipSha } = createLocalRemote({ defaultBranch: 'main' }); + const dest = join(makeTempDir('workspace-'), 'lrgp-rs'); + const out = runEnsureRepo({ remoteUrl: remote, destDir: dest }); + expect(out).toContain('SELECTED=origin/main'); + expect(out).toContain(`SHA=${tipSha}`); + }); + it('applies rsReticulum and rsLXMF overlays after checkout via shared list', () => { expect(cloneScript).toContain('apply_ratspeak_rns_overlays'); expect(cloneScript).toContain('apply_ratspeak_lxmf_overlays'); diff --git a/scripts/i18n-unused-keys.mjs b/scripts/i18n-unused-keys.mjs index 1ef694b6a..8e1337149 100644 --- a/scripts/i18n-unused-keys.mjs +++ b/scripts/i18n-unused-keys.mjs @@ -52,6 +52,10 @@ export const DYNAMIC_T_PREFIXES = [ { prefix: 'reticulumRemote.transfer.status.', leafKeys: true }, { prefix: 'reticulumRemote.settings.inboundMode.', leafKeys: true }, { prefix: 'reticulumRemote.settings.decision.', leafKeys: true }, + { prefix: 'gamesPanel.apps.', leafKeys: true }, + { prefix: 'gamesPanel.status.', leafKeys: true }, + { prefix: 'gamesPanel.filters.', leafKeys: true }, + { prefix: 'gamesPanel.chess.pieceNames.', leafKeys: true }, ]; export function flatten(obj, prefix = '') { diff --git a/scripts/update.sh b/scripts/update.sh index 0654e24a1..466e97fde 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -106,16 +106,18 @@ rebuild_reticulum_sidecar() { echo 'cargo not on PATH — skipping Reticulum sidecar rebuild.' return 0 fi - echo 'Preparing rsReticulum, rsLXMF, and rsNomad functionality check...' + echo 'Preparing rsReticulum, rsLXMF, rsNomad, rsLXST, and lrgp-rs functionality check...' local sidecar_dir='reticulum-sidecar' - # Paths match reticulum-sidecar/Cargo.toml (../../rs* from the sidecar dir). + # Paths match reticulum-sidecar/Cargo.toml (../../rs* / ../../lrgp-rs from the sidecar dir). local rns_runtime='../../rsReticulum/crates/rns-runtime/Cargo.toml' local lxmf_core='../../rsLXMF/crates/lxmf-core/Cargo.toml' local nomad_core='../../rsNomad/crates/nomad-core/Cargo.toml' + local lxst_telephony='../../rsLXST/crates/lxst-telephony/Cargo.toml' + local lrgp_crate='../../lrgp-rs/Cargo.toml' bash scripts/clone-ratspeak-stack.sh local missing_manifest='' local manifest - for manifest in "${rns_runtime}" "${lxmf_core}" "${nomad_core}"; do + for manifest in "${rns_runtime}" "${lxmf_core}" "${nomad_core}" "${lxst_telephony}" "${lrgp_crate}"; do if [ ! -f "${sidecar_dir}/${manifest}" ]; then missing_manifest="${sidecar_dir}/${manifest}" break @@ -125,7 +127,7 @@ rebuild_reticulum_sidecar() { echo "Error: required rs stack manifest missing after preparation: ${missing_manifest}" >&2 return 1 fi - echo 'Checking rsReticulum, rsLXMF, and rsNomad via full-feature sidecar build...' + echo 'Checking rsReticulum, rsLXMF, rsNomad, rsLXST, and lrgp-rs via full-feature sidecar build...' (cd "${sidecar_dir}" && cargo build --features rns-stack,rns-ble,rns-rnode-tcp) if [ "${CLEAN_SIDECAR_TARGET}" = '1' ]; then echo 'CLEAN_SIDECAR_TARGET=1: removing reticulum-sidecar/target (next sidecar build will be cold)...' @@ -406,11 +408,12 @@ process.stdin.on("end", () => { # Curated release watch + known org repos (keep in sync when adopting new ratspeak libs). # Format: "owner/repo|stub-kind-or-empty|display-label" # stub-kind: games → warn while mesh-client still has sidecar stubs only. -# (voice cleared after lxst-telephony integration; empty stub field = informational.) +# stub-kind: games-parity → non-fatal reminder to review Ratspeak Games tab vs mesh-client. +# (voice/games stubs cleared after lxst-telephony / lrgp-rs integration; empty stub = informational.) RATSPEAK_RELEASE_WATCH_ENTRIES=( 'ratspeak/rsLXST||rsLXST voice (lxst-telephony)' - 'ratspeak/lrgp-rs|games|lrgp-rs games (sidecar stub)' - 'ratspeak/Ratspeak||Ratspeak client (reference)' + 'ratspeak/lrgp-rs||lrgp-rs games (LRGP)' + 'ratspeak/Ratspeak|games-parity|Ratspeak client (review Games tab parity)' 'ratspeak/LXMFace||LXMFace identicons (vendored in renderer)' ) @@ -471,6 +474,14 @@ check_ratspeak_upstream() { echo " Reason tracked: mesh-client still stubs this feature; review integrating ${repo} @ ${tag}" has_upstream_warning=1 HAS_WARNING=1 + elif [ "${stub}" = 'games-parity' ]; then + warn_box "${label}" "Games parity review" "${tag} available" "${url}" + echo " Reason tracked: compare Ratspeak Games tab with mesh-client:" + echo " crates/ratspeak-tauri/src/commands/games.rs" + echo " dashboard/static/js/games_tab.js" + echo " docs/reticulum-games-parity.md" + has_upstream_warning=1 + HAS_WARNING=1 fi done diff --git a/scripts/update.test.mjs b/scripts/update.test.mjs index a15dadef1..5a9cf8d28 100644 --- a/scripts/update.test.mjs +++ b/scripts/update.test.mjs @@ -98,9 +98,13 @@ describe('update.sh Reticulum stack functionality check', () => { expect(result.status, result.stderr || result.stdout).toBe(0); expect(result.stdout).toContain('RATSPEAK_RELEASE_WATCH_ENTRIES:'); expect(result.stdout).toContain('ratspeak/rsLXST||rsLXST voice (lxst-telephony)'); - expect(result.stdout).toContain('ratspeak/lrgp-rs|games|'); - expect(result.stdout).toContain('ratspeak/Ratspeak||'); + expect(result.stdout).toContain('ratspeak/lrgp-rs||lrgp-rs games (LRGP)'); + expect(result.stdout).toContain( + 'ratspeak/Ratspeak|games-parity|Ratspeak client (review Games tab parity)', + ); expect(result.stdout).toContain('ratspeak/LXMFace||'); + expect(updateScript).toContain('"${stub}" = \'games-parity\''); + expect(updateScript).toContain('docs/reticulum-games-parity.md'); expect(result.stdout).toContain('RATSPEAK_KNOWN_ORG_REPOS:'); expect(result.stdout).toContain(' rsReticulum'); expect(result.stdout).toContain(' rsLXMF'); @@ -316,11 +320,13 @@ exit 0 path.join(work, 'reticulum-sidecar', 'Cargo.toml'), '[package]\nname = "mesh-client-reticulum"\n', ); - // Path deps are ../../rs* from reticulum-sidecar → siblings of mesh-client. + // Path deps are ../../rs* / ../../lrgp-rs from reticulum-sidecar → siblings of mesh-client. for (const rel of [ 'rsReticulum/crates/rns-runtime/Cargo.toml', 'rsLXMF/crates/lxmf-core/Cargo.toml', 'rsNomad/crates/nomad-core/Cargo.toml', + 'rsLXST/crates/lxst-telephony/Cargo.toml', + 'lrgp-rs/Cargo.toml', ]) { const abs = path.join(root, rel); mkdirSync(path.dirname(abs), { recursive: true }); diff --git a/src/main/index.contract.test.ts b/src/main/index.contract.test.ts index 3362d45cf..596543293 100644 --- a/src/main/index.contract.test.ts +++ b/src/main/index.contract.test.ts @@ -318,6 +318,8 @@ describe('Reticulum sidecar IPC handlers (source contract)', () => { expect(PRELOAD_SOURCE).toContain('rrc:'); expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:proxyPost'"); expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:voiceSendAudio'"); + expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:gamesStatus'"); + expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:gamesAction'"); expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:proxyPut'"); expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:proxyDelete'"); expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:readDefaultConfigFile'"); diff --git a/src/main/ipc/reticulum-handlers.ts b/src/main/ipc/reticulum-handlers.ts index 36bfc1b9f..2bb2c115f 100644 --- a/src/main/ipc/reticulum-handlers.ts +++ b/src/main/ipc/reticulum-handlers.ts @@ -1,6 +1,7 @@ import type { BrowserWindow } from 'electron'; import { ipcMain, shell } from 'electron'; +import { isGamesApiPath, parseGamesActionRequest } from '../../shared/games-types'; import type { ReticulumSidecarStartOptions, ReticulumSidecarStatus, @@ -54,10 +55,27 @@ const reticulumVoiceAudioIpcRateLimit = createIpcRateLimiter({ label: 'reticulum:voiceSendAudio', }); +/** + * LRGP games control/poll traffic. Own bucket so session polls + moves do not + * starve the shared 300/min reticulum proxy ceiling. + */ +const reticulumGamesIpcRateLimit = createIpcRateLimiter({ + max: 600, + windowMs: MS_PER_MINUTE, + label: 'reticulum:games', +}); + function isVoiceAudioApiPath(apiPath: string): boolean { return apiPath === VOICE_AUDIO_API_PATH; } +function assertGamesSessionId(sessionId: unknown): string | { error: string } { + if (typeof sessionId !== 'string' || sessionId.length === 0 || sessionId.length > 128) { + return { error: 'invalid_session_id' }; + } + return sessionId; +} + export interface ReticulumIpcDeps { idleStatus: ReticulumSidecarStatus; ensureManager: () => ReticulumSidecarManager; @@ -197,6 +215,9 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { assertIpcSender(event, 'reticulum:proxyGet'); reticulumProxyIpcRateLimit.checkOrThrow(); const pathArg = assertProxyApiPath(apiPath); + if (isGamesApiPath(pathArg)) { + throw new Error('LRGP games require reticulum:games* IPC channels'); + } try { const m = ensureManager(); return await m.proxyGet(pathArg); @@ -218,6 +239,9 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { if (isVoiceAudioApiPath(pathArg)) { throw new Error('voice PCM ingest requires reticulum:voiceSendAudio'); } + if (isGamesApiPath(pathArg)) { + throw new Error('LRGP games require reticulum:games* IPC channels'); + } try { const m = ensureManager(); return await m.proxyPost(pathArg, body); @@ -281,6 +305,9 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { assertIpcSender(event, 'reticulum:proxyDelete'); reticulumProxyIpcRateLimit.checkOrThrow(); const pathArg = assertProxyApiPath(apiPath); + if (isGamesApiPath(pathArg)) { + throw new Error('LRGP games require reticulum:games* IPC channels'); + } try { const m = ensureManager(); return await m.proxyDelete(pathArg); @@ -290,6 +317,123 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { } }); + /** Dedicated LRGP games channels — blocked on generic proxyGet/Post/Delete. */ + ipcMain.handle('reticulum:gamesStatus', async (event) => { + assertIpcSender(event, 'reticulum:gamesStatus'); + reticulumGamesIpcRateLimit.checkOrThrow(); + try { + return await ensureManager().proxyGet('/api/v1/games/status'); + } catch (err) { + // catch-no-log-ok settleReticulumProxyFailure logs expected failures / rethrows unexpected + return settleReticulumProxyFailure('gamesStatus', err, '/api/v1/games/status'); + } + }); + + ipcMain.handle('reticulum:gamesApps', async (event) => { + assertIpcSender(event, 'reticulum:gamesApps'); + reticulumGamesIpcRateLimit.checkOrThrow(); + try { + return await ensureManager().proxyGet('/api/v1/games/apps'); + } catch (err) { + // catch-no-log-ok settleReticulumProxyFailure logs expected failures / rethrows unexpected + return settleReticulumProxyFailure('gamesApps', err, '/api/v1/games/apps'); + } + }); + + ipcMain.handle('reticulum:gamesSessions', async (event, peer: unknown) => { + assertIpcSender(event, 'reticulum:gamesSessions'); + reticulumGamesIpcRateLimit.checkOrThrow(); + const q = + typeof peer === 'string' && peer.length > 0 + ? `/api/v1/games/sessions?peer=${encodeURIComponent(peer)}` + : '/api/v1/games/sessions'; + try { + return await ensureManager().proxyGet(q); + } catch (err) { + // catch-no-log-ok settleReticulumProxyFailure logs expected failures / rethrows unexpected + return settleReticulumProxyFailure('gamesSessions', err, q); + } + }); + + ipcMain.handle('reticulum:gamesSessionDetail', async (event, sessionId: unknown) => { + assertIpcSender(event, 'reticulum:gamesSessionDetail'); + reticulumGamesIpcRateLimit.checkOrThrow(); + const idOrErr = assertGamesSessionId(sessionId); + if (typeof idOrErr !== 'string') { + return { ok: false, error: idOrErr.error }; + } + const path = `/api/v1/games/sessions/${encodeURIComponent(idOrErr)}`; + try { + return await ensureManager().proxyGet(path); + } catch (err) { + // catch-no-log-ok settleReticulumProxyFailure logs expected failures / rethrows unexpected + return settleReticulumProxyFailure('gamesSessionDetail', err, path); + } + }); + + ipcMain.handle('reticulum:gamesAction', async (event, opts: unknown) => { + assertIpcSender(event, 'reticulum:gamesAction'); + reticulumGamesIpcRateLimit.checkOrThrow(); + const parsed = parseGamesActionRequest(opts); + if ('error' in parsed) { + return { ok: false, error: parsed.error }; + } + try { + return await ensureManager().proxyPost('/api/v1/games/action', parsed); + } catch (err) { + // catch-no-log-ok settleReticulumProxyFailure logs expected failures / rethrows unexpected + return settleReticulumProxyFailure('gamesAction', err, '/api/v1/games/action'); + } + }); + + ipcMain.handle('reticulum:gamesResend', async (event, sessionId: unknown) => { + assertIpcSender(event, 'reticulum:gamesResend'); + reticulumGamesIpcRateLimit.checkOrThrow(); + const idOrErr = assertGamesSessionId(sessionId); + if (typeof idOrErr !== 'string') { + return { ok: false, error: idOrErr.error }; + } + const path = `/api/v1/games/sessions/${encodeURIComponent(idOrErr)}/resend`; + try { + return await ensureManager().proxyPost(path, {}); + } catch (err) { + // catch-no-log-ok settleReticulumProxyFailure logs expected failures / rethrows unexpected + return settleReticulumProxyFailure('gamesResend', err, path); + } + }); + + ipcMain.handle('reticulum:gamesMarkRead', async (event, sessionId: unknown) => { + assertIpcSender(event, 'reticulum:gamesMarkRead'); + reticulumGamesIpcRateLimit.checkOrThrow(); + const idOrErr = assertGamesSessionId(sessionId); + if (typeof idOrErr !== 'string') { + return { ok: false, error: idOrErr.error }; + } + const path = `/api/v1/games/sessions/${encodeURIComponent(idOrErr)}/read`; + try { + return await ensureManager().proxyPost(path, {}); + } catch (err) { + // catch-no-log-ok settleReticulumProxyFailure logs expected failures / rethrows unexpected + return settleReticulumProxyFailure('gamesMarkRead', err, path); + } + }); + + ipcMain.handle('reticulum:gamesDeleteSession', async (event, sessionId: unknown) => { + assertIpcSender(event, 'reticulum:gamesDeleteSession'); + reticulumGamesIpcRateLimit.checkOrThrow(); + const idOrErr = assertGamesSessionId(sessionId); + if (typeof idOrErr !== 'string') { + return { ok: false, error: idOrErr.error }; + } + const path = `/api/v1/games/sessions/${encodeURIComponent(idOrErr)}`; + try { + return await ensureManager().proxyDelete(path); + } catch (err) { + // catch-no-log-ok settleReticulumProxyFailure logs expected failures / rethrows unexpected + return settleReticulumProxyFailure('gamesDeleteSession', err, path); + } + }); + ipcMain.handle('reticulum:readDefaultConfigFile', (event) => { assertIpcSender(event, 'reticulum:readDefaultConfigFile'); return readFirstExistingConfig(); diff --git a/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts b/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts index 3f7566c98..ab1c4f598 100644 --- a/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts +++ b/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts @@ -53,6 +53,22 @@ describe('reticulum proxy rate limit + 100k peer ceilings (source contract)', () expect(preload).not.toMatch(/invoke\('reticulum:proxyPost',\s*'\/api\/v1\/voice\/audio'/); }); + it('routes LRGP games through dedicated IPC with its own rate limit', () => { + expect(HANDLERS_SOURCE).toMatch( + /const reticulumGamesIpcRateLimit = createIpcRateLimiter\(\{\s*max:\s*600,[\s\S]*?label:\s*'reticulum:games'/, + ); + expect(HANDLERS_SOURCE).toContain('reticulumGamesIpcRateLimit.checkOrThrow()'); + expect(HANDLERS_SOURCE).toContain('LRGP games require reticulum:games* IPC channels'); + expect(HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:gamesStatus'"); + expect(HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:gamesAction'"); + expect(HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:gamesDeleteSession'"); + const preload = readFileSync(join(__dirname, '../../preload/index.ts'), 'utf-8'); + expect(preload).toContain("ipcRenderer.invoke('reticulum:gamesStatus'"); + expect(preload).toContain("ipcRenderer.invoke('reticulum:gamesAction'"); + expect(preload).not.toMatch(/invoke\('reticulum:proxyGet'[\s\S]*?\/api\/v1\/games/); + expect(preload).not.toMatch(/invoke\('reticulum:proxyPost'[\s\S]*?\/api\/v1\/games/); + }); + it('aligns sidecar peer cache and WS added batch with ~100k scale', () => { expect(SIDECAR_STACK_SOURCE).toMatch(/const MAX_PEER_CACHE: usize = 100_000;/); expect(SIDECAR_LIVE_SOURCE).toMatch(/const MAX_PEERS_UPDATED_ADDED: usize = 4096;/); diff --git a/src/preload/index.ts b/src/preload/index.ts index 4b6f6dff3..29f1dd5db 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1197,6 +1197,29 @@ contextBridge.exposeInMainWorld('electronAPI', { sendAudio: (opts: { profile?: number; channels: number; samples_b64: string }) => unwrapReticulumProxy(ipcRenderer.invoke('reticulum:voiceSendAudio', opts)), }, + /** LRGP games — dedicated IPC (blocked on generic proxy). */ + games: { + getStatus: () => unwrapReticulumProxy(ipcRenderer.invoke('reticulum:gamesStatus')), + listApps: () => unwrapReticulumProxy(ipcRenderer.invoke('reticulum:gamesApps')), + listSessions: (peer?: string) => + unwrapReticulumProxy(ipcRenderer.invoke('reticulum:gamesSessions', peer)), + getSession: (sessionId: string) => + unwrapReticulumProxy(ipcRenderer.invoke('reticulum:gamesSessionDetail', sessionId)), + sendAction: (opts: { + dest_hash: string; + app_id: string; + command: string; + session_id?: string; + payload?: Record; + delivery_method?: string; + }) => unwrapReticulumProxy(ipcRenderer.invoke('reticulum:gamesAction', opts)), + resend: (sessionId: string) => + unwrapReticulumProxy(ipcRenderer.invoke('reticulum:gamesResend', sessionId)), + markRead: (sessionId: string) => + unwrapReticulumProxy(ipcRenderer.invoke('reticulum:gamesMarkRead', sessionId)), + deleteSession: (sessionId: string) => + unwrapReticulumProxy(ipcRenderer.invoke('reticulum:gamesDeleteSession', sessionId)), + }, rncp: { send: (opts: { destination_hash: string; path: string }) => ipcRenderer.invoke('reticulum:rncpSend', opts), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index db54ef1c2..22529937e 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -126,6 +126,7 @@ import { AppPanel, ChannelUtilizationChart, DiagnosticsPanel, + GamesPanel, MapPanel, ModulePanel, NomadNetworkPanel, @@ -159,6 +160,7 @@ import { computeTabMappings, DIAGNOSTICS_PANEL_INDEX, findFilteredTabIndexForPanel, + GAMES_PANEL_INDEX, GRAPH_PANEL_INDEX, MAP_TAB_PANEL_INDEX, MODULES_PANEL_INDEX, @@ -1921,6 +1923,7 @@ function AppContent() { const [chatTabVisited, setChatTabVisited] = useState(false); const [roomsTabVisited, setRoomsTabVisited] = useState(false); + const [gamesTabVisited, setGamesTabVisited] = useState(false); const [rrcTabVisited, setRrcTabVisited] = useState(false); const [remoteTabVisited, setRemoteTabVisited] = useState(false); const [nomadTabVisited, setNomadTabVisited] = useState(false); @@ -1938,6 +1941,7 @@ function AppContent() { // eslint-disable-next-line react-hooks/set-state-in-effect -- protocol switch clears tab visit state setChatTabVisited(false); setRoomsTabVisited(false); + setGamesTabVisited(false); setRrcTabVisited(false); setRemoteTabVisited(false); setNomadTabVisited(false); @@ -1952,6 +1956,13 @@ function AppContent() { } }, [activePanelIndex, capabilities.hasRoomServersPanel]); + useEffect(() => { + if (activePanelIndex === GAMES_PANEL_INDEX) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- track Games tab visit for keep-alive mount + setGamesTabVisited(true); + } + }, [activePanelIndex]); + useEffect(() => { if (activePanelIndex === RRC_PANEL_INDEX) { // eslint-disable-next-line react-hooks/set-state-in-effect -- track RRC tab visit for keep-alive mount @@ -3070,6 +3081,7 @@ function AppContent() { dmOnlyChat={capabilities.hasReticulumInterfaceConfig} hasRncpTransfer={capabilities.hasRncpTransfer} hasLxstVoice={capabilities.hasLxstVoice} + hasLrgpGames={capabilities.hasLrgpGames} showLxmfDeliveryStatus={capabilities.hasLxmfDeliveryStatus} showLxmfAttachmentLine={capabilities.hasReticulumInterfaceConfig} composerPayloadLimit={capabilities.lxmfPayloadLimit} @@ -3165,6 +3177,33 @@ function AppContent() { )} +
) : ( ) : null; + const gamesChallengeControl = + protocol === 'reticulum' && hasLrgpGames && reticulumDmDestinationHash != null ? ( + + ) : null; const peerDetailsAppearance = reticulumDmDestinationHash ? peerAppearanceByHash.get(reticulumDmDestinationHash) : undefined; @@ -2343,10 +2356,17 @@ function ChatPanel({ {t('chatPanel.openPeerDetails')} ) : null; - if (!pathBadge && !dmNode && !rncpControl && !voiceCallControl && !peerDetailsControl) { + if ( + !pathBadge && + !dmNode && + !rncpControl && + !voiceCallControl && + !gamesChallengeControl && + !peerDetailsControl + ) { return null; } - // Order: path status → last heard → peer details → Probe/Path → Call → Send file. + // Order: path status → last heard → peer details → Probe/Path → Call → Challenge → Send file. return (
{pathBadge} @@ -2354,6 +2374,7 @@ function ChatPanel({ {peerDetailsControl} {pathActions} {voiceCallControl} + {gamesChallengeControl} {rncpControl}
); diff --git a/src/renderer/components/GamesPanel.test.tsx b/src/renderer/components/GamesPanel.test.tsx new file mode 100644 index 000000000..723d7523f --- /dev/null +++ b/src/renderer/components/GamesPanel.test.tsx @@ -0,0 +1,225 @@ +import { act, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { axe } from 'vitest-axe'; + +import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers'; +import { useReticulumGamesStore } from '@/renderer/stores/reticulumGamesStore'; +import type { GameSession } from '@/shared/games-types'; + +import GamesPanel from './GamesPanel'; + +const peerHash = 'a'.repeat(32); + +function makeSession(overrides: Partial = {}): GameSession { + return { + session_id: 's1', + identity_id: 'me', + app_id: 'ttt', + app_version: 1, + contact_hash: peerHash, + initiator: 'me', + status: 'active', + metadata: { + board: '_________', + turn: 'me', + first_turn: 'me', + my_marker: 'X', + move_count: 0, + winner: '', + terminal: '', + draw_offered: false, + }, + unread: 0, + created_at: 1, + updated_at: 1, + last_action_at: 1, + ...overrides, + }; +} + +async function renderAndSelectSession(session: GameSession) { + vi.mocked(window.electronAPI.reticulum.games.listSessions).mockResolvedValue({ + sessions: [session], + }); + render(); + const row = await screen.findByRole('button', { name: new RegExp(`game with`, 'i') }); + await userEvent.click(row); +} + +describe('GamesPanel', () => { + beforeEach(() => { + useReticulumGamesStore.getState().clear(); + hydrateAxeThemeColors(document.documentElement); + vi.mocked(window.electronAPI.reticulum.games.getStatus).mockClear(); + vi.mocked(window.electronAPI.reticulum.games.getStatus).mockResolvedValue({ + available: true, + enabled: true, + running: true, + }); + vi.mocked(window.electronAPI.reticulum.games.listApps).mockClear(); + vi.mocked(window.electronAPI.reticulum.games.listApps).mockResolvedValue({ apps: [] }); + vi.mocked(window.electronAPI.reticulum.games.listSessions).mockClear(); + vi.mocked(window.electronAPI.reticulum.games.listSessions).mockResolvedValue({ sessions: [] }); + vi.mocked(window.electronAPI.reticulum.games.sendAction).mockClear(); + vi.mocked(window.electronAPI.reticulum.games.sendAction).mockResolvedValue({ ok: true }); + vi.mocked(window.electronAPI.reticulum.games.markRead).mockClear(); + vi.mocked(window.electronAPI.reticulum.games.markRead).mockResolvedValue({ ok: true }); + vi.mocked(window.electronAPI.reticulum.games.resend).mockClear(); + vi.mocked(window.electronAPI.reticulum.games.resend).mockResolvedValue({ ok: true }); + vi.mocked(window.electronAPI.reticulum.games.deleteSession).mockClear(); + vi.mocked(window.electronAPI.reticulum.games.deleteSession).mockResolvedValue({ ok: true }); + }); + + it('renders the empty state with no axe violations', async () => { + const { container } = render(); + await waitFor(() => { + expect(window.electronAPI.reticulum.games.listSessions).toHaveBeenCalled(); + }); + expect(screen.getByText('No game sessions yet.')).toBeInTheDocument(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('lists sessions and shows the selected session board', async () => { + await renderAndSelectSession(makeSession()); + + expect(screen.getByRole('group', { name: 'Tic-Tac-Toe board' })).toBeInTheDocument(); + }); + + it('sends a challenge action for a valid peer hash', async () => { + render(); + + const input = screen.getByLabelText('Peer destination hash'); + await userEvent.type(input, peerHash); + await userEvent.click(screen.getByRole('button', { name: 'Send challenge' })); + + await waitFor(() => { + expect(window.electronAPI.reticulum.games.sendAction).toHaveBeenCalledWith( + expect.objectContaining({ dest_hash: peerHash, app_id: 'ttt', command: 'challenge' }), + ); + }); + }); + + it('sends a move via the tic-tac-toe board when clicking a cell', async () => { + await renderAndSelectSession(makeSession()); + + await userEvent.click(screen.getByRole('button', { name: 'Cell 1, empty' })); + + await waitFor(() => { + expect(window.electronAPI.reticulum.games.sendAction).toHaveBeenCalledWith( + expect.objectContaining({ + dest_hash: peerHash, + app_id: 'ttt', + command: 'move', + session_id: 's1', + payload: { i: 0 }, + }), + ); + }); + }); + + it('shows Accept/Decline for a pending session that was not initiated locally', async () => { + await renderAndSelectSession(makeSession({ status: 'pending', initiator: peerHash })); + + await userEvent.click(screen.getByRole('button', { name: 'Accept challenge' })); + + await waitFor(() => { + expect(window.electronAPI.reticulum.games.sendAction).toHaveBeenCalledWith( + expect.objectContaining({ command: 'accept', session_id: 's1' }), + ); + }); + }); + + it('resigns after confirming the resign modal', async () => { + await renderAndSelectSession(makeSession({ status: 'active' })); + + await userEvent.click(screen.getByRole('button', { name: 'Resign' })); + const dialog = screen.getByRole('alertdialog', { name: 'Resign game?' }); + expect(dialog).toBeInTheDocument(); + await userEvent.click(within(dialog).getByRole('button', { name: 'Resign' })); + + await waitFor(() => { + expect(window.electronAPI.reticulum.games.sendAction).toHaveBeenCalledWith( + expect.objectContaining({ command: 'resign', session_id: 's1' }), + ); + }); + }); + + it('sends draw accept and decline when draw_offered metadata is set', async () => { + await renderAndSelectSession( + makeSession({ + metadata: { + board: '_________', + turn: 'me', + first_turn: 'me', + my_marker: 'X', + move_count: 0, + winner: '', + terminal: '', + draw_offered: true, + }, + }), + ); + + await userEvent.click(screen.getByRole('button', { name: 'Accept draw offer' })); + await waitFor(() => { + expect(window.electronAPI.reticulum.games.sendAction).toHaveBeenCalledWith( + expect.objectContaining({ command: 'draw_accept', session_id: 's1' }), + ); + }); + + vi.mocked(window.electronAPI.reticulum.games.sendAction).mockClear(); + await userEvent.click(screen.getByRole('button', { name: 'Decline draw offer' })); + await waitFor(() => { + expect(window.electronAPI.reticulum.games.sendAction).toHaveBeenCalledWith( + expect.objectContaining({ command: 'draw_decline', session_id: 's1' }), + ); + }); + }); + + it('shows resend after a failed action and triggers resend', async () => { + vi.mocked(window.electronAPI.reticulum.games.sendAction).mockResolvedValue({ + ok: false, + session_id: 's1', + error: 'send_failed', + }); + vi.mocked(window.electronAPI.reticulum.games.resend).mockResolvedValue({ ok: true }); + + await renderAndSelectSession(makeSession()); + await userEvent.click(screen.getByRole('button', { name: 'Cell 1, empty' })); + + await waitFor(() => { + expect(window.electronAPI.reticulum.games.sendAction).toHaveBeenCalled(); + }); + + // Simulate WS action_result for the failed send so the resend control appears. + act(() => { + useReticulumGamesStore.getState().applyActionResult({ + app_id: 'ttt', + session_id: 's1', + ok: false, + error: 'send_failed', + }); + }); + + const resend = await screen.findByRole('button', { name: 'Resend last action' }); + await userEvent.click(resend); + + await waitFor(() => { + expect(window.electronAPI.reticulum.games.resend).toHaveBeenCalledWith('s1'); + }); + }); + + it('deletes a session only after confirmation', async () => { + vi.mocked(window.electronAPI.reticulum.games.deleteSession).mockResolvedValue({ ok: true }); + await renderAndSelectSession(makeSession()); + + await userEvent.click(screen.getByRole('button', { name: 'Delete session' })); + const dialog = screen.getByRole('alertdialog', { name: 'Delete session?' }); + await userEvent.click(within(dialog).getByRole('button', { name: 'Delete session' })); + + await waitFor(() => { + expect(window.electronAPI.reticulum.games.deleteSession).toHaveBeenCalledWith('s1'); + }); + }); +}); diff --git a/src/renderer/components/GamesPanel.tsx b/src/renderer/components/GamesPanel.tsx new file mode 100644 index 000000000..e243e0e7c --- /dev/null +++ b/src/renderer/components/GamesPanel.tsx @@ -0,0 +1,394 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ConfirmModal } from '@/renderer/components/ConfirmModal'; +import { ChessBoard } from '@/renderer/components/games/ChessBoard'; +import { TicTacToeBoard } from '@/renderer/components/games/TicTacToeBoard'; +import { + gamesMetaBool, + isGamesSessionInitiator, +} from '@/renderer/lib/reticulum/reticulumGamesMetadata'; +import { + deleteGamesSession, + markGamesSessionRead, + refreshGamesApps, + refreshGamesSessions, + refreshGamesStatus, + resendGamesAction, + sendGamesAction, + sendGamesChallenge, +} from '@/renderer/lib/reticulum/reticulumGamesSession'; +import { useReticulumGamesStore } from '@/renderer/stores/reticulumGamesStore'; +import { GAMES_CMD, type GamesAppId, type GameSession } from '@/shared/games-types'; + +export interface GamesPanelProps { + isActive: boolean; +} + +type GamesFilter = 'all' | 'active' | 'pending' | 'completed'; + +const GAMES_FILTERS: GamesFilter[] = ['all', 'active', 'pending', 'completed']; +const COMPLETED_STATUSES = new Set(['completed', 'expired', 'declined']); +const CHALLENGE_APPS: GamesAppId[] = ['ttt', 'chess']; + +function matchesFilter(session: GameSession, filter: GamesFilter): boolean { + if (filter === 'all') return true; + if (filter === 'active') return session.status === 'active'; + if (filter === 'pending') return session.status === 'pending'; + return COMPLETED_STATUSES.has(session.status); +} + +function sessionPeerLabel(session: GameSession): string { + return session.contact_hash ? session.contact_hash.slice(0, 10) : session.session_id.slice(0, 8); +} + +export default function GamesPanel({ isActive }: GamesPanelProps) { + const { t } = useTranslation(); + const sessions = useReticulumGamesStore((s) => s.sessions); + const selectedSessionId = useReticulumGamesStore((s) => s.selectedSessionId); + const actionBusy = useReticulumGamesStore((s) => s.actionBusy); + const lastActionResult = useReticulumGamesStore((s) => s.lastActionResult); + const selectSession = useReticulumGamesStore((s) => s.selectSession); + + const [filter, setFilter] = useState('all'); + const [challengeHash, setChallengeHash] = useState(''); + const [challengeApp, setChallengeApp] = useState('ttt'); + const [confirmResign, setConfirmResign] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + + useEffect(() => { + if (!isActive) return; + void refreshGamesStatus(); + void refreshGamesApps(); + void refreshGamesSessions(); + }, [isActive]); + + const selectedSession = useMemo( + () => sessions.find((row) => row.session_id === selectedSessionId) ?? null, + [sessions, selectedSessionId], + ); + + useEffect(() => { + if (isActive && selectedSession && selectedSession.unread > 0) { + void markGamesSessionRead(selectedSession.session_id); + } + }, [isActive, selectedSession]); + + const filteredSessions = useMemo( + () => sessions.filter((row) => matchesFilter(row, filter)), + [sessions, filter], + ); + + async function handleSendChallenge() { + const ok = await sendGamesChallenge(challengeHash, challengeApp); + if (ok) setChallengeHash(''); + } + + function handleMove(payload: Record) { + if (!selectedSession) return; + void sendGamesAction({ + destHash: selectedSession.contact_hash, + appId: selectedSession.app_id, + command: GAMES_CMD.MOVE, + sessionId: selectedSession.session_id, + payload, + }); + } + + function handleCommand(command: string) { + if (!selectedSession) return; + void sendGamesAction({ + destHash: selectedSession.contact_hash, + appId: selectedSession.app_id, + command, + sessionId: selectedSession.session_id, + }); + } + + const showResend = + selectedSession != null && + lastActionResult != null && + !lastActionResult.ok && + lastActionResult.session_id === selectedSession.session_id; + const drawOffered = selectedSession + ? gamesMetaBool(selectedSession.metadata, 'draw_offered') + : false; + + return ( +
+ +
+ {!selectedSession ? ( +
{t('gamesPanel.selectSessionPrompt')}
+ ) : ( + <> +
+ {t('gamesPanel.opponentLabel', { peer: sessionPeerLabel(selectedSession) })} +
+ {selectedSession.app_id === 'chess' ? ( + { + handleMove({ m }); + }} + /> + ) : ( + { + handleMove({ i }); + }} + /> + )} +
+ {selectedSession.status === 'pending' && + !isGamesSessionInitiator(selectedSession) && ( + <> + + + + )} + {selectedSession.status === 'active' && ( + <> + + {drawOffered ? ( + <> + + + + ) : ( + + )} + + )} + {showResend && ( + + )} + +
+ + )} +
+ {confirmResign && selectedSession && ( + { + setConfirmResign(false); + }} + onConfirm={() => { + setConfirmResign(false); + handleCommand(GAMES_CMD.RESIGN); + }} + /> + )} + {confirmDelete && selectedSession && ( + { + setConfirmDelete(false); + }} + onConfirm={() => { + setConfirmDelete(false); + void deleteGamesSession(selectedSession.session_id); + }} + /> + )} +
+ ); +} diff --git a/src/renderer/components/ReticulumPeerListPanel.tsx b/src/renderer/components/ReticulumPeerListPanel.tsx index bf159caf7..b4334a7f6 100644 --- a/src/renderer/components/ReticulumPeerListPanel.tsx +++ b/src/renderer/components/ReticulumPeerListPanel.tsx @@ -54,6 +54,7 @@ import { resolveReticulumPeerLabel, useReticulumPeerStore, } from '../stores/reticulumPeerStore'; +import { ReticulumGameChallengeButton } from './reticulum/ReticulumGameChallengeButton'; import { ReticulumPeerPathsDetail } from './reticulum/ReticulumPeerPathsDetail'; import { ReticulumVoiceCallButton } from './reticulum/ReticulumVoiceCallButton'; import { ReticulumProfileIconSlot } from './ReticulumProfileIcon'; @@ -82,6 +83,8 @@ export interface ReticulumPeerListPanelProps { contactGroupsEnabled?: boolean; /** LXST voice Call button on each peer row. */ hasLxstVoice?: boolean; + /** LRGP games Challenge button on each peer row. */ + hasLrgpGames?: boolean; } function peerHashToNodeNum(hash: string): number { @@ -242,6 +245,7 @@ export default function ReticulumPeerListPanel({ groupMemberIds, contactGroupsEnabled = false, hasLxstVoice = false, + hasLrgpGames = false, }: ReticulumPeerListPanelProps) { const { t } = useTranslation(); const { addToast } = useToast(); @@ -589,6 +593,12 @@ export default function ReticulumPeerListPanel({ disabled={busy || !isConnected} /> ) : null} + {hasLrgpGames ? ( + + ) : null} + ); + }), + )} +
+ {isActive && legalMoves.length > 0 && ( +
+ {legalMoves.map((move) => ( + + ))} +
+ )} + {drawOffered && isActive && ( +
{t('gamesPanel.drawOfferedBanner')}
+ )} + + ); +} + +function pieceAt(board: string[][], square: string): string { + const file = FILES.indexOf(square.charAt(0)); + const rank = Number(square.charAt(1)); + if (file < 0 || !Number.isFinite(rank)) return ''; + const rankIdx = 8 - rank; + return board[rankIdx]?.[file] ?? ''; +} diff --git a/src/renderer/components/games/TicTacToeBoard.test.tsx b/src/renderer/components/games/TicTacToeBoard.test.tsx new file mode 100644 index 000000000..60c35e27e --- /dev/null +++ b/src/renderer/components/games/TicTacToeBoard.test.tsx @@ -0,0 +1,149 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { axe } from 'vitest-axe'; + +import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers'; +import type { GameSession } from '@/shared/games-types'; + +import { TicTacToeBoard } from './TicTacToeBoard'; + +function makeSession(overrides: Partial = {}): GameSession { + return { + session_id: 's1', + identity_id: 'me', + app_id: 'ttt', + app_version: 1, + contact_hash: 'a'.repeat(32), + initiator: 'me', + status: 'active', + metadata: { + board: '_________', + turn: 'me', + first_turn: 'me', + my_marker: 'X', + move_count: 0, + winner: '', + terminal: '', + draw_offered: false, + }, + unread: 0, + created_at: 1, + updated_at: 1, + last_action_at: 1, + ...overrides, + }; +} + +describe('TicTacToeBoard', () => { + it('renders an empty board with no axe violations', async () => { + const { container } = render(); + hydrateAxeThemeColors(document.documentElement); + expect(screen.getByRole('group', { name: 'Tic-Tac-Toe board' })).toBeInTheDocument(); + expect(screen.getByText('Your turn')).toBeInTheDocument(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('calls onMove with the cell index when an empty cell is clicked', async () => { + const onMove = vi.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Cell 5, empty' })); + + expect(onMove).toHaveBeenCalledWith(4); + }); + + it('disables occupied cells and does not call onMove', async () => { + const onMove = vi.fn(); + render( + , + ); + + const cell1 = screen.getByRole('button', { name: 'Cell 1, X' }); + expect(cell1).toBeDisabled(); + await userEvent.click(cell1); + expect(onMove).not.toHaveBeenCalled(); + }); + + it('disables all cells and shows the result when it is not my turn', () => { + const onMove = vi.fn(); + render( + , + ); + + expect(screen.getByText("Opponent's turn")).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cell 2, empty' })).toBeDisabled(); + }); + + it('shows the win message and disables the board on terminal win', () => { + render( + , + ); + + expect(screen.getByText('You won!')).toBeInTheDocument(); + }); + + it('shows the draw-offered banner when active and a draw was offered', () => { + render( + , + ); + + expect(screen.getByText('Your opponent offered a draw.')).toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/games/TicTacToeBoard.tsx b/src/renderer/components/games/TicTacToeBoard.tsx new file mode 100644 index 000000000..174ae08db --- /dev/null +++ b/src/renderer/components/games/TicTacToeBoard.tsx @@ -0,0 +1,92 @@ +import { useTranslation } from 'react-i18next'; + +import { + gamesMetaBool, + gamesMetaNum, + gamesMetaStr, +} from '@/renderer/lib/reticulum/reticulumGamesMetadata'; +import type { GameSession } from '@/shared/games-types'; + +export interface TicTacToeBoardProps { + session: GameSession; + onMove: (cellIndex: number) => void; + disabled?: boolean; +} + +const EMPTY_CELL = '_'; + +/** LRGP `ttt` app board — 9-char board string, moves sent as `{ i: cellIndex }`. */ +export function TicTacToeBoard({ session, onMove, disabled = false }: TicTacToeBoardProps) { + const { t } = useTranslation(); + const metadata = session.metadata; + const board = gamesMetaStr(metadata, 'board', '_________'); + const myMarker = gamesMetaStr(metadata, 'my_marker'); + const turn = gamesMetaStr(metadata, 'turn'); + const terminal = gamesMetaStr(metadata, 'terminal'); + const winner = gamesMetaStr(metadata, 'winner'); + const drawOffered = gamesMetaBool(metadata, 'draw_offered'); + const moveCount = gamesMetaNum(metadata, 'move_count'); + + const isActive = session.status === 'active'; + const isMyTurn = isActive && turn === session.identity_id; + const cells = board.padEnd(9, EMPTY_CELL).slice(0, 9).split(''); + + let statusText: string; + if (terminal === 'win') { + statusText = + winner === session.identity_id ? t('gamesPanel.ttt.youWon') : t('gamesPanel.ttt.opponentWon'); + } else if (terminal === 'draw') { + statusText = t('gamesPanel.ttt.draw'); + } else if (!isActive) { + statusText = t(`gamesPanel.status.${session.status}`, { + defaultValue: session.status, + }); + } else if (isMyTurn) { + statusText = t('gamesPanel.ttt.yourTurn'); + } else { + statusText = t('gamesPanel.ttt.opponentTurn'); + } + + return ( +
+
{statusText}
+ {myMarker && ( +
+ {t('gamesPanel.ttt.yourMarker', { marker: myMarker })} + {moveCount > 0 ? ` · ${t('gamesPanel.ttt.moveCount', { count: moveCount })}` : ''} +
+ )} +
+ {cells.map((cell, index) => { + const isEmpty = cell === EMPTY_CELL; + const cellDisabled = disabled || !isMyTurn || !isEmpty; + return ( + + ); + })} +
+ {drawOffered && isActive && ( +
{t('gamesPanel.drawOfferedBanner')}
+ )} +
+ ); +} diff --git a/src/renderer/components/reticulum/ReticulumGameChallengeButton.test.tsx b/src/renderer/components/reticulum/ReticulumGameChallengeButton.test.tsx new file mode 100644 index 000000000..5272283f9 --- /dev/null +++ b/src/renderer/components/reticulum/ReticulumGameChallengeButton.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { axe } from 'vitest-axe'; + +import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers'; + +import { ReticulumGameChallengeButton } from './ReticulumGameChallengeButton'; + +const peerHash = 'a'.repeat(32); + +describe('ReticulumGameChallengeButton', () => { + beforeEach(() => { + vi.mocked(window.electronAPI.reticulum.games.sendAction).mockClear(); + vi.mocked(window.electronAPI.reticulum.games.sendAction).mockResolvedValue({ ok: true }); + }); + + it('opens a menu with Tic-Tac-Toe and Chess and has no axe violations', async () => { + const user = userEvent.setup(); + const { container } = render(); + hydrateAxeThemeColors(container); + await user.click(screen.getByRole('button', { name: 'Challenge to a game' })); + expect(screen.getByRole('button', { name: 'Challenge to Tic-Tac-Toe' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Challenge to Chess' })).toBeInTheDocument(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('sends a chess challenge action when Chess is picked', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: 'Challenge to a game' })); + await user.click(screen.getByRole('button', { name: 'Challenge to Chess' })); + + expect(window.electronAPI.reticulum.games.sendAction).toHaveBeenCalledWith( + expect.objectContaining({ dest_hash: peerHash, app_id: 'chess', command: 'challenge' }), + ); + }); + + it('is disabled when the disabled prop is set', () => { + render(); + expect(screen.getByRole('button', { name: 'Challenge to a game' })).toBeDisabled(); + }); + + it('does not dispatch a challenge after becoming disabled with the menu open', async () => { + const user = userEvent.setup(); + const { rerender } = render(); + await user.click(screen.getByRole('button', { name: 'Challenge to a game' })); + expect(screen.getByRole('button', { name: 'Challenge to Chess' })).toBeInTheDocument(); + + rerender(); + expect(screen.queryByRole('button', { name: 'Challenge to Chess' })).not.toBeInTheDocument(); + expect(window.electronAPI.reticulum.games.sendAction).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/components/reticulum/ReticulumGameChallengeButton.tsx b/src/renderer/components/reticulum/ReticulumGameChallengeButton.tsx new file mode 100644 index 000000000..965697a30 --- /dev/null +++ b/src/renderer/components/reticulum/ReticulumGameChallengeButton.tsx @@ -0,0 +1,95 @@ +import { Gamepad2 } from 'lucide-react-motion'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { pushAppToast } from '@/renderer/components/Toast'; +import { sendGamesChallenge } from '@/renderer/lib/reticulum/reticulumGamesSession'; +import { RETICULUM_DM_HEADER_ACTION_CLASS } from '@/renderer/lib/reticulumDmHeaderActions'; +import type { GamesAppId } from '@/shared/games-types'; + +interface ReticulumGameChallengeButtonProps { + lxmfPeerHash: string; + disabled?: boolean; + className?: string; +} + +const CHALLENGE_APPS: GamesAppId[] = ['ttt', 'chess']; + +/** Compact "Challenge" control for Peers rows / Chat DM header — opens LRGP ttt/chess. */ +export function ReticulumGameChallengeButton({ + lxmfPeerHash, + disabled = false, + className = `${RETICULUM_DM_HEADER_ACTION_CLASS} ml-2`, +}: ReticulumGameChallengeButtonProps) { + const { t } = useTranslation(); + const [menuOpen, setMenuOpen] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + if (!menuOpen) return; + function onDocClick(e: MouseEvent) { + if (!containerRef.current?.contains(e.target as Node)) setMenuOpen(false); + } + document.addEventListener('mousedown', onDocClick); + return () => { + document.removeEventListener('mousedown', onDocClick); + }; + }, [menuOpen]); + + async function handleChallenge(appId: GamesAppId) { + if (disabled) return; + setMenuOpen(false); + const ok = await sendGamesChallenge(lxmfPeerHash, appId); + if (ok) { + pushAppToast( + t('gamesPanel.challengeSent', { + app: t(`gamesPanel.apps.${appId}`, { defaultValue: appId }), + }), + 'success', + ); + } + } + + const showMenu = menuOpen && !disabled; + + return ( +
+ + {showMenu && ( +
+ {CHALLENGE_APPS.map((appId) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/renderer/lazyTabPanels.ts b/src/renderer/lazyTabPanels.ts index afcdfdf59..d9856f994 100644 --- a/src/renderer/lazyTabPanels.ts +++ b/src/renderer/lazyTabPanels.ts @@ -10,6 +10,7 @@ export const RadioPanel = lazy(() => import('./components/RadioPanel')); export const ReticulumNetworkPanel = lazy(() => import('./components/ReticulumNetworkPanel')); export const ReticulumAdminPanel = lazy(() => import('./components/ReticulumAdminPanel')); export const NomadNetworkPanel = lazy(() => import('./components/NomadNetworkPanel')); +export const GamesPanel = lazy(() => import('./components/GamesPanel')); export const RrcPanel = lazy(() => import('./components/RrcPanel')); export const ReticulumRemotePanel = lazy(() => import('./components/ReticulumRemotePanel')); export const ReticulumPeerListPanel = lazy(() => import('./components/ReticulumPeerListPanel')); diff --git a/src/renderer/lib/appTabMappings.test.ts b/src/renderer/lib/appTabMappings.test.ts index cd577f3ba..2d4264363 100644 --- a/src/renderer/lib/appTabMappings.test.ts +++ b/src/renderer/lib/appTabMappings.test.ts @@ -104,6 +104,22 @@ describe('computeTabMappings', () => { expect(meshtasticTabs.tabIndexToPanelIndex).not.toContain(TAB_SLOT_IDS.indexOf('RRC')); }); + it('shows Games tab after Chat and before RRC for Reticulum only', () => { + const reticulumTabs = computeTabMappings(identityT, 'reticulum', RETICULUM_CAPABILITIES); + const gamesIndex = reticulumTabs.tabIndexToPanelIndex.indexOf(TAB_SLOT_IDS.indexOf('Games')); + const chatIndex = reticulumTabs.tabIndexToPanelIndex.indexOf(TAB_SLOT_IDS.indexOf('Chat')); + const rrcIndex = reticulumTabs.tabIndexToPanelIndex.indexOf(TAB_SLOT_IDS.indexOf('RRC')); + expect(RETICULUM_CAPABILITIES.hasLrgpGames).toBe(true); + expect(gamesIndex).toBeGreaterThan(chatIndex); + expect(rrcIndex).toBeGreaterThan(gamesIndex); + expect(reticulumTabs.displayTabLabels[gamesIndex]).toBe('tabs.games'); + + const meshtasticTabs = computeTabMappings(identityT, 'meshtastic', MESHTASTIC_CAPABILITIES); + const meshcoreTabs = computeTabMappings(identityT, 'meshcore', MESHCORE_CAPABILITIES); + expect(meshtasticTabs.tabIndexToPanelIndex).not.toContain(TAB_SLOT_IDS.indexOf('Games')); + expect(meshcoreTabs.tabIndexToPanelIndex).not.toContain(TAB_SLOT_IDS.indexOf('Games')); + }); + it('shows Meshtastic sidebar panels including Radio, Map, and Modules', () => { const tabs = computeTabMappings(identityT, 'meshtastic', MESHTASTIC_CAPABILITIES); const expectedSlots: (typeof TAB_SLOT_IDS)[number][] = [ diff --git a/src/renderer/lib/appTabMappings.ts b/src/renderer/lib/appTabMappings.ts index aa32ebc5b..5aa6ea188 100644 --- a/src/renderer/lib/appTabMappings.ts +++ b/src/renderer/lib/appTabMappings.ts @@ -4,6 +4,7 @@ import type { ProtocolCapabilities } from './radio/BaseRadioProvider'; import { TAB_SLOT_IDS, type TabIconSlotId } from './tabSlotIds'; import type { MeshProtocol } from './types'; +export const GAMES_PANEL_INDEX = TAB_SLOT_IDS.indexOf('Games'); export const RRC_PANEL_INDEX = TAB_SLOT_IDS.indexOf('RRC'); export const REMOTE_PANEL_INDEX = TAB_SLOT_IDS.indexOf('Remote'); export const NOMAD_NETWORK_PANEL_INDEX = TAB_SLOT_IDS.indexOf('NomadNetwork'); @@ -29,6 +30,7 @@ type TabCapabilityRequirement = keyof ProtocolCapabilities | { or: (keyof Protoc const TAB_CAPABILITY_REQUIREMENTS: (TabCapabilityRequirement | undefined)[] = [ undefined, // Connection undefined, // Chat + 'hasLrgpGames', // Games 'hasRrcPanel', // RRC 'hasNomadNetworkPanel', // Nomad Network 'hasReticulumRemotePanel', // Remote diff --git a/src/renderer/lib/icons/tabIcons.test.tsx b/src/renderer/lib/icons/tabIcons.test.tsx index 99070ac63..45238d34e 100644 --- a/src/renderer/lib/icons/tabIcons.test.tsx +++ b/src/renderer/lib/icons/tabIcons.test.tsx @@ -27,6 +27,7 @@ vi.mock('lucide-react-motion', async (importOriginal) => { Wifi: MockIcon, GitBranch: MockIcon, Shield: MockIcon, + Gamepad2: MockIcon, }; }); diff --git a/src/renderer/lib/icons/tabIcons.tsx b/src/renderer/lib/icons/tabIcons.tsx index 58ddccb97..e4f3045c4 100644 --- a/src/renderer/lib/icons/tabIcons.tsx +++ b/src/renderer/lib/icons/tabIcons.tsx @@ -5,6 +5,7 @@ import { Code, Crosshair, FileChartColumn, + Gamepad2, GitBranch, Globe, Hash, @@ -38,6 +39,8 @@ export function TabIcon({ name }: { name: string }) { return ; case 'Chat': return ; + case 'Games': + return ; case 'RRC': return ; case 'Remote': diff --git a/src/renderer/lib/radio/BaseRadioProvider.ts b/src/renderer/lib/radio/BaseRadioProvider.ts index 0a041fb4b..3b24b4405 100644 --- a/src/renderer/lib/radio/BaseRadioProvider.ts +++ b/src/renderer/lib/radio/BaseRadioProvider.ts @@ -149,6 +149,8 @@ export interface ProtocolCapabilities { hasRncpTransfer: boolean; /** Reticulum: LXST voice calls (Peers / Chat DM) */ hasLxstVoice: boolean; + /** Reticulum: LRGP games (Games tab, Peers / Chat DM Challenge) */ + hasLrgpGames: boolean; /** DM composer payload limit (Reticulum LXMF only) */ lxmfPayloadLimit?: number; } @@ -225,6 +227,7 @@ export const MESHTASTIC_CAPABILITIES: ProtocolCapabilities = { hasReticulumRemotePanel: false, hasRncpTransfer: false, hasLxstVoice: false, + hasLrgpGames: false, }; export const MESHCORE_CAPABILITIES: ProtocolCapabilities = { @@ -300,6 +303,7 @@ export const MESHCORE_CAPABILITIES: ProtocolCapabilities = { hasReticulumRemotePanel: false, hasRncpTransfer: false, hasLxstVoice: false, + hasLrgpGames: false, }; export const RETICULUM_CAPABILITIES: ProtocolCapabilities = { @@ -374,5 +378,6 @@ export const RETICULUM_CAPABILITIES: ProtocolCapabilities = { hasReticulumRemotePanel: true, hasRncpTransfer: true, hasLxstVoice: true, + hasLrgpGames: true, lxmfPayloadLimit: RETICULUM_LXMF_PAYLOAD_LIMIT, }; diff --git a/src/renderer/lib/radio/protocol-capabilities.test.ts b/src/renderer/lib/radio/protocol-capabilities.test.ts index fb3bd0cb3..45193feeb 100644 --- a/src/renderer/lib/radio/protocol-capabilities.test.ts +++ b/src/renderer/lib/radio/protocol-capabilities.test.ts @@ -87,6 +87,7 @@ const REQUIRED_CAPABILITY_KEYS: (keyof ProtocolCapabilities)[] = [ 'hasReticulumRemotePanel', 'hasRncpTransfer', 'hasLxstVoice', + 'hasLrgpGames', 'hasDiagnosticsPanel', 'nodeStaleThresholdMs', 'nodeOfflineThresholdMs', @@ -138,6 +139,7 @@ describe('ProtocolCapabilities contract', () => { "hasHopCount": true, "hasIpTunnel": true, "hasJsonRadioConfigImport": false, + "hasLrgpGames": false, "hasLxmfDeliveryStatus": false, "hasLxstVoice": false, "hasMapReport": true, @@ -219,6 +221,7 @@ describe('ProtocolCapabilities contract', () => { "hasHopCount": true, "hasIpTunnel": false, "hasJsonRadioConfigImport": true, + "hasLrgpGames": false, "hasLxmfDeliveryStatus": false, "hasLxstVoice": false, "hasMapReport": false, @@ -306,6 +309,7 @@ describe('ProtocolCapabilities contract', () => { "hasHopCount": false, "hasIpTunnel": false, "hasJsonRadioConfigImport": true, + "hasLrgpGames": true, "hasLxmfDeliveryStatus": true, "hasLxstVoice": true, "hasMapReport": false, diff --git a/src/renderer/lib/reticulum/clearReticulumSessionStores.test.ts b/src/renderer/lib/reticulum/clearReticulumSessionStores.test.ts index f8ba753b0..a7017a3e2 100644 --- a/src/renderer/lib/reticulum/clearReticulumSessionStores.test.ts +++ b/src/renderer/lib/reticulum/clearReticulumSessionStores.test.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useReticulumGamesStore } from '@/renderer/stores/reticulumGamesStore'; import { useReticulumVoiceStore } from '@/renderer/stores/reticulumVoiceStore'; import { clearReticulumSessionStores } from './clearReticulumSessionStores'; @@ -42,4 +43,26 @@ describe('clearReticulumSessionStores', () => { expect(hangup).not.toHaveBeenCalled(); expect(stopMedia).toHaveBeenCalled(); }); + + it('clears the games store', () => { + useReticulumGamesStore.getState().upsertSession({ + session_id: 's1', + identity_id: 'me', + app_id: 'ttt', + app_version: 1, + contact_hash: 'a'.repeat(32), + initiator: 'me', + status: 'pending', + metadata: {}, + unread: 1, + created_at: 1, + updated_at: 1, + last_action_at: 1, + }); + useReticulumGamesStore.getState().selectSession('s1'); + expect(useReticulumGamesStore.getState().sessions).toHaveLength(1); + clearReticulumSessionStores(); + expect(useReticulumGamesStore.getState().selectedSessionId).toBeNull(); + expect(useReticulumGamesStore.getState().sessions).toHaveLength(0); + }); }); diff --git a/src/renderer/lib/reticulum/clearReticulumSessionStores.ts b/src/renderer/lib/reticulum/clearReticulumSessionStores.ts index 37861c192..db5ed6bf6 100644 --- a/src/renderer/lib/reticulum/clearReticulumSessionStores.ts +++ b/src/renderer/lib/reticulum/clearReticulumSessionStores.ts @@ -1,6 +1,7 @@ import { releaseReticulumBleRnodeConnect } from '@/renderer/lib/reticulum/reticulumBleAdapterConflict'; import { stopReticulumVoiceMedia } from '@/renderer/lib/reticulumVoiceSession'; import { useReticulumDiscoveryMapStore } from '@/renderer/stores/reticulumDiscoveryMapStore'; +import { useReticulumGamesStore } from '@/renderer/stores/reticulumGamesStore'; import { useReticulumPeerStore } from '@/renderer/stores/reticulumPeerStore'; import { useReticulumVoiceStore } from '@/renderer/stores/reticulumVoiceStore'; import { useRncpTransferStore } from '@/renderer/stores/rncpTransferStore'; @@ -18,6 +19,7 @@ export function clearReticulumSessionStores(): void { useRrcHubStore.getState().clear(); useRnshSessionStore.getState().clearAll(); useRncpTransferStore.getState().clearAll(); + useReticulumGamesStore.getState().clear(); const voiceState = useReticulumVoiceStore.getState(); if (isReticulumVoiceSessionBusy(voiceState.activeCall ?? voiceState.incomingCall)) { // Best-effort sidecar hangup before local clear (stack may already be dead). diff --git a/src/renderer/lib/reticulum/reticulumGamesMetadata.ts b/src/renderer/lib/reticulum/reticulumGamesMetadata.ts new file mode 100644 index 000000000..31643244d --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumGamesMetadata.ts @@ -0,0 +1,41 @@ +/** Safe readers for LRGP `Session.metadata` (untyped JSON from the sidecar). */ + +export function gamesMetaStr( + metadata: Record | undefined, + key: string, + fallback = '', +): string { + const v = metadata?.[key]; + return typeof v === 'string' ? v : fallback; +} + +export function gamesMetaBool(metadata: Record | undefined, key: string): boolean { + return metadata?.[key] === true; +} + +export function gamesMetaNum( + metadata: Record | undefined, + key: string, + fallback = 0, +): number { + const v = metadata?.[key]; + return typeof v === 'number' && Number.isFinite(v) ? v : fallback; +} + +export function gamesMetaStrArray( + metadata: Record | undefined, + key: string, +): string[] { + const v = metadata?.[key]; + return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; +} + +/** True when the local player initiated (challenged) this session. */ +export function isGamesSessionInitiator(session: { + initiator: string; + identity_id: string; +}): boolean { + return Boolean( + session.initiator && session.identity_id && session.initiator === session.identity_id, + ); +} diff --git a/src/renderer/lib/reticulum/reticulumGamesSession.test.ts b/src/renderer/lib/reticulum/reticulumGamesSession.test.ts new file mode 100644 index 000000000..40e25ee9b --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumGamesSession.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/renderer/lib/i18n', () => ({ + default: { t: (key: string) => key }, +})); + +vi.mock('@/renderer/components/Toast', () => ({ + pushAppToast: vi.fn(), +})); + +import { useReticulumGamesStore } from '@/renderer/stores/reticulumGamesStore'; + +import { markGamesSessionRead } from './reticulumGamesSession'; + +function makeSession(overrides: Record = {}) { + return { + session_id: 's1', + identity_id: 'me', + app_id: 'ttt', + app_version: 1, + contact_hash: 'a'.repeat(32), + initiator: 'me', + status: 'active', + metadata: {}, + unread: 2, + created_at: 1, + updated_at: 10, + last_action_at: 10, + ...overrides, + }; +} + +describe('markGamesSessionRead', () => { + const markRead = vi.fn(); + + beforeEach(() => { + useReticulumGamesStore.getState().clear(); + markRead.mockReset(); + markRead.mockResolvedValue({ ok: true }); + Object.assign(window, { + electronAPI: { + reticulum: { + games: { markRead }, + }, + }, + }); + }); + + it('clears unread when the session revision is unchanged after markRead', async () => { + useReticulumGamesStore.getState().upsertSession(makeSession()); + await markGamesSessionRead('s1'); + expect(markRead).toHaveBeenCalledWith('s1'); + expect(useReticulumGamesStore.getState().sessions[0]).toEqual( + expect.objectContaining({ unread: 0 }), + ); + }); + + it('preserves unread when a games.update arrives during markRead', async () => { + useReticulumGamesStore.getState().upsertSession(makeSession()); + markRead.mockImplementation(() => { + useReticulumGamesStore.getState().applyGamesUpdate({ + app_id: 'ttt', + session_id: 's1', + direction: 'inbound', + session: makeSession({ unread: 3, updated_at: 20, last_action_at: 20, status: 'active' }), + }); + return Promise.resolve({ ok: true }); + }); + + await markGamesSessionRead('s1'); + + expect(useReticulumGamesStore.getState().sessions[0]).toEqual( + expect.objectContaining({ updated_at: 20, unread: 3 }), + ); + }); +}); diff --git a/src/renderer/lib/reticulum/reticulumGamesSession.ts b/src/renderer/lib/reticulum/reticulumGamesSession.ts new file mode 100644 index 000000000..67bcce1a7 --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumGamesSession.ts @@ -0,0 +1,140 @@ +import { pushAppToast } from '@/renderer/components/Toast'; +import i18n from '@/renderer/lib/i18n'; +import { useReticulumGamesStore } from '@/renderer/stores/reticulumGamesStore'; +import { GAMES_CMD, type GamesActionRequest, type GamesAppId } from '@/shared/games-types'; + +const DEST_HASH_RE = /^[0-9a-f]{32}$/; + +export function normalizeGamesDestHash(hash: string): string | null { + const v = hash.trim().toLowerCase(); + return DEST_HASH_RE.test(v) ? v : null; +} + +export async function refreshGamesStatus(): Promise { + try { + const status = await window.electronAPI.reticulum.games.getStatus(); + useReticulumGamesStore.getState().setStatus(status); + } catch (e) { + console.debug('[reticulumGamesSession] getStatus failed', e); + } +} + +export async function refreshGamesApps(): Promise { + try { + const res = await window.electronAPI.reticulum.games.listApps(); + const apps = 'apps' in res ? res.apps : undefined; + useReticulumGamesStore.getState().setApps(apps); + } catch (e) { + console.debug('[reticulumGamesSession] listApps failed', e); + } +} + +export async function refreshGamesSessions(peer?: string): Promise { + try { + const res = await window.electronAPI.reticulum.games.listSessions(peer); + useReticulumGamesStore.getState().setSessions(res.sessions); + } catch (e) { + console.debug('[reticulumGamesSession] listSessions failed', e); + } +} + +export interface SendGamesActionOpts { + destHash: string; + appId: string; + command: string; + sessionId?: string; + payload?: Record; +} + +/** Send an LRGP action over LXMF. Resolves `true` on success (`{ ok: true }`). */ +export async function sendGamesAction(opts: SendGamesActionOpts): Promise { + const destHash = normalizeGamesDestHash(opts.destHash); + if (!destHash) { + pushAppToast(i18n.t('gamesPanel.errors.invalidPeerHash'), 'error'); + return false; + } + const store = useReticulumGamesStore.getState(); + store.setActionBusy(true); + try { + const req: GamesActionRequest = { + dest_hash: destHash, + app_id: opts.appId, + command: opts.command, + session_id: opts.sessionId, + payload: opts.payload, + }; + const result = await window.electronAPI.reticulum.games.sendAction(req); + if (!result.ok) { + pushAppToast( + i18n.t('gamesPanel.errors.actionFailed', { + reason: result.error ?? result.reason ?? i18n.t('gamesPanel.errors.unknownReason'), + }), + 'error', + ); + return false; + } + return true; + } catch (e) { + console.warn('[reticulumGamesSession] sendAction failed', e); + pushAppToast( + i18n.t('gamesPanel.errors.actionFailed', { + reason: i18n.t('gamesPanel.errors.unknownReason'), + }), + 'error', + ); + return false; + } finally { + store.setActionBusy(false); + } +} + +export function sendGamesChallenge(destHash: string, appId: GamesAppId): Promise { + return sendGamesAction({ destHash, appId, command: GAMES_CMD.CHALLENGE }); +} + +export async function resendGamesAction(sessionId: string): Promise { + const store = useReticulumGamesStore.getState(); + store.setActionBusy(true); + try { + const result = await window.electronAPI.reticulum.games.resend(sessionId); + const ok = result.ok; + if (!ok) { + pushAppToast(i18n.t('gamesPanel.errors.resendFailed'), 'error'); + } + return ok; + } catch (e) { + console.warn('[reticulumGamesSession] resend failed', e); + pushAppToast(i18n.t('gamesPanel.errors.resendFailed'), 'error'); + return false; + } finally { + store.setActionBusy(false); + } +} + +export async function markGamesSessionRead(sessionId: string): Promise { + try { + const before = useReticulumGamesStore + .getState() + .sessions.find((row) => row.session_id === sessionId); + const revision = before?.updated_at; + await window.electronAPI.reticulum.games.markRead(sessionId); + const state = useReticulumGamesStore.getState(); + const session = state.sessions.find((row) => row.session_id === sessionId); + // Skip local unread clear when a newer games.update arrived during markRead. + if (session && session.updated_at === revision && session.unread !== 0) { + state.upsertSession({ ...session, unread: 0 }); + } + } catch (e) { + console.debug('[reticulumGamesSession] markRead failed', e); + } +} + +export async function deleteGamesSession(sessionId: string): Promise { + try { + await window.electronAPI.reticulum.games.deleteSession(sessionId); + useReticulumGamesStore.getState().removeSession(sessionId); + } catch (e) { + console.warn('[reticulumGamesSession] deleteSession failed', e); + pushAppToast(i18n.t('gamesPanel.errors.deleteFailed'), 'error'); + } +} diff --git a/src/renderer/lib/tabSlotIds.ts b/src/renderer/lib/tabSlotIds.ts index 430ef0e4b..900740fc1 100644 --- a/src/renderer/lib/tabSlotIds.ts +++ b/src/renderer/lib/tabSlotIds.ts @@ -2,6 +2,7 @@ export const TAB_SLOT_IDS = [ 'Connection', 'Chat', + 'Games', 'RRC', 'NomadNetwork', 'Remote', diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index f392711f9..2570824b6 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -4549,7 +4549,8 @@ "topology": "topologie sítě", "network": "Síť", "rrc": "RRC", - "remote": "Vzdálený přístup" + "remote": "Vzdálený přístup", + "games": "Herní aktivity" }, "takServerPanel": { "title": "Server TAK", @@ -5151,5 +5152,106 @@ }, "statsSeparator": "·", "ringing": "Vyzvání…" + }, + "gamesPanel": { + "title": "Herní aktivity", + "filters": { + "all": "Vše", + "active": "Aktivní", + "pending": "Čekající", + "completed": "Dokončeno" + }, + "noSessions": "Zatím žádné herní relace.", + "sessionRowAria": "{{app}} hra s {{peer}}", + "apps": { + "ttt": "Piškvorky", + "chess": "Šachy" + }, + "status": { + "pending": "Čekající", + "active": "Aktivní", + "completed": "Dokončeno", + "expired": "Platnost vypršela", + "declined": "Odmítnuto" + }, + "unreadBadgeAria": "{{count}} nepřečteno", + "newChallenge": "Nová výzva!", + "peerHashPlaceholder": "Partnerský cílový hash", + "peerHashAria": "Partnerský cílový hash", + "selectAppAria": "Select Game", + "sendChallengeAria": "Odeslat výzvu", + "sendChallenge": "Odeslat výzvu", + "selectSessionPrompt": "Vyberte relaci hry, kterou chcete hrát.", + "opponentLabel": "Oponent: {{peer}}", + "acceptAria": "Přijměte naši Výzvu.", + "accept": "Přijmout", + "declineAria": "Odmítnout výzvu", + "decline": "Odmítnout", + "resignAria": "Odstoupit", + "resign": "Odstoupit", + "acceptDrawAria": "Přijmout nabídku remízy", + "acceptDraw": "Přijmout remízu", + "declineDrawAria": "Odmítnout nabídku losování", + "declineDraw": "Odmítnout remízu", + "offerDrawAria": "Nabídnout remízu", + "offerDraw": "Nabídnout remízu", + "resendAria": "Znovu odeslat poslední akci", + "resend": "Znovu odeslat", + "deleteSessionAria": "Smazat relaci", + "deleteSession": "Smazat relaci", + "resignConfirmTitle": "Chcete ukončit hru?", + "resignConfirmMessage": "Opravdu chcete ukončit tuto hru? Tuto akci nelze vrátit zpět.", + "drawOfferedBanner": "Váš soupeř nabídl remízu.", + "challenge": "Výzva", + "challengeAria": "Výzva ke hře", + "challengeAppAria": "Výzva pro {{app}}", + "challengeSent": "{{app}} výzva odeslána", + "ttt": { + "youWon": "Vyhráli jste!", + "opponentWon": "Soupeř vyhrál.", + "draw": "Nákres.", + "yourTurn": "Váš tah", + "opponentTurn": "Soupeřovo kolo", + "yourMarker": "Jsi {{marker}}", + "moveCount": "{{count}} se pohybuje", + "boardAria": "Tic-Tac-Toe deska", + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" + }, + "chess": { + "youWon": "Vyhráli jste!", + "opponentWon": "Soupeř vyhrál.", + "draw": "Nákres.", + "yourTurn": "Váš tah", + "yourTurnInCheck": "Jste na řadě — jste v šachu", + "opponentTurn": "Soupeřovo kolo", + "boardAria": "Šachovnice", + "squareAria": "{{square}}, {{piece}}", + "emptySquare": "prázdný", + "legalMoveAria": "Hrát {{move}}", + "pieceNames": { + "K": "Bílý král", + "Q": "Bílá královna", + "R": "Bílá věž", + "B": "Bílý střelec", + "N": "ÊÔíÒæßæ,", + "P": "Bílý pěšec", + "k": "Černý král", + "q": "Black Queen", + "r": "Černá věž", + "b": "Černý střelec", + "n": "Black Jack", + "p": "Černý pěšec" + } + }, + "errors": { + "invalidPeerHash": "Zadejte platný 32místný partnerský cílový hash.", + "actionFailed": "Akce se nezdařila: {{reason}}", + "unknownReason": "Neznámý důvod", + "resendFailed": "Opětovné odeslání se nezdařilo.", + "deleteFailed": "Odstranění relace se nezdařilo." + }, + "deleteConfirmTitle": "Smazat relaci?", + "deleteConfirmMessage": "Odebrat tuto herní relaci ze seznamu? Tuto akci nelze vrátit zpět." } } diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 61f5d860f..ad98c6eaa 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -4547,7 +4547,8 @@ "topology": "Netzwerk-Topologie", "network": "Netzwerk", "rrc": "RRC", - "remote": "Remote" + "remote": "Remote", + "games": "Spiele" }, "takServerPanel": { "title": "TAK-Server", @@ -5149,5 +5150,106 @@ "connectFailed": "Konnte keine Verbindung herstellen" }, "ringing": "Klingelt…" + }, + "gamesPanel": { + "title": "Spiele", + "filters": { + "all": "Alle", + "active": "Aktiv", + "pending": "Ausstehend", + "completed": "erledigt" + }, + "noSessions": "Noch keine Spielsitzungen.", + "sessionRowAria": "{{app}} Spiel mit {{peer}}", + "apps": { + "ttt": "Drei gewinnt", + "chess": "Schach" + }, + "status": { + "pending": "Ausstehend", + "active": "Aktiv", + "completed": "erledigt", + "expired": "Abgelaufen", + "declined": "Abgelehnt" + }, + "unreadBadgeAria": "{{count}} ungelesen", + "newChallenge": "Neue Herausforderung", + "peerHashPlaceholder": "Peer-Ziel-Hash", + "peerHashAria": "Peer-Ziel-Hash", + "selectAppAria": "Wähle Spiel", + "sendChallengeAria": "Herausforderung senden", + "sendChallenge": "Herausforderung senden", + "selectSessionPrompt": "Wählen Sie eine Spielsitzung zum Spielen aus.", + "opponentLabel": "Einsprechender: {{peer}}", + "acceptAria": "Herausforderung annehmen", + "accept": "Zustimmen", + "declineAria": "Herausforderung ablehnen", + "decline": "Ablehnen", + "resignAria": "Kündigung", + "resign": "Kündigung", + "acceptDrawAria": "Ziehungsangebot annehmen", + "acceptDraw": "Ziehung annehmen", + "declineDrawAria": "Ziehungsangebot ablehnen", + "declineDraw": "Ziehung ablehnen", + "offerDrawAria": "Angebotsauslosung", + "offerDraw": "Angebotsauslosung", + "resendAria": "Letzte Aktion erneut senden", + "resend": "Erneut senden", + "deleteSessionAria": "Sitzung löschen", + "deleteSession": "Sitzung löschen", + "resignConfirmTitle": "Partie aufgeben", + "resignConfirmMessage": "Bist du sicher, dass du dieses Spiel kündigen möchtest? Dies kann nicht rückgängig gemacht werden.", + "drawOfferedBanner": "Dein Gegner hat ein Unentschieden angeboten.", + "challenge": "Schwierigkeiten", + "challengeAria": "Herausforderung zu einem Spiel", + "challengeAppAria": "Herausforderung an {{app}}", + "challengeSent": "{{app}} Herausforderung gesendet", + "ttt": { + "youWon": "Sie haben gewonnen!", + "opponentWon": "Gegner hat gewonnen.", + "draw": "Einzeichnen", + "yourTurn": "Du bist dran", + "opponentTurn": "GEGNERISCHER ZUG", + "yourMarker": "Du bist {{marker}}", + "moveCount": "{{count}} Züge", + "boardAria": "Tic-Tac-Toe-Brett", + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" + }, + "chess": { + "youWon": "Sie haben gewonnen!", + "opponentWon": "Gegner hat gewonnen.", + "draw": "Einzeichnen", + "yourTurn": "Du bist dran", + "yourTurnInCheck": "Sie sind an der Reihe — Sie sind in Schach", + "opponentTurn": "GEGNERISCHER ZUG", + "boardAria": "SchachbrettFilter Effect: Melt Down", + "squareAria": "{{square}}, {{piece}}", + "emptySquare": "empty", + "legalMoveAria": "Spiele {{move}}", + "pieceNames": { + "K": "Weißer König", + "Q": "The White Queen", + "R": "Weißer Turm", + "B": "Weißer Bischof", + "N": "Weißer Ritter", + "P": "Weißer Bauer", + "k": "Schwarzer König", + "q": "Schwarze Königin", + "r": "Schwarzer Turm", + "b": "Schwarzer Läufer", + "n": "Black Knight", + "p": "Schwarzer Bauer" + } + }, + "errors": { + "invalidPeerHash": "Geben Sie einen gültigen 32-stelligen Peer-Ziel-Hash ein.", + "actionFailed": "Aktion fehlgeschlagen: {{reason}}", + "unknownReason": "Unbekannter Grund", + "resendFailed": "Erneutes Senden fehlgeschlagen.", + "deleteFailed": "Die Recycling-Sitzung konnte nicht gelöscht werden." + }, + "deleteConfirmTitle": "Löschen sitzung?", + "deleteConfirmMessage": "Diese Spielsitzung von Ihrer Liste entfernen? Dies kann nicht rückgängig gemacht werden." } } diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index c3b75d81a..e1729dbd0 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -1865,6 +1865,107 @@ "esp32FlashStalled": "Firmware transfer stalled with no progress. Try another USB cable or port, enter bootloader mode (hold BOOT, tap RESET), and flash again." } }, + "gamesPanel": { + "title": "Games", + "filters": { + "all": "All", + "active": "Active", + "pending": "Pending", + "completed": "Completed" + }, + "noSessions": "No game sessions yet.", + "sessionRowAria": "{{app}} game with {{peer}}", + "apps": { + "ttt": "Tic-Tac-Toe", + "chess": "Chess" + }, + "status": { + "pending": "Pending", + "active": "Active", + "completed": "Completed", + "expired": "Expired", + "declined": "Declined" + }, + "unreadBadgeAria": "{{count}} unread", + "newChallenge": "New challenge", + "peerHashPlaceholder": "Peer destination hash", + "peerHashAria": "Peer destination hash", + "selectAppAria": "Select game", + "sendChallengeAria": "Send challenge", + "sendChallenge": "Send challenge", + "selectSessionPrompt": "Select a game session to play.", + "opponentLabel": "Opponent: {{peer}}", + "acceptAria": "Accept challenge", + "accept": "Accept", + "declineAria": "Decline challenge", + "decline": "Decline", + "resignAria": "Resign", + "resign": "Resign", + "acceptDrawAria": "Accept draw offer", + "acceptDraw": "Accept draw", + "declineDrawAria": "Decline draw offer", + "declineDraw": "Decline draw", + "offerDrawAria": "Offer draw", + "offerDraw": "Offer draw", + "resendAria": "Resend last action", + "resend": "Resend", + "deleteSessionAria": "Delete session", + "deleteSession": "Delete session", + "resignConfirmTitle": "Resign game?", + "resignConfirmMessage": "Are you sure you want to resign this game? This cannot be undone.", + "deleteConfirmTitle": "Delete session?", + "deleteConfirmMessage": "Remove this game session from your list? This cannot be undone.", + "drawOfferedBanner": "Your opponent offered a draw.", + "challenge": "Challenge", + "challengeAria": "Challenge to a game", + "challengeAppAria": "Challenge to {{app}}", + "challengeSent": "{{app}} challenge sent", + "ttt": { + "youWon": "You won!", + "opponentWon": "Opponent won.", + "draw": "Draw.", + "yourTurn": "Your turn", + "opponentTurn": "Opponent's turn", + "yourMarker": "You are {{marker}}", + "moveCount": "{{count}} moves", + "boardAria": "Tic-Tac-Toe board", + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" + }, + "chess": { + "youWon": "You won!", + "opponentWon": "Opponent won.", + "draw": "Draw.", + "yourTurn": "Your turn", + "yourTurnInCheck": "Your turn — you are in check", + "opponentTurn": "Opponent's turn", + "boardAria": "Chess board", + "squareAria": "{{square}}, {{piece}}", + "emptySquare": "empty", + "legalMoveAria": "Play {{move}}", + "pieceNames": { + "K": "White king", + "Q": "White queen", + "R": "White rook", + "B": "White bishop", + "N": "White knight", + "P": "White pawn", + "k": "Black king", + "q": "Black queen", + "r": "Black rook", + "b": "Black bishop", + "n": "Black knight", + "p": "Black pawn" + } + }, + "errors": { + "invalidPeerHash": "Enter a valid 32-character peer destination hash.", + "actionFailed": "Action failed: {{reason}}", + "unknownReason": "unknown reason", + "resendFailed": "Resend failed.", + "deleteFailed": "Failed to delete session." + } + }, "identityVault": { "title": "Identity vault", "hint": "Encrypt a local copy of your identity backup with a vault passcode. Unlock to access protected identity operations.", @@ -4750,6 +4851,7 @@ "tabs": { "connection": "Connection", "chat": "Chat", + "games": "Games", "rrc": "RRC", "remote": "Remote", "nomadnetwork": "Nomad Network", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 65b44d56a..d0465b9a2 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -4547,7 +4547,8 @@ "topology": "Topología de red", "network": "Red", "rrc": "RRC", - "remote": "Remoto" + "remote": "Remoto", + "games": "Juegos" }, "takServerPanel": { "title": "Servidor TAK", @@ -5149,5 +5150,106 @@ }, "statsSeparator": "·", "ringing": "Sonando…" + }, + "gamesPanel": { + "title": "Juegos", + "filters": { + "all": "Todos", + "active": "Activo", + "pending": "Pendiente", + "completed": "Completado" + }, + "noSessions": "Todavía no hay sesiones de juego.", + "sessionRowAria": "{{app}} juego con {{peer}}", + "apps": { + "ttt": "Tres en raya", + "chess": "Ajedrez" + }, + "status": { + "pending": "Pendiente", + "active": "Activo", + "completed": "Completado", + "expired": "Caducada", + "declined": "Rechazada" + }, + "unreadBadgeAria": "{{count}} sin leer", + "newChallenge": "Nuevo desafío", + "peerHashPlaceholder": "Hash de destino de pares", + "peerHashAria": "Hash de destino de pares", + "selectAppAria": "Seleccionar juego", + "sendChallengeAria": "Enviar desafío", + "sendChallenge": "Enviar desafío", + "selectSessionPrompt": "Selecciona una sesión de juego para jugar.", + "opponentLabel": "Oponente: {{peer}}", + "acceptAria": "Aceptar el desafío.", + "accept": "Aceptar", + "declineAria": "Rechazar Desafío", + "decline": "Rechazar", + "resignAria": "Renunciar", + "resign": "Renunciar", + "acceptDrawAria": "Aceptar oferta de sorteo", + "acceptDraw": "Aceptar sorteo", + "declineDrawAria": "Rechazar oferta de sorteo", + "declineDraw": "Rechazar sorteo", + "offerDrawAria": "Sorteo de ofertas", + "offerDraw": "Sorteo de ofertas", + "resendAria": "Reenviar la última acción", + "resend": "Volver a enviar", + "deleteSessionAria": "Eliminar la sesión", + "deleteSession": "Eliminar la sesión", + "resignConfirmTitle": "¿Renunciar al juego?", + "resignConfirmMessage": "¿Seguro que quieres renunciar a este juego? Esto no se puede deshacer.", + "drawOfferedBanner": "Tu oponente ofreció tablas.", + "challenge": "Desafíos", + "challengeAria": "Desafío a un juego", + "challengeAppAria": "Desafío a {{app}}", + "challengeSent": "{{app}} desafío enviado", + "ttt": { + "youWon": "¡Has ganado!", + "opponentWon": "El oponente ganó.", + "draw": "Plano", + "yourTurn": "Tu turno", + "opponentTurn": "TURNO DEL OPONENTE", + "yourMarker": "Eres {{marker}}", + "moveCount": "{{count}} SE mueve", + "boardAria": "1 tablero de Tres en raya", + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" + }, + "chess": { + "youWon": "¡Has ganado!", + "opponentWon": "El oponente ganó.", + "draw": "Plano", + "yourTurn": "Tu turno", + "yourTurnInCheck": "Su turno — usted está en jaque", + "opponentTurn": "TURNO DEL OPONENTE", + "boardAria": "Tablero de ajedrez", + "squareAria": "{{square}}, {{piece}}", + "emptySquare": "vacío", + "legalMoveAria": "Reproducir {{move}}", + "pieceNames": { + "K": "White King", + "Q": "La Reina Blanca", + "R": "Torre blanca", + "B": "Alfil blanco", + "N": "caballero blanco, white knight", + "P": "Peón blanco", + "k": "Black King", + "q": "reina negra", + "r": "Black rook", + "b": "Alfil negro", + "n": "Deflector", + "p": "Peón negro" + } + }, + "errors": { + "invalidPeerHash": "Introduzca un hash de destino de 32 caracteres válido.", + "actionFailed": "Acción fallida: {{reason}}", + "unknownReason": "razón desconocida", + "resendFailed": "Fallo en el reenvío", + "deleteFailed": "Error al eliminar la sesión." + }, + "deleteConfirmTitle": "¿Eliminar sesión?", + "deleteConfirmMessage": "¿Quieres eliminar esta sesión de juego de tu lista? Esto no se puede deshacer." } } diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 546e67589..98b265c4f 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -4547,7 +4547,8 @@ "topology": "Topologie de réseau", "network": "Réseau", "rrc": "RRC", - "remote": "À distance" + "remote": "À distance", + "games": "Jeux" }, "takServerPanel": { "title": "Serveur TAK", @@ -5149,5 +5150,106 @@ }, "statsSeparator": "·", "ringing": "Ça sonne…" + }, + "gamesPanel": { + "title": "Jeux", + "filters": { + "all": "Tous", + "active": "Actif", + "pending": "En attente", + "completed": "Terminé" + }, + "noSessions": "Pas encore de sessions de jeu.", + "sessionRowAria": "{{app}} jeu avec {{peer}}", + "apps": { + "ttt": "Tictacto", + "chess": "Échecs" + }, + "status": { + "pending": "En attente", + "active": "Actif", + "completed": "Terminé", + "expired": "Expiré", + "declined": "Décliné" + }, + "unreadBadgeAria": "{{count}} non lu", + "newChallenge": "Nouveau défi", + "peerHashPlaceholder": "Hash de Destination", + "peerHashAria": "Hash de Destination", + "selectAppAria": "Choisir jeu", + "sendChallengeAria": "Envoyer un défi", + "sendChallenge": "Envoyer un défi", + "selectSessionPrompt": "Sélectionnez une session de jeu à jouer.", + "opponentLabel": "Opposant : {{peer}}", + "acceptAria": "Accepter le défi", + "accept": "Accepter", + "declineAria": "Refuser le défi", + "decline": "Refuser", + "resignAria": "Démissionner", + "resign": "Démissionner", + "acceptDrawAria": "Accepter l'offre de tirage", + "acceptDraw": "Accepter le tirage", + "declineDrawAria": "Refuser l'offre de tirage", + "declineDraw": "Refuser le tirage", + "offerDrawAria": "Proposer la partie nulle", + "offerDraw": "Proposer la partie nulle", + "resendAria": "Renvoyer la dernière action", + "resend": "Envoyer à nouveau", + "deleteSessionAria": "Supprimer la session", + "deleteSession": "Supprimer la session", + "resignConfirmTitle": "Abandonner la partie ?", + "resignConfirmMessage": "Êtes-vous sûr de vouloir démissionner de ce jeu ? Cela ne peut pas être annulé.", + "drawOfferedBanner": "Votre adversaire a offert un match nul.", + "challenge": "Défi", + "challengeAria": "Défi à un jeu", + "challengeAppAria": "Défiez {{app}}", + "challengeSent": "{{app}} défi envoyé", + "ttt": { + "youWon": "Vous avez gagné !", + "opponentWon": "L'adversaire a gagné.", + "draw": "Dessin.", + "yourTurn": "À votre tour", + "opponentTurn": "Au tour de l'adversaire", + "yourMarker": "Vous êtes {{marker}}", + "moveCount": "{{count}} se déplace", + "boardAria": "Carte Tic-Tac-Toe", + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" + }, + "chess": { + "youWon": "Vous avez gagné !", + "opponentWon": "L'adversaire a gagné.", + "draw": "Dessin.", + "yourTurn": "À votre tour", + "yourTurnInCheck": "À votre tour — vous êtes sous contrôle", + "opponentTurn": "Au tour de l'adversaire", + "boardAria": "Echiquier", + "squareAria": "{{square}}, {{piece}}", + "emptySquare": "vide", + "legalMoveAria": "Jouer {{move}}", + "pieceNames": { + "K": "Roi blanc", + "Q": "La Reine Blanche", + "R": "Tour blanche", + "B": "White bishop", + "N": "Chevalier blanc", + "P": "Pion blanc", + "k": "Black King", + "q": "Reine noire", + "r": "Tour noire", + "b": "Black bishop", + "n": "Chevalier noir", + "p": "Pion noir" + } + }, + "errors": { + "invalidPeerHash": "Entrez un hachage de destination homologue valide de 32 caractères.", + "actionFailed": "Échec de l'action : {{reason}}", + "unknownReason": "raison inconnue", + "resendFailed": "Échec du renvoi.", + "deleteFailed": "Échec de la suppression de la session." + }, + "deleteConfirmTitle": "Supprimer la période ?", + "deleteConfirmMessage": "Supprimer cette session de jeu de votre liste ? Cela ne peut pas être annulé." } } diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 0f07f04ee..4a1716244 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -4547,7 +4547,8 @@ "topology": "Topologi jaringan", "network": "Jaringan", "rrc": "RRC", - "remote": "Jarak jauh" + "remote": "Jarak jauh", + "games": "Game" }, "takServerPanel": { "title": "Server TAK", @@ -5149,5 +5150,106 @@ }, "statsSeparator": "·", "ringing": "Berdering…" + }, + "gamesPanel": { + "title": "Game", + "filters": { + "all": "Semua", + "active": "Aktif", + "pending": "Tertunda", + "completed": "Selesai" + }, + "noSessions": "Belum ada sesi permainan.", + "sessionRowAria": "{{app}} permainan dengan {{peer}}", + "apps": { + "ttt": "Tic Tac Toe", + "chess": "Catur" + }, + "status": { + "pending": "Tertunda", + "active": "Aktif", + "completed": "Selesai", + "expired": "Berakhir", + "declined": "Ditolak" + }, + "unreadBadgeAria": "{{count}} belum dibaca", + "newChallenge": "Tantangan baru!", + "peerHashPlaceholder": "Hash tujuan sejawat", + "peerHashAria": "Hash tujuan sejawat", + "selectAppAria": "Pilih Satu Permainan", + "sendChallengeAria": "Kirim tantangan", + "sendChallenge": "Kirim tantangan", + "selectSessionPrompt": "Pilih sesi permainan untuk dimainkan.", + "opponentLabel": "Lawan: {{peer}}", + "acceptAria": "Terima Tantangan", + "accept": "Setujui", + "declineAria": "Tolak tantangan", + "decline": "Menurun", + "resignAria": "Mengundurkan Diri", + "resign": "Mengundurkan Diri", + "acceptDrawAria": "Terima penawaran undian", + "acceptDraw": "Terima undian", + "declineDrawAria": "Tolak penawaran undian", + "declineDraw": "Tolak undian", + "offerDrawAria": "Penawaran undian", + "offerDraw": "Penawaran undian", + "resendAria": "Kirim ulang tindakan terakhir", + "resend": "Kirim ulang", + "deleteSessionAria": "Hapus sesi", + "deleteSession": "Hapus sesi", + "resignConfirmTitle": "Mengundurkan diri dari permainan?", + "resignConfirmMessage": "Anda yakin ingin mengundurkan diri dari permainan ini? Ini tidak dapat diurungkan.", + "drawOfferedBanner": "Lawanmu menawarkan hasil imbang.", + "challenge": "Tantangan", + "challengeAria": "Tantangan untuk permainan", + "challengeAppAria": "Tantangan untuk {{app}}", + "challengeSent": "{{app}} tantangan terkirim", + "ttt": { + "youWon": "Kamu menang!", + "opponentWon": "Lawan menang.", + "draw": "Draw", + "yourTurn": "Giliran Anda", + "opponentTurn": "Giliran Lawan", + "yourMarker": "Anda {{marker}}", + "moveCount": "{{count}} bergerak", + "boardAria": "Papan Tic - Tac - Toe", + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" + }, + "chess": { + "youWon": "Kamu menang!", + "opponentWon": "Lawan menang.", + "draw": "Draw", + "yourTurn": "Giliran Anda", + "yourTurnInCheck": "Giliran Anda — Anda sudah siap", + "opponentTurn": "Giliran Lawan", + "boardAria": "Papan catur", + "squareAria": "{{square}}, {{piece}}", + "emptySquare": "kosong", + "legalMoveAria": "Mainkan {{move}}", + "pieceNames": { + "K": "Raja kulit putih", + "Q": "Ratu putih", + "R": "Benteng putih", + "B": "Uskup kulit putih", + "N": "Ksatria putih", + "P": "Pion putih", + "k": "Raja kulit hitam", + "q": "Ratu hitam", + "r": "Benteng hitam", + "b": "Uskup kulit hitam", + "n": "Ksatria hitam", + "p": "Pion hitam" + } + }, + "errors": { + "invalidPeerHash": "Masukkan hash tujuan rekan 32 karakter yang valid.", + "actionFailed": "Tindakan gagal: {{reason}}", + "unknownReason": "Alasan tidak dikenal", + "resendFailed": "Pengiriman ulang gagal.", + "deleteFailed": "Gagal menghapus sesi." + }, + "deleteConfirmTitle": "Hapus sesi?", + "deleteConfirmMessage": "Hapus sesi permainan ini dari daftar Anda? Ini tidak dapat diurungkan." } } diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index ee34027dd..8e3ce1a7c 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -4547,7 +4547,8 @@ "topology": "Topologia di rete.", "network": "Rete", "rrc": "RRC", - "remote": "Remoto" + "remote": "Remoto", + "games": "Giochi" }, "takServerPanel": { "title": "TAK Server", @@ -5149,5 +5150,106 @@ }, "statsSeparator": "·", "ringing": "Sta squillando…" + }, + "gamesPanel": { + "title": "Giochi", + "filters": { + "all": "Tutte", + "active": "Attivo", + "pending": "In sospeso", + "completed": "Completato" + }, + "noSessions": "Ancora nessuna sessione di gioco.", + "sessionRowAria": "{{app}} gioco con {{peer}}", + "apps": { + "ttt": "Tris", + "chess": "Scacchiera" + }, + "status": { + "pending": "In sospeso", + "active": "Attivo", + "completed": "Completato", + "expired": "scaduta", + "declined": "Rifiutato" + }, + "unreadBadgeAria": "{{count}} non letto", + "newChallenge": "Una nuova sfida.", + "peerHashPlaceholder": "Hash di destinazione peer", + "peerHashAria": "Hash di destinazione peer", + "selectAppAria": "Scegli il gioco", + "sendChallengeAria": "Invia sfida", + "sendChallenge": "Invia sfida", + "selectSessionPrompt": "Seleziona una sessione di gioco da giocare.", + "opponentLabel": "Avversario: {{peer}}", + "acceptAria": "Accetta sfida", + "accept": "Accetta", + "declineAria": "Rifiuta la sfida", + "decline": "Rifiuto", + "resignAria": "Rinunciare", + "resign": "Rinunciare", + "acceptDrawAria": "Accetta l'offerta di estrazione", + "acceptDraw": "Accetta sorteggio", + "declineDrawAria": "Rifiuta l'offerta di estrazione", + "declineDraw": "Rifiuta estrazione", + "offerDrawAria": "Estrazione offerta", + "offerDraw": "Estrazione offerta", + "resendAria": "Invia di nuovo l'ultima azione", + "resend": "Invia di nuovo", + "deleteSessionAria": "Cancellare una sessione", + "deleteSession": "Cancellare una sessione", + "resignConfirmTitle": "Abbandona partita", + "resignConfirmMessage": "Vuoi davvero eliminare questo gioco? Non potrai tornare indietro.", + "drawOfferedBanner": "Il tuo avversario ha offerto un pareggio.", + "challenge": "Sfida", + "challengeAria": "Sfida a un gioco", + "challengeAppAria": "Sfida a {{app}}", + "challengeSent": "Sfida {{app}} inviata", + "ttt": { + "youWon": "Hai vinto!", + "opponentWon": "L'avversario ha vinto.", + "draw": "Patta.", + "yourTurn": "Tocca a te.", + "opponentTurn": "Tocca al tuo avversario", + "yourMarker": "Lei è {{marker}}", + "moveCount": "{{count}} mosse", + "boardAria": "Tavola Tic-Tac-Toe", + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" + }, + "chess": { + "youWon": "Hai vinto!", + "opponentWon": "L'avversario ha vinto.", + "draw": "Patta.", + "yourTurn": "Tocca a te.", + "yourTurnInCheck": "Tocca a te — sei sotto controllo", + "opponentTurn": "Tocca al tuo avversario", + "boardAria": "Scacchiera", + "squareAria": "{{square}}, {{piece}}", + "emptySquare": "vuoto", + "legalMoveAria": "Gioca {{move}}", + "pieceNames": { + "K": "Il Re bianco", + "Q": "Regina bianca", + "R": "Una Torre bianca", + "B": "Alfiere bianco", + "N": "Un Cavallo bianco", + "P": "Pedone bianco", + "k": "Il Re Nero", + "q": "Black Queen", + "r": "Una Torre nera", + "b": "Un Alfiere nero", + "n": "Il cavaliere nero", + "p": "Pedone nero" + } + }, + "errors": { + "invalidPeerHash": "Inserisci un hash di destinazione peer di 32 caratteri valido.", + "actionFailed": "Azione non riuscita: {{reason}}", + "unknownReason": "Motivo sconosciuto", + "resendFailed": "Nuovo invio del codice non riuscito", + "deleteFailed": "Sessione non eliminata" + }, + "deleteConfirmTitle": "Cancellare una sessione", + "deleteConfirmMessage": "Vuoi rimuovere questa sessione di gioco dalla tua lista? Questa operazione non può essere annullata." } } diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 311c1b8cf..2e215b06c 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -4547,7 +4547,8 @@ "topology": "ネットワーク・トポロジー", "network": "ネットワーク", "rrc": "RRC", - "remote": "リモート" + "remote": "リモート", + "games": "ゲーム" }, "takServerPanel": { "title": "TAKサーバー", @@ -5149,5 +5150,106 @@ }, "statsSeparator": "・", "ringing": "呼び出し中…" + }, + "gamesPanel": { + "title": "ゲーム", + "filters": { + "all": "すべて", + "active": "アクティブ", + "pending": "保留", + "completed": "完了" + }, + "noSessions": "ゲームセッションはまだありません。", + "sessionRowAria": "{{peer}}との{{app}}ゲーム", + "apps": { + "ttt": "三目並べ", + "chess": "チェス" + }, + "status": { + "pending": "保留", + "active": "アクティブ", + "completed": "完了", + "expired": "有効期限切れ", + "declined": "却下されました" + }, + "unreadBadgeAria": "{{count}}未読", + "newChallenge": "新しいチャレンジ(勝負)", + "peerHashPlaceholder": "ピア宛先ハッシュ", + "peerHashAria": "ピア宛先ハッシュ", + "selectAppAria": "ゲームを選ぶ", + "sendChallengeAria": "チャレンジを発送", + "sendChallenge": "チャレンジを発送", + "selectSessionPrompt": "プレイするゲームセッションを選択します。", + "opponentLabel": "対戦相手: {{peer}}", + "acceptAria": "チャレンジを受け入れる", + "accept": "同意", + "declineAria": "チャレンジを辞退する", + "decline": "拒否する", + "resignAria": "やめる", + "resign": "やめる", + "acceptDrawAria": "抽選オファーを受け入れる", + "acceptDraw": "引き分けを受け入れる", + "declineDrawAria": "引き分けオファーを辞退する", + "declineDraw": "引き分けを辞退する", + "offerDrawAria": "オファー抽選", + "offerDraw": "オファー抽選", + "resendAria": "最後のアクションを再送", + "resend": "再送信", + "deleteSessionAria": "セッションを削除", + "deleteSession": "セッションを削除", + "resignConfirmTitle": "ゲームを終了しますか?", + "resignConfirmMessage": "本当にこのゲームを辞めますか?これは元に戻せません。", + "drawOfferedBanner": "対戦相手が引き分けを申し出ました。", + "challenge": "チャレンジ", + "challengeAria": "ゲームへの挑戦", + "challengeAppAria": "{{app}}へのチャレンジ", + "challengeSent": "{{app}}チャレンジを送信しました", + "ttt": { + "youWon": "勝ったぁ!", + "opponentWon": "対戦相手が勝利した。", + "draw": "引け", + "yourTurn": "あなたのターン", + "opponentTurn": "相手のターン!", + "yourMarker": "あなたは{{marker}}です", + "moveCount": "{{count}}ムーブ", + "boardAria": "Tic - Tac - Toeボード", + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" + }, + "chess": { + "youWon": "勝ったぁ!", + "opponentWon": "対戦相手が勝利した。", + "draw": "引け", + "yourTurn": "あなたのターン", + "yourTurnInCheck": "あなたの番です—あなたはチェックされています", + "opponentTurn": "相手のターン!", + "boardAria": "チェスボード", + "squareAria": "{{square}}、{{piece}}", + "emptySquare": "なにも入っていません", + "legalMoveAria": "再生{{move}}", + "pieceNames": { + "K": "白のキング", + "Q": "ホワイトクイーン", + "R": "ホワイトルーク", + "B": "ホワイトビショップ", + "N": "ホワイト・ナイト?", + "P": "白い駒", + "k": "黒のキング", + "q": "ブラック・クィーン", + "r": "ブラックルーク", + "b": "ブラックビショップ", + "n": "黒騎士", + "p": "黒い質物" + } + }, + "errors": { + "invalidPeerHash": "有効な32文字のピア宛先ハッシュを入力します。", + "actionFailed": "アクションに失敗しました: {{reason}}", + "unknownReason": "不明な理由", + "resendFailed": "再送に失敗しました。", + "deleteFailed": "セッションを削除できませんでした。" + }, + "deleteConfirmTitle": "セッションを削除", + "deleteConfirmMessage": "このゲームセッションをリストから削除しますか?これは元に戻せません。" } } diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 75cf35728..1337af36d 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -4547,7 +4547,8 @@ "topology": "네트워크 토폴로지", "network": "회로망", "rrc": "RRC", - "remote": "원격" + "remote": "원격", + "games": "게임" }, "takServerPanel": { "title": "TAK 서버", @@ -5149,5 +5150,106 @@ }, "statsSeparator": "Poisonblow", "ringing": "벨이 울리는 중…" + }, + "gamesPanel": { + "title": "게임", + "filters": { + "all": "All", + "active": "활성화", + "pending": "대기", + "completed": "완료됨" + }, + "noSessions": "아직 게임 세션이 없습니다.", + "sessionRowAria": "{{peer}} 과 (와) 의 {{app}} 게임", + "apps": { + "ttt": "Tic Tac Toe", + "chess": "체스" + }, + "status": { + "pending": "대기", + "active": "활성화", + "completed": "완료됨", + "expired": "만료됨", + "declined": "반려" + }, + "unreadBadgeAria": "{{count}} 읽지 않음", + "newChallenge": "새로운 도전", + "peerHashPlaceholder": "피어 대상 해시", + "peerHashAria": "피어 대상 해시", + "selectAppAria": "게임 선택", + "sendChallengeAria": "챌린지 보내기", + "sendChallenge": "챌린지 보내기", + "selectSessionPrompt": "플레이할 게임 세션을 선택합니다.", + "opponentLabel": "상대: {{peer}}", + "acceptAria": "도전 수락하기", + "accept": "수락", + "declineAria": "챌린지 거절", + "decline": "거절", + "resignAria": "사직", + "resign": "사직", + "acceptDrawAria": "추첨 제안 수락하기", + "acceptDraw": "무승부 수락", + "declineDrawAria": "추첨 제안 거절", + "declineDraw": "추첨 거절", + "offerDrawAria": "혜택 추첨", + "offerDraw": "혜택 추첨", + "resendAria": "마지막 작업 다시 보내기", + "resend": "다시 보내기", + "deleteSessionAria": "세션 삭제", + "deleteSession": "세션 삭제", + "resignConfirmTitle": "게임을 사임하시겠습니까?", + "resignConfirmMessage": "정말로 이 게임을 사임하시겠습니까? 이 작업은 취소할 수 없습니다.", + "drawOfferedBanner": "상대가 무승부를 제안했습니다.", + "challenge": "도전 과제", + "challengeAria": "게임에 도전하기", + "challengeAppAria": "{{app}} 에 대한 도전", + "challengeSent": "{{app}} 챌린지 전송 완료", + "ttt": { + "youWon": "당신의 승리입니다!", + "opponentWon": "상대가 이겼습니다.", + "draw": "그리기", + "yourTurn": "당신의 차례입니다.", + "opponentTurn": "상대방 턴!", + "yourMarker": "당신은 {{marker}} 입니다", + "moveCount": "{{count}} 이동", + "boardAria": "Tic-Tac-Toe 보드", + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" + }, + "chess": { + "youWon": "당신의 승리입니다!", + "opponentWon": "상대가 이겼습니다.", + "draw": "그리기", + "yourTurn": "당신의 차례입니다.", + "yourTurnInCheck": "내 턴 — 현재 확인 중", + "opponentTurn": "상대방 턴!", + "boardAria": "체스판", + "squareAria": "{{square}}, {{piece}}", + "emptySquare": "비어 있는", + "legalMoveAria": "PLAY {{move}}", + "pieceNames": { + "K": "흰색이 왕이다", + "Q": "화이트 퀸", + "R": "화이트 룩", + "B": "백인 주교", + "N": "백기사", + "P": "화이트 폰", + "k": "흑왕", + "q": "블랙 퀸", + "r": "블랙 룩", + "b": "흑인 주교", + "n": "흑기사", + "p": "블랙 폰" + } + }, + "errors": { + "invalidPeerHash": "유효한 32자 피어 대상 해시를 입력하십시오.", + "actionFailed": "조치 실패: {{reason}}", + "unknownReason": "알 수 없는 이유", + "resendFailed": "재전송에 실패했습니다.", + "deleteFailed": "세션을 삭제하지 못했습니다." + }, + "deleteConfirmTitle": "세션 삭제", + "deleteConfirmMessage": "목록에서 이 게임 세션을 제거하시겠습니까? 이 작업은 취소할 수 없습니다." } } diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 42505bca4..211c8fe9a 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -4547,7 +4547,8 @@ "topology": "Netwerktopologie", "network": "Netwerk", "rrc": "RRC", - "remote": "Op afstand" + "remote": "Op afstand", + "games": "Games" }, "takServerPanel": { "title": "TAK-server", @@ -5149,5 +5150,106 @@ "connectFailed": "Kon niet verbinden" }, "ringing": "Overgaan…" + }, + "gamesPanel": { + "title": "Games", + "filters": { + "all": "All", + "active": "Actief", + "pending": "Openstaand", + "completed": "Voltooid" + }, + "noSessions": "Nog geen spelsessies.", + "sessionRowAria": "{{app}} game met {{peer}}", + "apps": { + "ttt": "Boter-kaas-eierenmahjongg map name", + "chess": "Chess" + }, + "status": { + "pending": "Openstaand", + "active": "Actief", + "completed": "Voltooid", + "expired": "Verlopen", + "declined": "Geweigerd" + }, + "unreadBadgeAria": "{{count}} ongelezen", + "newChallenge": "Nieuwe Uitdaging", + "peerHashPlaceholder": "Peer destination hash", + "peerHashAria": "Peer destination hash", + "selectAppAria": "SPEL KIEZEN", + "sendChallengeAria": "Uitdaging verzenden", + "sendChallenge": "Uitdaging verzenden", + "selectSessionPrompt": "Selecteer een spelsessie om te spelen.", + "opponentLabel": "Tegenstander: {{peer}}", + "acceptAria": "Accepteer uitdaging", + "accept": "Aanvaarden", + "declineAria": "Uitdaging afwijzen", + "decline": "Afwijzen", + "resignAria": "Stoppen", + "resign": "Stoppen", + "acceptDrawAria": "Trekkingsaanbod accepteren", + "acceptDraw": "Tekening accepteren", + "declineDrawAria": "Trekkingsaanbod afwijzen", + "declineDraw": "Trekking afwijzen", + "offerDrawAria": "Aanbieding loting", + "offerDraw": "Aanbieding loting", + "resendAria": "Laatste actie opnieuw verzenden", + "resend": "Opnieuw versturen", + "deleteSessionAria": "Sessie verwijderen", + "deleteSession": "Sessie verwijderen", + "resignConfirmTitle": "Spel opzeggen?", + "resignConfirmMessage": "Weet je zeker dat je dit spel wilt opzeggen? Dit kan niet ongedaan worden gemaakt.", + "drawOfferedBanner": "Je tegenstander bood een gelijkspel aan.", + "challenge": "Uitdaging", + "challengeAria": "Uitdaging voor een spel", + "challengeAppAria": "Uitdaging voor {{app}}", + "challengeSent": "{{app}} uitdaging verzonden", + "ttt": { + "youWon": "Je hebt gewonnen!", + "opponentWon": "Tegenstander heeft gewonnen.", + "draw": "Rapen", + "yourTurn": "Jouw beurt", + "opponentTurn": "De beurt van de tegenstander", + "yourMarker": "Jij bent {{marker}}", + "moveCount": "{{count}} verhuist", + "boardAria": "Tic-Tac-Toe-bord", + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" + }, + "chess": { + "youWon": "Je hebt gewonnen!", + "opponentWon": "Tegenstander heeft gewonnen.", + "draw": "Rapen", + "yourTurn": "Jouw beurt", + "yourTurnInCheck": "Jouw beurt — jij bent aan de beurt", + "opponentTurn": "De beurt van de tegenstander", + "boardAria": "SchaakbordFilter Effect: Melt Down", + "squareAria": "{{square}}, {{piece}}", + "emptySquare": "leegmaken", + "legalMoveAria": "Speel {{move}}", + "pieceNames": { + "K": "WHITE KING", + "Q": "Witte koningin", + "R": "Witte toren", + "B": "Witte bisschop", + "N": "witte ridder", + "P": "Witte pion", + "k": "Zwarte koning", + "q": "Zwarte koningin", + "r": "Zwarte toren", + "b": "Zwarte loper", + "n": "Zwarte ridder", + "p": "Zwarte pion" + } + }, + "errors": { + "invalidPeerHash": "Voer een geldige peer-bestemmingshash van 32 tekens in.", + "actionFailed": "Actie mislukt: {{reason}}", + "unknownReason": "onbekende reden", + "resendFailed": "Opnieuw verzenden mislukt.", + "deleteFailed": "Kan sessie niet verwijderen." + }, + "deleteConfirmTitle": "Moment verwijderen?", + "deleteConfirmMessage": "Deze spelsessie uit je lijst verwijderen? Dit kan niet ongedaan worden gemaakt." } } diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 9f3776557..784a5224a 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -4551,7 +4551,8 @@ "topology": "Topologia sieci", "network": "Sieć", "rrc": "RRC", - "remote": "Zdalny" + "remote": "Zdalny", + "games": "Gry" }, "takServerPanel": { "title": "Serwer TAK", @@ -5153,5 +5154,106 @@ }, "statsSeparator": "·", "ringing": "Dzwoni…" + }, + "gamesPanel": { + "title": "Gry", + "filters": { + "all": "Wszystko", + "active": "Aktywna", + "pending": "Oczekujące", + "completed": "Projekt zakończony" + }, + "noSessions": "Nie ma jeszcze sesji gry.", + "sessionRowAria": "{{app}} gra Z {{peer}}", + "apps": { + "ttt": "Kółko i krzyżyk", + "chess": "Szachy" + }, + "status": { + "pending": "Oczekujące", + "active": "Aktywna", + "completed": "Projekt zakończony", + "expired": "Wygasłe", + "declined": "Odrzucone" + }, + "unreadBadgeAria": "{{count}} nieprzeczytane", + "newChallenge": "Nowe wyzwanie", + "peerHashPlaceholder": "Hasz celu równorzędnego", + "peerHashAria": "Hasz celu równorzędnego", + "selectAppAria": "Wybór gry", + "sendChallengeAria": "Wyślij wyzwanie", + "sendChallenge": "Wyślij wyzwanie", + "selectSessionPrompt": "Wybierz sesję gry do rozegrania.", + "opponentLabel": "Przeciwnik: {{peer}}", + "acceptAria": "Zaakceptuj wyzwanie", + "accept": "Zaakceptuj", + "declineAria": "Odrzuć wyzwanie", + "decline": "Odrzuć", + "resignAria": "Rezygnacja", + "resign": "Rezygnacja", + "acceptDrawAria": "Zaakceptuj ofertę remisu", + "acceptDraw": "Zaakceptuj losowanie", + "declineDrawAria": "Odrzuć ofertę losowania", + "declineDraw": "Odrzuć losowanie", + "offerDrawAria": "Oferta losowania", + "offerDraw": "Oferta losowania", + "resendAria": "Wyślij ponownie ostatnią akcję", + "resend": "Wyślij ponownie", + "deleteSessionAria": "Usuń sesję", + "deleteSession": "Usuń sesję", + "resignConfirmTitle": "Zrezygnować z gry?", + "resignConfirmMessage": "Czy na pewno chcesz zrezygnować z tej gry? Tej czynności nie można cofnąć.", + "drawOfferedBanner": "Twój przeciwnik zaoferował remis.", + "challenge": "Wyzwanie", + "challengeAria": "Wyzwanie w grze", + "challengeAppAria": "Wyzwanie dla {{app}}", + "challengeSent": "Wysłano wyzwanie: {{app}}", + "ttt": { + "youWon": "Zwycięstwo!", + "opponentWon": "Przeciwnik wygrał.", + "draw": "Rys.", + "yourTurn": "Twój ruch", + "opponentTurn": "Tura przeciwnika", + "yourMarker": "Jesteś {{marker}}", + "moveCount": "{{count}} ruchy", + "boardAria": "Deska Tic-Tac-Toe", + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" + }, + "chess": { + "youWon": "Zwycięstwo!", + "opponentWon": "Przeciwnik wygrał.", + "draw": "Rys.", + "yourTurn": "Twój ruch", + "yourTurnInCheck": "Twoja kolej — wszystko jest pod kontrolą", + "opponentTurn": "Tura przeciwnika", + "boardAria": "szachownica", + "squareAria": "{{square}}, {{piece}}", + "emptySquare": "pusty", + "legalMoveAria": "Graj {{move}}", + "pieceNames": { + "K": "Biały król", + "Q": "Biały Hetman", + "R": "Biała wieża", + "B": "Biały goniec", + "N": "Biały rycerz", + "P": "Biały pionek", + "k": "czarny król", + "q": "czarna królowa", + "r": "Czarna wieża", + "b": "Czarny goniec", + "n": "Czarny skoczek", + "p": "czarny pionek" + } + }, + "errors": { + "invalidPeerHash": "Wprowadź prawidłowy 32-znakowy hasz celu równorzędnego.", + "actionFailed": "Działanie nie powiodło się: {{reason}}", + "unknownReason": "nieznanych powodów", + "resendFailed": "Ponowne wysłanie nie powiodło się.", + "deleteFailed": "Nie udało się usunąć sesji." + }, + "deleteConfirmTitle": "Usuń sesję", + "deleteConfirmMessage": "Usunąć tę sesję gry z listy? Tej czynności nie można cofnąć." } } diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index cd987a65a..54f4174ec 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -4547,7 +4547,8 @@ "topology": "Topologia de rede", "network": "Rede", "rrc": "RRC", - "remote": "Remoto" + "remote": "Remoto", + "games": "Jogos" }, "takServerPanel": { "title": "Servidor TAK", @@ -5149,5 +5150,106 @@ }, "statsSeparator": "·", "ringing": "Chamando…" + }, + "gamesPanel": { + "title": "Jogos", + "filters": { + "all": "Todos", + "active": "Ativo", + "pending": "Pendente", + "completed": "Concluído" + }, + "noSessions": "Ainda não há sessões de jogo.", + "sessionRowAria": "Jogo {{app}} com {{peer}}", + "apps": { + "ttt": "Jogo da velha", + "chess": "Xadrez" + }, + "status": { + "pending": "Pendente", + "active": "Ativo", + "completed": "Concluído", + "expired": "Expirado", + "declined": "Recusado" + }, + "unreadBadgeAria": "{{count}} não lido", + "newChallenge": "Novo Desafio", + "peerHashPlaceholder": "Hash de destino", + "peerHashAria": "Hash de destino", + "selectAppAria": "Selecione o Jogo", + "sendChallengeAria": "Enviar desafio", + "sendChallenge": "Enviar desafio", + "selectSessionPrompt": "Selecione uma sessão de jogo para jogar.", + "opponentLabel": "Oponente: {{peer}}", + "acceptAria": "Aceitar Desafio", + "accept": "Aceitar", + "declineAria": "Recusar desafio", + "decline": "Recusar", + "resignAria": "Renunciar", + "resign": "Renunciar", + "acceptDrawAria": "Aceitar oferta de sorteio", + "acceptDraw": "Aceitar sorteio", + "declineDrawAria": "Recusar oferta de sorteio", + "declineDraw": "Recusar sorteio", + "offerDrawAria": "Oferecer empate", + "offerDraw": "Oferecer empate", + "resendAria": "Reenviar última ação", + "resend": "Reenviar", + "deleteSessionAria": "Excluir Sessão", + "deleteSession": "Excluir Sessão", + "resignConfirmTitle": "Desistir da partida", + "resignConfirmMessage": "Tem certeza de que deseja desistir deste jogo? Isso não pode ser desfeito.", + "drawOfferedBanner": "Seu oponente ofereceu um empate.", + "challenge": "Desafio", + "challengeAria": "Desafio para um jogo", + "challengeAppAria": "Desafio para {{app}}", + "challengeSent": "{{app}} desafio enviado", + "ttt": { + "youWon": "Ganhaste!", + "opponentWon": "O oponente venceu.", + "draw": "Desenho", + "yourTurn": "Sua vez", + "opponentTurn": "Turno do(a) oponente", + "yourMarker": "Você é {{marker}}", + "moveCount": "{{count}} movimentos", + "boardAria": "Placa Tic-Tac-Toe", + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" + }, + "chess": { + "youWon": "Ganhaste!", + "opponentWon": "O oponente venceu.", + "draw": "Desenho", + "yourTurn": "Sua vez", + "yourTurnInCheck": "Sua vez — você está sob controle", + "opponentTurn": "Turno do(a) oponente", + "boardAria": "tabuleiro de xadrez", + "squareAria": "{{square}}, {{piece}}", + "emptySquare": "vazio", + "legalMoveAria": "Reproduzir {{move}}", + "pieceNames": { + "K": "rei branco", + "Q": "rainha branca", + "R": "torre branca", + "B": "bispo branco", + "N": "cavalo branco", + "P": "peão branco", + "k": "rei preto", + "q": "rainha preta", + "r": "torre preta", + "b": "bispo preto", + "n": "cavalo preto", + "p": "peão preto" + } + }, + "errors": { + "invalidPeerHash": "Insira um hash de destino de par de 32 caracteres válido.", + "actionFailed": "Falha na ação: {{reason}}", + "unknownReason": "razão desconhecida.", + "resendFailed": "O reenvio falhou", + "deleteFailed": "Falha ao excluir sessão." + }, + "deleteConfirmTitle": "Excluir sessão?", + "deleteConfirmMessage": "Remover esta sessão de jogo da sua lista? Isso não pode ser desfeito." } } diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 599625543..8c95fb21a 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -4549,7 +4549,8 @@ "topology": "Топология сети", "network": "Сеть", "rrc": "RRC", - "remote": "Удалённый" + "remote": "Удалённый", + "games": "Игры" }, "takServerPanel": { "title": "TAK Сервер", @@ -5151,5 +5152,106 @@ }, "statsSeparator": "·", "ringing": "Идёт вызов…" + }, + "gamesPanel": { + "title": "Игры", + "filters": { + "all": "Все", + "active": "Действующая", + "pending": "В процессе", + "completed": "Завершено" + }, + "noSessions": "Игровых сессий пока нет.", + "sessionRowAria": "{{app}} игра с {{peer}}", + "apps": { + "ttt": "Крестики Нолики", + "chess": "Шахматы" + }, + "status": { + "pending": "В процессе", + "active": "Действующая", + "completed": "Завершено", + "expired": "Истек срок действия", + "declined": "Отклонено" + }, + "unreadBadgeAria": "{{count}} НЕ прочитано", + "newChallenge": "Новый спор", + "peerHashPlaceholder": "Хеш назначения однорангового узла", + "peerHashAria": "Хеш назначения однорангового узла", + "selectAppAria": "Выбрать игру", + "sendChallengeAria": "Отправить вызов", + "sendChallenge": "Отправить вызов", + "selectSessionPrompt": "Выберите игровой сеанс для игры.", + "opponentLabel": "Противник: {{peer}}", + "acceptAria": "Принять вызов", + "accept": "Принять", + "declineAria": "Отклонить вызов", + "decline": "Отклонить", + "resignAria": "(Добровольное) увольнение", + "resign": "(Добровольное) увольнение", + "acceptDrawAria": "Принять предложение о розыгрыше", + "acceptDraw": "Принять жеребьев", + "declineDrawAria": "Отклонить предложение о розыгрыше", + "declineDraw": "Отклонить розыгрыш", + "offerDrawAria": "Розыгрыш предложения", + "offerDraw": "Розыгрыш предложения", + "resendAria": "Отправить последнее действие еще раз", + "resend": "Отправить повторно", + "deleteSessionAria": "Удалить сессию", + "deleteSession": "Удалить сессию", + "resignConfirmTitle": "Выйти из игры?", + "resignConfirmMessage": "Вы уверены, что хотите выйти из этой игры? Это действие нельзя отменить.", + "drawOfferedBanner": "Ваш оппонент предложил ничью.", + "challenge": "Испытание", + "challengeAria": "Вызов игре", + "challengeAppAria": "Вызов {{app}}", + "challengeSent": "{{app}} вызов отправлен", + "ttt": { + "youWon": "Вы победили!", + "opponentWon": "Противник победил.", + "draw": "Чертеж", + "yourTurn": "Твой черед", + "opponentTurn": "Ход противника", + "yourMarker": "Вы {{marker}}", + "moveCount": "{{count}} ходов", + "boardAria": "Доска крестики-нолики", + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" + }, + "chess": { + "youWon": "Вы победили!", + "opponentWon": "Противник победил.", + "draw": "Чертеж", + "yourTurn": "Твой черед", + "yourTurnInCheck": "Ваша очередь — вы на чеку", + "opponentTurn": "Ход противника", + "boardAria": "Шахматная доска", + "squareAria": "{{square}}, {{piece}}", + "emptySquare": "пустой", + "legalMoveAria": "Играть {{move}}", + "pieceNames": { + "K": "Белый король", + "Q": "БЕЛАЯ КОРОЛЕВА", + "R": "Белая ладья", + "B": "Белый слон", + "N": "Белый рыцарь", + "P": "Белая пешка", + "k": "Черный король", + "q": "Черная королева", + "r": "Черная ладья", + "b": "Чёрный епископ", + "n": "Чёрный рыцарь", + "p": "Черная пешка" + } + }, + "errors": { + "invalidPeerHash": "Введите допустимый 32-символьный одноранговый хэш назначения.", + "actionFailed": "Действие не выполнено: {{reason}}", + "unknownReason": "неизвесная причина", + "resendFailed": "Повторная отправка не удалась.", + "deleteFailed": "Не удалось удалить сеанс." + }, + "deleteConfirmTitle": "Удалить сессию", + "deleteConfirmMessage": "Удалить эту игровую сессию из списка? Это действие нельзя отменить." } } diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 74b844289..1bb764252 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -4547,7 +4547,8 @@ "topology": "Ağ topolojisi", "network": "Ağ", "rrc": "RRC", - "remote": "Uzak" + "remote": "Uzak", + "games": "Oyun" }, "takServerPanel": { "title": "TAK Sunucusu", @@ -5149,5 +5150,106 @@ }, "statsSeparator": "·", "ringing": "Çalıyor…" + }, + "gamesPanel": { + "title": "Oyun", + "filters": { + "all": "Tümü", + "active": "Aktif", + "pending": "Bekleyen", + "completed": "Tamamlandı" + }, + "noSessions": "Henüz oyun oturumu yok.", + "sessionRowAria": "{{app}} ile {{peer}} oyunu", + "apps": { + "ttt": "Tic Tac Toe", + "chess": "Satranç" + }, + "status": { + "pending": "Bekleyen", + "active": "Aktif", + "completed": "Tamamlandı", + "expired": "Süresi doldu", + "declined": "Reddedildi" + }, + "unreadBadgeAria": "{{count}} okunmadı", + "newChallenge": "Yeni meydan okuma", + "peerHashPlaceholder": "Eş hedef hash'i", + "peerHashAria": "Eş hedef hash'i", + "selectAppAria": "Oyunu Seç", + "sendChallengeAria": "Meydan okuma gönder", + "sendChallenge": "Meydan okuma gönder", + "selectSessionPrompt": "Oynamak için bir oyun oturumu seçin.", + "opponentLabel": "Rakip: {{peer}}", + "acceptAria": "Mücadeleyi Kabul Et", + "accept": "Kabul et", + "declineAria": "Meydan okumayı reddet", + "decline": "Reddet", + "resignAria": "Çekil", + "resign": "Çekil", + "acceptDrawAria": "Beraberlik teklifini kabul", + "acceptDraw": "Beraberlik Kabul Et", + "declineDrawAria": "Çekiliş teklifini reddet", + "declineDraw": "Beraberliği reddet", + "offerDrawAria": "Teklif çekilişi", + "offerDraw": "Teklif çekilişi", + "resendAria": "Son eylemi yeniden gönder", + "resend": "Yeniden gönderin", + "deleteSessionAria": "Oturumları Yapılandır...", + "deleteSession": "Oturumları Yapılandır...", + "resignConfirmTitle": "Oyundan çekil?", + "resignConfirmMessage": "Bu oyundan çıkmak istediğinden emin misin? Bu işlem geri alınamaz.", + "drawOfferedBanner": "Rakibiniz beraberlik teklif etti.", + "challenge": "Mücadele", + "challengeAria": "Bir oyun için meydan oku", + "challengeAppAria": "{{app}} için meydan okuma", + "challengeSent": "{{app}} meydan okuma gönderildi", + "ttt": { + "youWon": "Sen kazandın!", + "opponentWon": "Rakip kazandı.", + "draw": "Berabere.", + "yourTurn": "Sıra sende.", + "opponentTurn": "Rakibin Sırası", + "yourMarker": "Siz {{marker}}", + "moveCount": "{{count}} hamle", + "boardAria": "Tic - Tac - Toe tahtası", + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" + }, + "chess": { + "youWon": "Sen kazandın!", + "opponentWon": "Rakip kazandı.", + "draw": "Berabere.", + "yourTurn": "Sıra sende.", + "yourTurnInCheck": "Sıra sizde — kontrol altındasınız", + "opponentTurn": "Rakibin Sırası", + "boardAria": "Satranç tahtası", + "squareAria": "{{square}}, {{piece}}", + "emptySquare": "boş", + "legalMoveAria": "Oynat {{move}}", + "pieceNames": { + "K": "Beyaz Kral", + "Q": "Beyaz kraliçe", + "R": "Beyaz Kale", + "B": "Psikopoz", + "N": "Beyaz at", + "P": "Beyaz piyon", + "k": "Kara Kral", + "q": "Siyah kraliçe", + "r": "Siyah kale", + "b": "Kara fil", + "n": "Kara Şövalye", + "p": "Siyah piyon" + } + }, + "errors": { + "invalidPeerHash": "Geçerli bir 32 karakterlik eş hedef karması girin.", + "actionFailed": "İşlem başarısız oldu: {{reason}}", + "unknownReason": "bilinmeyen neden", + "resendFailed": "Yeniden Gönderilemedi", + "deleteFailed": "Oturum silinemedi." + }, + "deleteConfirmTitle": "Oturumları Yapılandır...", + "deleteConfirmMessage": "Bu oyun oturumu listenden kaldırılsın mı? Bu işlem geri alınamaz." } } diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 75c5f9bd8..a439698fd 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -4549,7 +4549,8 @@ "topology": "Топологія мережі", "network": "Мережа", "rrc": "RRC", - "remote": "Віддалений" + "remote": "Віддалений", + "games": "Ігри" }, "takServerPanel": { "title": "Сервер TAK", @@ -5151,5 +5152,106 @@ }, "statsSeparator": "·", "ringing": "Дзвенить…" + }, + "gamesPanel": { + "title": "Ігри", + "filters": { + "all": "Всі", + "active": "Активний", + "pending": "Очікується", + "completed": "Завершено" + }, + "noSessions": "Ще немає ігрових сеансів.", + "sessionRowAria": "{{app}} гра з {{peer}}", + "apps": { + "ttt": "Хрестики-нулики", + "chess": "Шахи" + }, + "status": { + "pending": "Очікується", + "active": "Активний", + "completed": "Завершено", + "expired": "Минув термін дії", + "declined": "Відхилено" + }, + "unreadBadgeAria": "{{count}} непрочитаних", + "newChallenge": "Новий виклик!", + "peerHashPlaceholder": "Хеш призначення вузла", + "peerHashAria": "Хеш призначення вузла", + "selectAppAria": "Вибрати гру", + "sendChallengeAria": "Надіслати челендж", + "sendChallenge": "Надіслати челендж", + "selectSessionPrompt": "Виберіть ігровий сеанс для відтворення.", + "opponentLabel": "Опонент: {{peer}}", + "acceptAria": "Прийняти виклик", + "accept": "Прийняти", + "declineAria": "Відхилити виклик", + "decline": "Відмовитися", + "resignAria": "Здатись", + "resign": "Здатись", + "acceptDrawAria": "Прийняти пропозицію розіграшу", + "acceptDraw": "Прийняти розіграш", + "declineDrawAria": "Відхилити пропозицію розіграшу", + "declineDraw": "Відхилити розіграш", + "offerDrawAria": "Розіграш пропозицій", + "offerDraw": "Розіграш пропозицій", + "resendAria": "Надіслати останню дію ще раз", + "resend": "Відправити повторно", + "deleteSessionAria": "Вилучити сеанс...", + "deleteSession": "Вилучити сеанс...", + "resignConfirmTitle": "Вийти з гри?", + "resignConfirmMessage": "Ви впевнені, що хочете вийти з цієї гри? Цю дію неможливо скасувати.", + "drawOfferedBanner": "Ваш суперник запропонував нічию.", + "challenge": "Проблема", + "challengeAria": "Виклик грі", + "challengeAppAria": "Виклик {{app}}", + "challengeSent": "{{app}} виклик надіслано", + "ttt": { + "youWon": "Ви виграли!", + "opponentWon": "Противник переміг.", + "draw": "Балка", + "yourTurn": "Ваш хід.", + "opponentTurn": "Хід опонента", + "yourMarker": "Вас звати {{marker}}", + "moveCount": "{{count}} ходів", + "boardAria": "Дошка Tic-Tac-Toe", + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" + }, + "chess": { + "youWon": "Ви виграли!", + "opponentWon": "Противник переміг.", + "draw": "Балка", + "yourTurn": "Ваш хід.", + "yourTurnInCheck": "Ваша черга — ви на чеку", + "opponentTurn": "Хід опонента", + "boardAria": "Шахова дошкаFilter Effect: Melt Down", + "squareAria": "{{square}}, {{piece}}", + "emptySquare": "Порожня клітина", + "legalMoveAria": "Грати {{move}}", + "pieceNames": { + "K": "Білий король", + "Q": "Біла королева", + "R": "Біла грач", + "B": "Білий слон", + "N": "Білий лицар", + "P": "Білий пішак", + "k": "Чорний король", + "q": "Чорна королева", + "r": "Чорна грач", + "b": "Чорний слон", + "n": "Чорний лицар", + "p": "Чорний пішак" + } + }, + "errors": { + "invalidPeerHash": "Введіть дійсний 32-символьний хеш призначення вузла.", + "actionFailed": "Помилка дії: {{reason}}", + "unknownReason": "Невідома причина", + "resendFailed": "Помилка повторного надсилання.", + "deleteFailed": "Не вдалося видалити сеанс." + }, + "deleteConfirmTitle": "Вилучити сеанс...", + "deleteConfirmMessage": "Видалити цю ігрову сесію зі списку? Цю дію неможливо скасувати." } } diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 2ea2c89fc..d009a013b 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -4547,7 +4547,8 @@ "topology": "网络拓扑图", "network": "网络", "rrc": "RRC", - "remote": "远程" + "remote": "远程", + "games": "出賽" }, "takServerPanel": { "title": "TAK服务器", @@ -5149,5 +5150,106 @@ }, "statsSeparator": "·", "ringing": "正在响铃…" + }, + "gamesPanel": { + "title": "出賽", + "filters": { + "all": "所有食谱", + "active": "Active", + "pending": "待定", + "completed": "已完成" + }, + "noSessions": "还没有游戏会话。", + "sessionRowAria": "{{app}}游戏与{{peer}}", + "apps": { + "ttt": "井字游戏", + "chess": "棋格" + }, + "status": { + "pending": "待定", + "active": "Active", + "completed": "已完成", + "expired": "已失效", + "declined": "已拒绝" + }, + "unreadBadgeAria": "{{count}}未读", + "newChallenge": "新的挑战!", + "peerHashPlaceholder": "对等目标哈希", + "peerHashAria": "对等目标哈希", + "selectAppAria": "选择游戏:", + "sendChallengeAria": "发送挑战\"", + "sendChallenge": "发送挑战\"", + "selectSessionPrompt": "选择要玩的游戏会话。", + "opponentLabel": "对手: {{peer}}", + "acceptAria": "接受挑战", + "accept": "接受", + "declineAria": "拒绝挑战", + "decline": "拒绝", + "resignAria": "放弃", + "resign": "放弃", + "acceptDrawAria": "接受抽奖报价", + "acceptDraw": "接受抽奖", + "declineDrawAria": "拒绝抽奖优惠", + "declineDraw": "拒绝抽奖", + "offerDrawAria": "提出和棋", + "offerDraw": "提出和棋", + "resendAria": "重新发送上次操作", + "resend": "重新发送", + "deleteSessionAria": "删除会话?", + "deleteSession": "删除会话?", + "resignConfirmTitle": "是否放弃游戏?", + "resignConfirmMessage": "您确定要退出此游戏吗?此操作无法撤消。", + "drawOfferedBanner": "您的对手开出平局。", + "challenge": "挑战", + "challengeAria": "挑战游戏", + "challengeAppAria": "挑战{{app}}", + "challengeSent": "{{app}}挑战已发送", + "ttt": { + "youWon": "您赢了 !", + "opponentWon": "对手获胜。", + "draw": "单次抽奖", + "yourTurn": "轮到你了", + "opponentTurn": "对手的回合", + "yourMarker": "您是{{marker}}", + "moveCount": "{{count}}步", + "boardAria": "Tic-Tac-Toe板", + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" + }, + "chess": { + "youWon": "您赢了 !", + "opponentWon": "对手获胜。", + "draw": "单次抽奖", + "yourTurn": "轮到你了", + "yourTurnInCheck": "轮到你了—你可以检查了", + "opponentTurn": "对手的回合", + "boardAria": "棋盘", + "squareAria": "{{square}}, {{piece}}", + "emptySquare": "空", + "legalMoveAria": "播放{{move}}", + "pieceNames": { + "K": "白王", + "Q": "白皇后", + "R": "白色车厢", + "B": "White bishop", + "N": "白骑士", + "P": "白兵", + "k": "黑王", + "q": "黑方王后", + "r": "黑方车", + "b": "黑人主教", + "n": "黑骑士", + "p": "黑兵" + } + }, + "errors": { + "invalidPeerHash": "输入有效的32个字符的对等目标哈希。", + "actionFailed": "操作失败: {{reason}}", + "unknownReason": "未知原因", + "resendFailed": "重新发送失败", + "deleteFailed": "删除会话失败。" + }, + "deleteConfirmTitle": "删除会话?", + "deleteConfirmMessage": "从您的列表中删除此游戏会话?此操作无法撤消。" } } diff --git a/src/renderer/runtime/useReticulumRuntime.games.test.ts b/src/renderer/runtime/useReticulumRuntime.games.test.ts new file mode 100644 index 000000000..157e5bb1d --- /dev/null +++ b/src/renderer/runtime/useReticulumRuntime.games.test.ts @@ -0,0 +1,146 @@ +// @vitest-environment jsdom +/** + * Behavioral tests for LRGP games WebSocket event routing + * (`games.update` / `games.action_result`), mirroring the voice event pattern. + */ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resetReticulumManualStackStopSuppressForTests } from '@/renderer/lib/reticulum/reticulumManualStackStopSuppress'; +import { useReticulumRuntime } from '@/renderer/runtime/useReticulumRuntime'; +import { useReticulumGamesStore } from '@/renderer/stores/reticulumGamesStore'; +import type { ReticulumSidecarEvent } from '@/shared/reticulum-types'; + +vi.mock('@/renderer/lib/reticulum/fetchRecentInboundLxmf', () => ({ + fetchRecentInboundLxmf: vi.fn().mockResolvedValue([]), + fetchRecentInboundLxmfDetailed: vi.fn().mockResolvedValue({ messages: [], ringLen: 0 }), +})); + +vi.mock('@/renderer/lib/reticulum/useReticulumNobleBleYieldWatcher', () => ({ + useReticulumNobleBleYieldWatcher: () => {}, +})); + +vi.mock('@/renderer/lib/reticulum/useReticulumPropagationAutoSync', () => ({ + useReticulumPropagationAutoSync: () => {}, +})); + +vi.mock('@/renderer/components/Toast', () => ({ + pushAppToast: vi.fn(), + useToast: () => ({ addToast: vi.fn() }), +})); + +function makeSession(overrides: Record = {}) { + return { + session_id: 's1', + identity_id: 'me', + app_id: 'ttt', + app_version: 1, + contact_hash: 'a'.repeat(32), + initiator: 'a'.repeat(32), + status: 'active', + metadata: { board: 'X________', turn: 'me' }, + unread: 1, + created_at: 1, + updated_at: 2, + last_action_at: 2, + ...overrides, + }; +} + +describe('useReticulumRuntime games event routing', () => { + let eventHandler: ((evt: ReticulumSidecarEvent) => void) | null = null; + + beforeEach(() => { + resetReticulumManualStackStopSuppressForTests(); + useReticulumGamesStore.getState().clear(); + eventHandler = null; + vi.mocked(window.electronAPI.reticulum.onEvent).mockImplementation((cb) => { + eventHandler = cb; + return () => { + if (eventHandler === cb) eventHandler = null; + }; + }); + vi.mocked(window.electronAPI.reticulum.onVoiceAudio).mockImplementation(() => () => {}); + vi.mocked(window.electronAPI.reticulum.start).mockResolvedValue({ + running: true, + port: 19437, + pid: 1, + }); + vi.mocked(window.electronAPI.reticulum.stop).mockResolvedValue(undefined); + vi.mocked(window.electronAPI.reticulum.getStatus).mockResolvedValue({ + running: true, + port: 19437, + pid: 1, + healthy: true, + }); + }); + + afterEach(() => { + vi.mocked(window.electronAPI.reticulum.onEvent).mockReset(); + vi.mocked(window.electronAPI.reticulum.onEvent).mockReturnValue(() => {}); + vi.mocked(window.electronAPI.reticulum.onVoiceAudio).mockReset(); + vi.mocked(window.electronAPI.reticulum.onVoiceAudio).mockReturnValue(() => {}); + useReticulumGamesStore.getState().clear(); + }); + + async function connectAndGetHandler() { + const { result, unmount } = renderHook(() => useReticulumRuntime()); + await act(async () => { + await result.current.connect(); + }); + expect(eventHandler).toBeTruthy(); + return { onEvent: eventHandler!, unmount }; + } + + it('upserts sessions from games.update', async () => { + const { onEvent, unmount } = await connectAndGetHandler(); + + act(() => { + onEvent({ + type: 'games.update', + payload: { + app_id: 'ttt', + session_id: 's1', + direction: 'inbound', + session: makeSession(), + }, + }); + }); + expect(useReticulumGamesStore.getState().sessions).toHaveLength(1); + expect(useReticulumGamesStore.getState().sessions[0].status).toBe('active'); + + act(() => { + onEvent({ + type: 'games.update', + payload: { + app_id: 'ttt', + session_id: 's1', + direction: 'outbound', + session: makeSession({ status: 'completed', last_action_at: 5 }), + }, + }); + }); + expect(useReticulumGamesStore.getState().sessions).toHaveLength(1); + expect(useReticulumGamesStore.getState().sessions[0].status).toBe('completed'); + + unmount(); + }); + + it('applies games.action_result and clears actionBusy', async () => { + const { onEvent, unmount } = await connectAndGetHandler(); + + useReticulumGamesStore.getState().setActionBusy(true); + act(() => { + onEvent({ + type: 'games.action_result', + payload: { app_id: 'ttt', session_id: 's1', ok: false, error: 'not_your_turn' }, + }); + }); + const state = useReticulumGamesStore.getState(); + expect(state.actionBusy).toBe(false); + expect(state.lastActionResult?.ok).toBe(false); + expect(state.lastActionResult?.error).toBe('not_your_turn'); + + unmount(); + }); +}); diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 4799d0f5a..5dfa8944f 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -177,6 +177,7 @@ import { normalizeRmapDiscoveryRows, useReticulumDiscoveryMapStore, } from '../stores/reticulumDiscoveryMapStore'; +import { useReticulumGamesStore } from '../stores/reticulumGamesStore'; import { parseAnnounceActivityRows, setReticulumAnnounceBusPressureActive, @@ -1231,6 +1232,12 @@ export function useReticulumRuntime(): ProtocolRuntime { callGeneration: useReticulumVoiceStore.getState().callGeneration, }); } + if (evt.type === 'games.update' && evt.payload && typeof evt.payload === 'object') { + useReticulumGamesStore.getState().applyGamesUpdate(evt.payload); + } + if (evt.type === 'games.action_result' && evt.payload && typeof evt.payload === 'object') { + useReticulumGamesStore.getState().applyActionResult(evt.payload); + } if (evt.type === 'rncp.progress' && evt.payload && typeof evt.payload === 'object') { const p = evt.payload as { transfer_id?: string; progress?: number }; if (p.transfer_id && typeof p.progress === 'number') { diff --git a/src/renderer/stores/reticulumGamesStore.test.ts b/src/renderer/stores/reticulumGamesStore.test.ts new file mode 100644 index 000000000..a91261b75 --- /dev/null +++ b/src/renderer/stores/reticulumGamesStore.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { useReticulumGamesStore } from './reticulumGamesStore'; + +function makeSession(overrides: Partial> = {}) { + return { + session_id: 's1', + identity_id: 'me', + app_id: 'ttt', + app_version: 1, + contact_hash: 'a'.repeat(32), + initiator: 'me', + status: 'pending', + metadata: {}, + unread: 1, + created_at: 1, + updated_at: 1, + last_action_at: 1, + ...overrides, + }; +} + +describe('reticulumGamesStore', () => { + beforeEach(() => { + useReticulumGamesStore.getState().clear(); + }); + + it('setSessions replaces the list and filters invalid rows', () => { + useReticulumGamesStore + .getState() + .setSessions([ + makeSession(), + { not: 'a session' }, + null, + { session_id: 'bad-missing-fields' }, + makeSession({ session_id: 's2', unread: Number.NaN }), + makeSession({ session_id: 's3', created_at: Number.POSITIVE_INFINITY }), + ]); + expect(useReticulumGamesStore.getState().sessions).toHaveLength(1); + expect(useReticulumGamesStore.getState().sessions[0].session_id).toBe('s1'); + }); + + it('setApps rejects incomplete manifests', () => { + useReticulumGamesStore + .getState() + .setApps([ + { app_id: 'ttt', version: 1, display_name: 'Tic-Tac-Toe' }, + { app_id: 'chess' }, + { version: 1, display_name: 'Nope' }, + null, + ]); + expect(useReticulumGamesStore.getState().apps).toHaveLength(1); + expect(useReticulumGamesStore.getState().apps[0].app_id).toBe('ttt'); + }); + + it('applyGamesUpdate ignores malformed payloads and incomplete sessions', () => { + useReticulumGamesStore.getState().setSessions([makeSession()]); + useReticulumGamesStore.getState().applyGamesUpdate({ session_id: 's1' }); + useReticulumGamesStore.getState().applyGamesUpdate({ + app_id: 'ttt', + session_id: 's1', + session: { session_id: 's1' }, + }); + expect(useReticulumGamesStore.getState().sessions[0].status).toBe('pending'); + }); + + it('upsertSession inserts new and updates existing sessions', () => { + const store = useReticulumGamesStore.getState(); + store.upsertSession(makeSession()); + expect(useReticulumGamesStore.getState().sessions).toHaveLength(1); + + store.upsertSession(makeSession({ status: 'active', last_action_at: 5 })); + const sessions = useReticulumGamesStore.getState().sessions; + expect(sessions).toHaveLength(1); + expect(sessions[0].status).toBe('active'); + + store.upsertSession(makeSession({ session_id: 's2', last_action_at: 10 })); + expect(useReticulumGamesStore.getState().sessions).toHaveLength(2); + // Sorted by last_action_at desc. + expect(useReticulumGamesStore.getState().sessions[0].session_id).toBe('s2'); + }); + + it('applyGamesUpdate upserts the session embedded in the WS payload', () => { + useReticulumGamesStore.getState().applyGamesUpdate({ + app_id: 'ttt', + session_id: 's1', + direction: 'inbound', + session: makeSession({ status: 'active' }), + }); + const sessions = useReticulumGamesStore.getState().sessions; + expect(sessions).toHaveLength(1); + expect(sessions[0].status).toBe('active'); + }); + + it('applyGamesUpdate ignores payloads with no session', () => { + useReticulumGamesStore.getState().setSessions([makeSession()]); + useReticulumGamesStore.getState().applyGamesUpdate({ + app_id: 'ttt', + session_id: 's1', + session: null, + error: { code: 'not_your_turn' }, + }); + expect(useReticulumGamesStore.getState().sessions).toHaveLength(1); + }); + + it('applyActionResult clears actionBusy and stores the result', () => { + useReticulumGamesStore.getState().setActionBusy(true); + useReticulumGamesStore.getState().applyActionResult({ + app_id: 'ttt', + session_id: 's1', + ok: false, + error: 'not_your_turn', + }); + const state = useReticulumGamesStore.getState(); + expect(state.actionBusy).toBe(false); + expect(state.lastActionResult?.ok).toBe(false); + expect(state.lastActionResult?.error).toBe('not_your_turn'); + }); + + it('selectSession clears lastActionResult when switching sessions', () => { + useReticulumGamesStore.setState({ + lastActionResult: { app_id: 'ttt', session_id: 's1', ok: true }, + }); + useReticulumGamesStore.getState().selectSession('s2'); + expect(useReticulumGamesStore.getState().selectedSessionId).toBe('s2'); + expect(useReticulumGamesStore.getState().lastActionResult).toBeNull(); + }); + + it('removeSession drops the row and clears selection if selected', () => { + useReticulumGamesStore.getState().setSessions([makeSession()]); + useReticulumGamesStore.getState().selectSession('s1'); + useReticulumGamesStore.getState().removeSession('s1'); + expect(useReticulumGamesStore.getState().sessions).toHaveLength(0); + expect(useReticulumGamesStore.getState().selectedSessionId).toBeNull(); + }); + + it('clear resets the whole store', () => { + useReticulumGamesStore.getState().setSessions([makeSession()]); + useReticulumGamesStore.getState().selectSession('s1'); + useReticulumGamesStore.getState().setActionBusy(true); + useReticulumGamesStore.getState().clear(); + const state = useReticulumGamesStore.getState(); + expect(state.sessions).toHaveLength(0); + expect(state.selectedSessionId).toBeNull(); + expect(state.actionBusy).toBe(false); + expect(state.lastActionResult).toBeNull(); + expect(state.apps).toHaveLength(0); + expect(state.status).toBeNull(); + }); +}); diff --git a/src/renderer/stores/reticulumGamesStore.ts b/src/renderer/stores/reticulumGamesStore.ts new file mode 100644 index 000000000..1f5dc6b0f --- /dev/null +++ b/src/renderer/stores/reticulumGamesStore.ts @@ -0,0 +1,169 @@ +import { create } from 'zustand'; + +import type { + GamesActionResultEventPayload, + GamesAppManifest, + GameSession, + GamesStatusResponse, + GamesUpdateEventPayload, +} from '@/shared/games-types'; + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function isGameSession(value: unknown): value is GameSession { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + return ( + typeof v.session_id === 'string' && + v.session_id.length > 0 && + typeof v.identity_id === 'string' && + typeof v.app_id === 'string' && + isFiniteNumber(v.app_version) && + typeof v.contact_hash === 'string' && + typeof v.initiator === 'string' && + typeof v.status === 'string' && + !!v.metadata && + typeof v.metadata === 'object' && + !Array.isArray(v.metadata) && + isFiniteNumber(v.unread) && + isFiniteNumber(v.created_at) && + isFiniteNumber(v.updated_at) && + isFiniteNumber(v.last_action_at) + ); +} + +function asGameSession(value: unknown): GameSession | null { + return isGameSession(value) ? value : null; +} + +function isGamesAppManifest(value: unknown): value is GamesAppManifest { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + return ( + typeof v.app_id === 'string' && + v.app_id.length > 0 && + isFiniteNumber(v.version) && + typeof v.display_name === 'string' + ); +} + +function isGamesUpdatePayload(value: unknown): value is GamesUpdateEventPayload { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + return typeof v.app_id === 'string' && typeof v.session_id === 'string'; +} + +interface ReticulumGamesStoreState { + sessions: GameSession[]; + selectedSessionId: string | null; + apps: GamesAppManifest[]; + status: GamesStatusResponse | null; + actionBusy: boolean; + lastActionResult: GamesActionResultEventPayload | null; + + setSessions: (sessions: unknown) => void; + upsertSession: (session: unknown) => void; + removeSession: (sessionId: string) => void; + applyGamesUpdate: (payload: unknown) => void; + applyActionResult: (payload: unknown) => void; + setApps: (apps: unknown) => void; + setStatus: (status: GamesStatusResponse | null) => void; + selectSession: (sessionId: string | null) => void; + setActionBusy: (busy: boolean) => void; + clear: () => void; +} + +function sortedSessions(sessions: GameSession[]): GameSession[] { + return [...sessions].sort((a, b) => b.last_action_at - a.last_action_at); +} + +export const useReticulumGamesStore = create((set) => ({ + sessions: [], + selectedSessionId: null, + apps: [], + status: null, + actionBusy: false, + lastActionResult: null, + + setSessions: (sessions) => { + const list = Array.isArray(sessions) ? sessions.filter(isGameSession) : []; + set({ sessions: sortedSessions(list) }); + }, + + upsertSession: (session) => { + const next = asGameSession(session); + if (!next) return; + set((s) => { + const idx = s.sessions.findIndex((row) => row.session_id === next.session_id); + const sessions = [...s.sessions]; + if (idx >= 0) { + sessions[idx] = next; + } else { + sessions.push(next); + } + return { sessions: sortedSessions(sessions) }; + }); + }, + + removeSession: (sessionId) => { + set((s) => ({ + sessions: s.sessions.filter((row) => row.session_id !== sessionId), + selectedSessionId: s.selectedSessionId === sessionId ? null : s.selectedSessionId, + })); + }, + + applyGamesUpdate: (payload) => { + if (!isGamesUpdatePayload(payload)) return; + const session = asGameSession(payload.session); + if (!session) return; + set((s) => { + const idx = s.sessions.findIndex((row) => row.session_id === session.session_id); + const sessions = [...s.sessions]; + if (idx >= 0) { + sessions[idx] = session; + } else { + sessions.push(session); + } + return { sessions: sortedSessions(sessions) }; + }); + }, + + applyActionResult: (payload) => { + if (!payload || typeof payload !== 'object') return; + const p = payload as GamesActionResultEventPayload; + set({ actionBusy: false, lastActionResult: p }); + }, + + setApps: (apps) => { + const list = Array.isArray(apps) ? apps.filter(isGamesAppManifest) : []; + set({ apps: list }); + }, + + setStatus: (status) => { + set({ status }); + }, + + selectSession: (sessionId) => { + set((s) => ({ + selectedSessionId: sessionId, + lastActionResult: sessionId === s.selectedSessionId ? s.lastActionResult : null, + })); + }, + + setActionBusy: (busy) => { + set({ actionBusy: busy }); + }, + + clear: () => { + set({ + sessions: [], + selectedSessionId: null, + apps: [], + status: null, + actionBusy: false, + lastActionResult: null, + }); + }, +})); diff --git a/src/renderer/vitest.electronApiMock.ts b/src/renderer/vitest.electronApiMock.ts index 4d25dc8b3..e72fdf6d9 100644 --- a/src/renderer/vitest.electronApiMock.ts +++ b/src/renderer/vitest.electronApiMock.ts @@ -454,6 +454,16 @@ export function createElectronAPIMock(): ElectronAPI { }), getIdentity: vi.fn().mockResolvedValue({ identity_hash: null, rncp_receive_hash: null }), }, + games: { + getStatus: vi.fn().mockResolvedValue({ available: true, enabled: true, running: true }), + listApps: vi.fn().mockResolvedValue({ apps: [] }), + listSessions: vi.fn().mockResolvedValue({ sessions: [] }), + getSession: vi.fn().mockResolvedValue({ session: null }), + sendAction: vi.fn().mockResolvedValue({ ok: true }), + resend: vi.fn().mockResolvedValue({ ok: true }), + markRead: vi.fn().mockResolvedValue({ ok: true }), + deleteSession: vi.fn().mockResolvedValue({ ok: true }), + }, }, vault: { setPasscode: vi.fn().mockResolvedValue({ ok: true }), diff --git a/src/shared/electron-api.types.ts b/src/shared/electron-api.types.ts index 2ba7c47be..8dca97fa5 100644 --- a/src/shared/electron-api.types.ts +++ b/src/shared/electron-api.types.ts @@ -1,5 +1,14 @@ // Single source of truth for the Electron context bridge API surface. import type { MeshNode, MQTTSettings, MQTTStatus } from '../renderer/lib/types'; +import type { + GamesActionRequest, + GamesActionResult, + GamesAppManifest, + GamesListSessionsResponse, + GamesOkResponse, + GamesSessionDetailResponse, + GamesStatusResponse, +} from './games-types'; import type { MeshProtocol } from './meshProtocol'; import type { PathCapability, @@ -1127,6 +1136,20 @@ export interface ElectronAPI { mute: (opts: VoiceMuteRequest) => Promise; sendAudio: (opts: VoiceAudioRequest) => Promise; }; + /** + * LRGP games (lrgp-rs). Dedicated IPC channels — generic `proxyGet`/`proxyPost` + * reject `/api/v1/games/*` so session polls/moves do not share the 300/min proxy bucket. + */ + games: { + getStatus: () => Promise; + listApps: () => Promise<{ apps?: GamesAppManifest[] } | GamesStatusResponse>; + listSessions: (peer?: string) => Promise; + getSession: (sessionId: string) => Promise; + sendAction: (opts: GamesActionRequest) => Promise; + resend: (sessionId: string) => Promise; + markRead: (sessionId: string) => Promise; + deleteSession: (sessionId: string) => Promise; + }; /** * rncp (file transfer). `send` / `fetch` / `setListener` are picker-backed * (path must match the last `showOpenFileDialog` / `showSaveDirectoryDialog` diff --git a/src/shared/games-types.test.ts b/src/shared/games-types.test.ts new file mode 100644 index 000000000..53bffd5a3 --- /dev/null +++ b/src/shared/games-types.test.ts @@ -0,0 +1,45 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; + +import { isGamesApiPath, parseGamesActionRequest } from './games-types'; + +describe('games-types', () => { + it('detects games API paths', () => { + expect(isGamesApiPath('/api/v1/games/status')).toBe(true); + expect(isGamesApiPath('/api/v1/games/sessions/abc/read')).toBe(true); + expect(isGamesApiPath('/api/v1/games')).toBe(true); + expect(isGamesApiPath('/api/v1/games/sessions?peer=abc')).toBe(true); + expect(isGamesApiPath('/api/v1/voice/status')).toBe(false); + expect(isGamesApiPath('/api/v1/gameshow')).toBe(false); + }); + + it('parses valid game actions', () => { + const parsed = parseGamesActionRequest({ + dest_hash: 'aabbccddeeff00112233445566778899', + app_id: 'ttt', + command: 'challenge', + session_id: 'abcdef0123456789', + payload: { i: 4 }, + }); + expect(parsed).toEqual({ + dest_hash: 'aabbccddeeff00112233445566778899', + app_id: 'ttt', + command: 'challenge', + session_id: 'abcdef0123456789', + payload: { i: 4 }, + }); + }); + + it('rejects invalid game actions', () => { + expect(parseGamesActionRequest(null)).toEqual({ error: 'invalid_game_action' }); + expect(parseGamesActionRequest({ app_id: 'ttt', command: 'move' })).toEqual({ + error: 'invalid_dest_hash', + }); + expect(parseGamesActionRequest({ dest_hash: 'aa', command: 'move' })).toEqual({ + error: 'invalid_app_id', + }); + expect(parseGamesActionRequest({ dest_hash: 'aa', app_id: 'ttt' })).toEqual({ + error: 'invalid_command', + }); + }); +}); diff --git a/src/shared/games-types.ts b/src/shared/games-types.ts new file mode 100644 index 000000000..068e262b0 --- /dev/null +++ b/src/shared/games-types.ts @@ -0,0 +1,178 @@ +/** Shared LRGP games types (sidecar HTTP + WS ↔ renderer). */ + +export interface GamesStatusResponse { + available: boolean; + enabled: boolean; + running?: boolean; + reason?: string; + apps?: GamesAppManifest[]; +} + +export interface GamesAppManifest { + app_id: string; + version: number; + display_name: string; + icon?: string; + session_type?: string; + max_players?: number; + validation?: string; + actions?: string[]; +} + +export interface GamesActionRequest { + dest_hash: string; + app_id: string; + command: string; + session_id?: string; + payload?: Record; + delivery_method?: string; +} + +export interface GamesActionResult { + ok: boolean; + session_id?: string; + command?: string; + msg_id?: string | null; + reason?: string; + error?: string; +} + +export interface GamesOkResponse { + ok: boolean; + error?: string; +} + +/** Known LRGP built-in app ids. */ +export type GamesAppId = 'ttt' | 'chess'; + +/** Standard LRGP commands (mirrors `lrgp-rs` `constants.rs`). */ +export const GAMES_CMD = { + CHALLENGE: 'challenge', + ACCEPT: 'accept', + DECLINE: 'decline', + MOVE: 'move', + RESIGN: 'resign', + DRAW_OFFER: 'draw_offer', + DRAW_ACCEPT: 'draw_accept', + DRAW_DECLINE: 'draw_decline', +} as const; + +export type GamesCommand = (typeof GAMES_CMD)[keyof typeof GAMES_CMD]; + +export type GamesSessionStatus = 'pending' | 'active' | 'completed' | 'expired' | 'declined'; + +/** `lrgp-rs` TicTacToeApp session metadata (see `session_to_json` in tictactoe.rs). */ +export interface GamesTttMetadata { + board: string; + turn: string; + first_turn: string; + my_marker: string; + move_count: number; + winner: string; + terminal: string; + draw_offered: boolean; +} + +/** `lrgp-rs` ChessApp session metadata (see `default_metadata` in chess.rs). */ +export interface GamesChessMetadata { + fen: string; + moves: string[]; + my_color: string; + first_turn: string; + turn: string; + move_count: number; + winner: string; + terminal: string; + draw_offered: boolean; + draw_offer_reason?: string; + in_check: boolean; + legal_moves: string[]; +} + +/** LRGP session record (`Session` struct in lrgp-rs `session.rs`), as JSON. */ +export interface GameSession { + session_id: string; + identity_id: string; + app_id: string; + app_version: number; + contact_hash: string; + initiator: string; + /** One of {@link GamesSessionStatus}; kept as `string` since the sidecar may add new values. */ + status: string; + metadata: Record; + unread: number; + created_at: number; + updated_at: number; + last_action_at: number; +} + +export interface GamesListSessionsResponse { + sessions?: GameSession[]; + error?: string; +} + +export interface GamesSessionDetailResponse { + session?: GameSession | null; + error?: string; +} + +/** Payload for the LRGP `games.update` WS event. */ +export interface GamesUpdateEventPayload { + app_id: string; + session_id: string; + direction?: 'inbound' | 'outbound'; + session: GameSession | null; + event?: Record; + error?: Record; +} + +/** Payload for the LRGP `games.action_result` WS event. */ +export interface GamesActionResultEventPayload { + app_id: string; + session_id: string; + ok: boolean; + error?: string; +} + +/** Prefix for all dedicated games IPC (blocked on generic proxy). */ +export const GAMES_API_PREFIX = '/api/v1/games/'; + +export function isGamesApiPath(apiPath: string): boolean { + const q = apiPath.indexOf('?'); + const pathOnly = q >= 0 ? apiPath.slice(0, q) : apiPath; + return pathOnly === '/api/v1/games' || pathOnly.startsWith(GAMES_API_PREFIX); +} + +export function parseGamesActionRequest(opts: unknown): GamesActionRequest | { error: string } { + if (!opts || typeof opts !== 'object' || Array.isArray(opts)) { + return { error: 'invalid_game_action' }; + } + const o = opts as Record; + const destHash = o.dest_hash; + const appId = o.app_id; + const command = o.command; + if (typeof destHash !== 'string' || destHash.length === 0) { + return { error: 'invalid_dest_hash' }; + } + if (typeof appId !== 'string' || appId.length === 0) { + return { error: 'invalid_app_id' }; + } + if (typeof command !== 'string' || command.length === 0) { + return { error: 'invalid_command' }; + } + const out: GamesActionRequest = { + dest_hash: destHash, + app_id: appId, + command, + }; + if (typeof o.session_id === 'string') { + out.session_id = o.session_id; + } + if (o.payload != null && typeof o.payload === 'object' && !Array.isArray(o.payload)) { + out.payload = o.payload as Record; + } + if (typeof o.delivery_method === 'string') { + out.delivery_method = o.delivery_method; + } + return out; +}