From 840d33b625ab57fe1d4194f5239bc0b41b2029d9 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Tue, 4 Aug 2026 19:02:28 -0600 Subject: [PATCH 1/2] feat(reticulum): integrate lrgp-rs Games tab for TTT and Chess Wire sibling lrgp-rs into the sidecar with dedicated games IPC, a Reticulum Games panel, and Ratspeak parity tracking so mesh-client peers can play over LRGP. --- AGENTS.md | 5 +- docs/ci-cd.md | 5 +- docs/development-environment.md | 2 +- docs/reticulum-games-parity.md | 50 ++ docs/reticulum-sidecar-ipc.md | 81 +- docs/reticulum.md | 14 +- docs/troubleshooting.md | 9 + reticulum-sidecar/Cargo.lock | 121 +++ reticulum-sidecar/Cargo.toml | 8 +- reticulum-sidecar/README.md | 9 +- reticulum-sidecar/src/api/games.rs | 110 +++ reticulum-sidecar/src/api/mod.rs | 18 +- reticulum-sidecar/src/api/system.rs | 4 - reticulum-sidecar/src/stack/games_session.rs | 743 ++++++++++++++++++ reticulum-sidecar/src/stack/live.rs | 239 ++++++ reticulum-sidecar/src/stack/mod.rs | 82 +- scripts/clone-ratspeak-stack.sh | 15 +- scripts/clone-ratspeak-stack.test.mjs | 13 + scripts/i18n-unused-keys.mjs | 4 + scripts/update.sh | 25 +- scripts/update.test.mjs | 12 +- src/main/index.contract.test.ts | 2 + src/main/ipc/reticulum-handlers.ts | 132 ++++ ...eticulum-proxy-rate-limit.contract.test.ts | 15 + src/preload/index.ts | 23 + src/renderer/App.tsx | 40 + src/renderer/components/ChatPanel.tsx | 25 +- src/renderer/components/GamesPanel.test.tsx | 143 ++++ src/renderer/components/GamesPanel.tsx | 370 +++++++++ .../components/ReticulumPeerListPanel.tsx | 10 + .../components/games/ChessBoard.test.tsx | 152 ++++ src/renderer/components/games/ChessBoard.tsx | 195 +++++ .../components/games/TicTacToeBoard.test.tsx | 149 ++++ .../components/games/TicTacToeBoard.tsx | 86 ++ .../ReticulumGameChallengeButton.test.tsx | 44 ++ .../ReticulumGameChallengeButton.tsx | 86 ++ src/renderer/lazyTabPanels.ts | 1 + src/renderer/lib/appTabMappings.test.ts | 16 + src/renderer/lib/appTabMappings.ts | 2 + src/renderer/lib/icons/tabIcons.test.tsx | 1 + src/renderer/lib/icons/tabIcons.tsx | 3 + src/renderer/lib/radio/BaseRadioProvider.ts | 5 + .../lib/radio/protocol-capabilities.test.ts | 4 + .../clearReticulumSessionStores.test.ts | 8 + .../reticulum/clearReticulumSessionStores.ts | 2 + .../lib/reticulum/reticulumGamesMetadata.ts | 41 + .../lib/reticulum/reticulumGamesSession.ts | 135 ++++ src/renderer/lib/tabSlotIds.ts | 1 + src/renderer/locales/cs/translation.json | 101 ++- src/renderer/locales/de/translation.json | 101 ++- src/renderer/locales/en/translation.json | 99 +++ src/renderer/locales/es/translation.json | 101 ++- src/renderer/locales/fr/translation.json | 101 ++- src/renderer/locales/id/translation.json | 101 ++- src/renderer/locales/it/translation.json | 101 ++- src/renderer/locales/ja/translation.json | 101 ++- src/renderer/locales/ko/translation.json | 101 ++- src/renderer/locales/nl/translation.json | 101 ++- src/renderer/locales/pl/translation.json | 101 ++- src/renderer/locales/pt-BR/translation.json | 101 ++- src/renderer/locales/ru/translation.json | 101 ++- src/renderer/locales/tr/translation.json | 101 ++- src/renderer/locales/uk/translation.json | 101 ++- src/renderer/locales/zh/translation.json | 101 ++- .../runtime/useReticulumRuntime.games.test.ts | 146 ++++ src/renderer/runtime/useReticulumRuntime.ts | 7 + .../stores/reticulumGamesStore.test.ts | 117 +++ src/renderer/stores/reticulumGamesStore.ts | 138 ++++ src/renderer/vitest.electronApiMock.ts | 10 + src/shared/electron-api.types.ts | 23 + src/shared/games-types.test.ts | 44 ++ src/shared/games-types.ts | 176 +++++ 72 files changed, 5444 insertions(+), 91 deletions(-) create mode 100644 docs/reticulum-games-parity.md create mode 100644 reticulum-sidecar/src/api/games.rs create mode 100644 reticulum-sidecar/src/stack/games_session.rs create mode 100644 src/renderer/components/GamesPanel.test.tsx create mode 100644 src/renderer/components/GamesPanel.tsx create mode 100644 src/renderer/components/games/ChessBoard.test.tsx create mode 100644 src/renderer/components/games/ChessBoard.tsx create mode 100644 src/renderer/components/games/TicTacToeBoard.test.tsx create mode 100644 src/renderer/components/games/TicTacToeBoard.tsx create mode 100644 src/renderer/components/reticulum/ReticulumGameChallengeButton.test.tsx create mode 100644 src/renderer/components/reticulum/ReticulumGameChallengeButton.tsx create mode 100644 src/renderer/lib/reticulum/reticulumGamesMetadata.ts create mode 100644 src/renderer/lib/reticulum/reticulumGamesSession.ts create mode 100644 src/renderer/runtime/useReticulumRuntime.games.test.ts create mode 100644 src/renderer/stores/reticulumGamesStore.test.ts create mode 100644 src/renderer/stores/reticulumGamesStore.ts create mode 100644 src/shared/games-types.test.ts create mode 100644 src/shared/games-types.ts 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..be51efdf0 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -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..fcbc6ab28 --- /dev/null +++ b/reticulum-sidecar/src/api/games.rs @@ -0,0 +1,110 @@ +//! 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; + +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 { + 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 Json(serde_json::json!({ "ok": false, "error": 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 { + 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 { + 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 { + 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..a710ae590 --- /dev/null +++ b/reticulum-sidecar/src/stack/games_session.rs @@ -0,0 +1,743 @@ +//! 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; + +/// 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; + } + }; + + self.persist_session_from_state(&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()); + } + 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()); + } + + fn persist_session_from_state(&self, app_id: &str, session_id: &str) { + let Some(store) = &self.store else { + return; + }; + if session_id.is_empty() { + return; + } + let Some(state) = self.router.with_app(app_id, |app| { + app.get_session_state(session_id, &self.identity_id) + }) else { + return; + }; + if state.is_empty() { + return; + } + if let Err(e) = + save_session_from_state(store, session_id, &self.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..e7013e47e 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,202 @@ 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 dispatched envelope for a session verbatim (same nonce), + /// e.g. after a transient send failure. 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..5f1647981 100644 --- a/scripts/clone-ratspeak-stack.test.mjs +++ b/scripts/clone-ratspeak-stack.test.mjs @@ -142,6 +142,19 @@ 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(" '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..74ed38660 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 { + if (typeof sessionId !== 'string' || sessionId.length === 0 || sessionId.length > 128) { + throw new 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,111 @@ 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 id = assertGamesSessionId(sessionId); + const path = `/api/v1/games/sessions/${encodeURIComponent(id)}`; + 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 id = assertGamesSessionId(sessionId); + const path = `/api/v1/games/sessions/${encodeURIComponent(id)}/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 id = assertGamesSessionId(sessionId); + const path = `/api/v1/games/sessions/${encodeURIComponent(id)}/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 id = assertGamesSessionId(sessionId); + const path = `/api/v1/games/sessions/${encodeURIComponent(id)}`; + 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..3528d4dd2 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,21 @@ 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).toContain("label: 'reticulum:games'"); + expect(HANDLERS_SOURCE).toMatch(/max:\s*600/); + 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*'\/api\/v1\/games/); + expect(preload).not.toMatch(/invoke\('reticulum:proxyPost',\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..c652d167d --- /dev/null +++ b/src/renderer/components/GamesPanel.test.tsx @@ -0,0 +1,143 @@ +import { 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 }); + }); + + 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' })); + + 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' }), + ); + }); + }); +}); diff --git a/src/renderer/components/GamesPanel.tsx b/src/renderer/components/GamesPanel.tsx new file mode 100644 index 000000000..32bf6c99a --- /dev/null +++ b/src/renderer/components/GamesPanel.tsx @@ -0,0 +1,370 @@ +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); + + 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); + }} + /> + )} +
+ ); +} 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..bdc5a0612 --- /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' })); + + 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' }); + 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' })).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..409f1a72b --- /dev/null +++ b/src/renderer/components/games/TicTacToeBoard.tsx @@ -0,0 +1,86 @@ +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}`); + } 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..fd0a4ea11 --- /dev/null +++ b/src/renderer/components/reticulum/ReticulumGameChallengeButton.test.tsx @@ -0,0 +1,44 @@ +// @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(); + }); +}); diff --git a/src/renderer/components/reticulum/ReticulumGameChallengeButton.tsx b/src/renderer/components/reticulum/ReticulumGameChallengeButton.tsx new file mode 100644 index 000000000..c88651ff6 --- /dev/null +++ b/src/renderer/components/reticulum/ReticulumGameChallengeButton.tsx @@ -0,0 +1,86 @@ +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) { + setMenuOpen(false); + const ok = await sendGamesChallenge(lxmfPeerHash, appId); + if (ok) { + pushAppToast( + t('gamesPanel.challengeSent', { app: t(`gamesPanel.apps.${appId}`) }), + 'success', + ); + } + } + + return ( +
+ + {menuOpen && ( +
+ {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..357b2c8c5 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,11 @@ describe('clearReticulumSessionStores', () => { expect(hangup).not.toHaveBeenCalled(); expect(stopMedia).toHaveBeenCalled(); }); + + it('clears the games store', () => { + useReticulumGamesStore.getState().selectSession('s1'); + 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.ts b/src/renderer/lib/reticulum/reticulumGamesSession.ts new file mode 100644 index 000000000..42a487992 --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumGamesSession.ts @@ -0,0 +1,135 @@ +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 { + await window.electronAPI.reticulum.games.markRead(sessionId); + const state = useReticulumGamesStore.getState(); + const session = state.sessions.find((row) => row.session_id === sessionId); + if (session && 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..d5f271250 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,103 @@ }, "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", + "cellAria": "Buňka {{index}}" + }, + "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." + } } } diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 61f5d860f..20e92aef2 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,103 @@ "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", + "cellAria": "Zelle {{index}}" + }, + "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." + } } } diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index c3b75d81a..d3027ceb9 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -1865,6 +1865,104 @@ "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.", + "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", + "cellAria": "Cell {{index}}" + }, + "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 +4848,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..da5d4a3b6 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,103 @@ }, "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", + "cellAria": "Celda {{index}}" + }, + "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." + } } } diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 546e67589..f9eadd557 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,103 @@ }, "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", + "cellAria": "Cellulaire {{index}}" + }, + "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." + } } } diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 0f07f04ee..5f1f95142 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,103 @@ }, "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", + "cellAria": "Sel {{index}}" + }, + "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." + } } } diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index ee34027dd..4d4769a39 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,103 @@ }, "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", + "cellAria": "Cella {{index}}" + }, + "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" + } } } diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 311c1b8cf..bcf5aef50 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,103 @@ }, "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ボード", + "cellAria": "Cell {{index}}" + }, + "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": "セッションを削除できませんでした。" + } } } diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 75cf35728..906705707 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,103 @@ }, "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 보드", + "cellAria": "셀 {{index}}" + }, + "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": "세션을 삭제하지 못했습니다." + } } } diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 42505bca4..e7a25d22b 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,103 @@ "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", + "cellAria": "Cel {{index}}" + }, + "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." + } } } diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 9f3776557..115e3b30e 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,103 @@ }, "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", + "cellAria": "Komórka {{index}}" + }, + "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." + } } } diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index cd987a65a..bd4943222 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,103 @@ }, "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", + "cellAria": "Célula {{index}}" + }, + "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." + } } } diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 599625543..595dcc9b7 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,103 @@ }, "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": "Доска крестики-нолики", + "cellAria": "Ячейка {{index}}" + }, + "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": "Не удалось удалить сеанс." + } } } diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 74b844289..4f1c14e10 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,103 @@ }, "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ı", + "cellAria": "Cell {{index}}" + }, + "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." + } } } diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 75c5f9bd8..63bb41b2e 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,103 @@ }, "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", + "cellAria": "Комірка {{index}}" + }, + "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": "Не вдалося видалити сеанс." + } } } diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 2ea2c89fc..402830651 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,103 @@ }, "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板", + "cellAria": "Cell {{index}}" + }, + "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": "删除会话失败。" + } } } 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..6a134a3ad --- /dev/null +++ b/src/renderer/stores/reticulumGamesStore.test.ts @@ -0,0 +1,117 @@ +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]); + expect(useReticulumGamesStore.getState().sessions).toHaveLength(1); + expect(useReticulumGamesStore.getState().sessions[0].session_id).toBe('s1'); + }); + + 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..7f63f913f --- /dev/null +++ b/src/renderer/stores/reticulumGamesStore.ts @@ -0,0 +1,138 @@ +import { create } from 'zustand'; + +import type { + GamesActionResultEventPayload, + GamesAppManifest, + GameSession, + GamesStatusResponse, + GamesUpdateEventPayload, +} from '@/shared/games-types'; + +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; +} + +function asGameSession(value: unknown): GameSession | null { + return isGameSession(value) ? value : null; +} + +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 (!payload || typeof payload !== 'object') return; + const p = payload as GamesUpdateEventPayload; + const session = asGameSession(p.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( + (a): a is GamesAppManifest => + !!a && typeof a === 'object' && typeof (a as GamesAppManifest).app_id === 'string', + ) + : []; + 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..44bd911f9 --- /dev/null +++ b/src/shared/games-types.test.ts @@ -0,0 +1,44 @@ +// @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/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..741434a2e --- /dev/null +++ b/src/shared/games-types.ts @@ -0,0 +1,176 @@ +/** 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 { + return apiPath === '/api/v1/games' || apiPath.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; +} From 526744568d2248d06d2f2ed6d0c3932d6db077a5 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Tue, 4 Aug 2026 19:30:19 -0600 Subject: [PATCH 2/2] fix(reticulum): harden Games review findings for IPC, persist, and UI Defer inbound LRGP SQLite persist off the LXMF router lock, tighten games input validation and store contracts, and close Games UI races around mark-read, delete confirm, and disabled challenges. --- docs/ci-cd.md | 2 +- reticulum-sidecar/src/api/games.rs | 33 ++++++- reticulum-sidecar/src/stack/games_session.rs | 89 +++++++++++++++---- reticulum-sidecar/src/stack/live.rs | 6 +- scripts/clone-ratspeak-stack.test.mjs | 4 +- src/main/ipc/reticulum-handlers.ts | 32 ++++--- ...eticulum-proxy-rate-limit.contract.test.ts | 9 +- src/renderer/components/GamesPanel.test.tsx | 86 +++++++++++++++++- src/renderer/components/GamesPanel.tsx | 32 ++++++- .../components/games/ChessBoard.test.tsx | 58 ++++++++++++ src/renderer/components/games/ChessBoard.tsx | 6 +- .../components/games/TicTacToeBoard.test.tsx | 6 +- .../components/games/TicTacToeBoard.tsx | 10 ++- .../ReticulumGameChallengeButton.test.tsx | 11 +++ .../ReticulumGameChallengeButton.tsx | 19 ++-- .../clearReticulumSessionStores.test.ts | 15 ++++ .../reticulum/reticulumGamesSession.test.ts | 77 ++++++++++++++++ .../lib/reticulum/reticulumGamesSession.ts | 7 +- src/renderer/locales/cs/translation.json | 7 +- src/renderer/locales/de/translation.json | 7 +- src/renderer/locales/en/translation.json | 5 +- src/renderer/locales/es/translation.json | 7 +- src/renderer/locales/fr/translation.json | 7 +- src/renderer/locales/id/translation.json | 7 +- src/renderer/locales/it/translation.json | 7 +- src/renderer/locales/ja/translation.json | 7 +- src/renderer/locales/ko/translation.json | 7 +- src/renderer/locales/nl/translation.json | 7 +- src/renderer/locales/pl/translation.json | 7 +- src/renderer/locales/pt-BR/translation.json | 7 +- src/renderer/locales/ru/translation.json | 7 +- src/renderer/locales/tr/translation.json | 7 +- src/renderer/locales/uk/translation.json | 7 +- src/renderer/locales/zh/translation.json | 7 +- .../stores/reticulumGamesStore.test.ts | 35 +++++++- src/renderer/stores/reticulumGamesStore.ts | 51 ++++++++--- src/shared/games-types.test.ts | 1 + src/shared/games-types.ts | 4 +- 38 files changed, 603 insertions(+), 100 deletions(-) create mode 100644 src/renderer/lib/reticulum/reticulumGamesSession.test.ts diff --git a/docs/ci-cd.md b/docs/ci-cd.md index be51efdf0..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). diff --git a/reticulum-sidecar/src/api/games.rs b/reticulum-sidecar/src/api/games.rs index fcbc6ab28..31422e5bf 100644 --- a/reticulum-sidecar/src/api/games.rs +++ b/reticulum-sidecar/src/api/games.rs @@ -9,6 +9,14 @@ 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) } @@ -34,6 +42,9 @@ 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) } @@ -57,7 +68,18 @@ pub async fn games_action( Json(body): Json, ) -> Json { if let Some(err) = reject_oversize("dest_hash", &body.dest_hash, MAX_DEST_HASH_CHARS) { - return Json(serde_json::json!({ "ok": false, "error": err })); + 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", @@ -83,6 +105,9 @@ 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 })), @@ -93,6 +118,9 @@ 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 })), @@ -103,6 +131,9 @@ 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/stack/games_session.rs b/reticulum-sidecar/src/stack/games_session.rs index a710ae590..a87ee375f 100644 --- a/reticulum-sidecar/src/stack/games_session.rs +++ b/reticulum-sidecar/src/stack/games_session.rs @@ -24,6 +24,9 @@ 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. @@ -238,7 +241,9 @@ impl GamesSessionManager { } }; - self.persist_session_from_state(&app_id, &session_id); + // 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, @@ -438,6 +443,17 @@ impl GamesSessionManager { 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"); @@ -488,29 +504,64 @@ impl GamesSessionManager { let _ = self.event_tx.send(frame.to_string()); } - fn persist_session_from_state(&self, app_id: &str, session_id: &str) { - let Some(store) = &self.store else { + /// 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, }; - if session_id.is_empty() { - 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(); + } } - let Some(state) = self.router.with_app(app_id, |app| { - app.get_session_state(session_id, &self.identity_id) - }) else { + } + + fn persist_session_from_state(&self, app_id: &str, session_id: &str) { + let Some(store) = &self.store else { return; }; - if state.is_empty() { - return; - } - if let Err(e) = - save_session_from_state(store, session_id, &self.identity_id, app_id, &state) - { - tracing::warn!( - target: "games", - "failed to persist lrgp session {session_id} ({app_id}): {e}" - ); - } + 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}" + ); } } diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index e7013e47e..66f48e27c 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -3125,8 +3125,10 @@ impl LiveBridge { })) } - /// Resend the last dispatched envelope for a session verbatim (same nonce), - /// e.g. after a transient send failure. Does not re-dispatch game logic. + /// 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, diff --git a/scripts/clone-ratspeak-stack.test.mjs b/scripts/clone-ratspeak-stack.test.mjs index 5f1647981..2c3a6584e 100644 --- a/scripts/clone-ratspeak-stack.test.mjs +++ b/scripts/clone-ratspeak-stack.test.mjs @@ -146,7 +146,9 @@ describe('clone-ratspeak-stack.sh float policy', () => { 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(" 'lrgp-rs'"); + 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'); diff --git a/src/main/ipc/reticulum-handlers.ts b/src/main/ipc/reticulum-handlers.ts index 74ed38660..2bb2c115f 100644 --- a/src/main/ipc/reticulum-handlers.ts +++ b/src/main/ipc/reticulum-handlers.ts @@ -69,9 +69,9 @@ function isVoiceAudioApiPath(apiPath: string): boolean { return apiPath === VOICE_AUDIO_API_PATH; } -function assertGamesSessionId(sessionId: unknown): string { +function assertGamesSessionId(sessionId: unknown): string | { error: string } { if (typeof sessionId !== 'string' || sessionId.length === 0 || sessionId.length > 128) { - throw new Error('invalid_session_id'); + return { error: 'invalid_session_id' }; } return sessionId; } @@ -358,8 +358,11 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { ipcMain.handle('reticulum:gamesSessionDetail', async (event, sessionId: unknown) => { assertIpcSender(event, 'reticulum:gamesSessionDetail'); reticulumGamesIpcRateLimit.checkOrThrow(); - const id = assertGamesSessionId(sessionId); - const path = `/api/v1/games/sessions/${encodeURIComponent(id)}`; + 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) { @@ -386,8 +389,11 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { ipcMain.handle('reticulum:gamesResend', async (event, sessionId: unknown) => { assertIpcSender(event, 'reticulum:gamesResend'); reticulumGamesIpcRateLimit.checkOrThrow(); - const id = assertGamesSessionId(sessionId); - const path = `/api/v1/games/sessions/${encodeURIComponent(id)}/resend`; + 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) { @@ -399,8 +405,11 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { ipcMain.handle('reticulum:gamesMarkRead', async (event, sessionId: unknown) => { assertIpcSender(event, 'reticulum:gamesMarkRead'); reticulumGamesIpcRateLimit.checkOrThrow(); - const id = assertGamesSessionId(sessionId); - const path = `/api/v1/games/sessions/${encodeURIComponent(id)}/read`; + 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) { @@ -412,8 +421,11 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { ipcMain.handle('reticulum:gamesDeleteSession', async (event, sessionId: unknown) => { assertIpcSender(event, 'reticulum:gamesDeleteSession'); reticulumGamesIpcRateLimit.checkOrThrow(); - const id = assertGamesSessionId(sessionId); - const path = `/api/v1/games/sessions/${encodeURIComponent(id)}`; + 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) { 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 3528d4dd2..ab1c4f598 100644 --- a/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts +++ b/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts @@ -54,8 +54,9 @@ describe('reticulum proxy rate limit + 100k peer ceilings (source contract)', () }); it('routes LRGP games through dedicated IPC with its own rate limit', () => { - expect(HANDLERS_SOURCE).toContain("label: 'reticulum:games'"); - expect(HANDLERS_SOURCE).toMatch(/max:\s*600/); + 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'"); @@ -64,8 +65,8 @@ describe('reticulum proxy rate limit + 100k peer ceilings (source contract)', () 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*'\/api\/v1\/games/); - expect(preload).not.toMatch(/invoke\('reticulum:proxyPost',\s*'\/api\/v1\/games/); + 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', () => { diff --git a/src/renderer/components/GamesPanel.test.tsx b/src/renderer/components/GamesPanel.test.tsx index c652d167d..723d7523f 100644 --- a/src/renderer/components/GamesPanel.test.tsx +++ b/src/renderer/components/GamesPanel.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; +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'; @@ -65,6 +65,10 @@ describe('GamesPanel', () => { 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 () => { @@ -99,7 +103,7 @@ describe('GamesPanel', () => { 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' })); + await userEvent.click(screen.getByRole('button', { name: 'Cell 1, empty' })); await waitFor(() => { expect(window.electronAPI.reticulum.games.sendAction).toHaveBeenCalledWith( @@ -140,4 +144,82 @@ describe('GamesPanel', () => { ); }); }); + + 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 index 32bf6c99a..e243e0e7c 100644 --- a/src/renderer/components/GamesPanel.tsx +++ b/src/renderer/components/GamesPanel.tsx @@ -54,6 +54,7 @@ export default function GamesPanel({ isActive }: GamesPanelProps) { const [challengeHash, setChallengeHash] = useState(''); const [challengeApp, setChallengeApp] = useState('ttt'); const [confirmResign, setConfirmResign] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); useEffect(() => { if (!isActive) return; @@ -149,7 +150,9 @@ export default function GamesPanel({ isActive }: GamesPanelProps) { selectedSessionId === session.session_id ? 'bg-amber-950/60' : '' }`} aria-label={t('gamesPanel.sessionRowAria', { - app: t(`gamesPanel.apps.${session.app_id}`), + app: t(`gamesPanel.apps.${session.app_id}`, { + defaultValue: session.app_id, + }), peer: sessionPeerLabel(session), })} onClick={() => { @@ -158,12 +161,16 @@ export default function GamesPanel({ isActive }: GamesPanelProps) { > - {t(`gamesPanel.apps.${session.app_id}`)} + {t(`gamesPanel.apps.${session.app_id}`, { + defaultValue: session.app_id, + })} {sessionPeerLabel(session)} - {t(`gamesPanel.status.${session.status}`)} + {t(`gamesPanel.status.${session.status}`, { + defaultValue: session.status, + })} {session.unread > 0 && ( void deleteGamesSession(selectedSession.session_id)} + onClick={() => { + setConfirmDelete(true); + }} > {t('gamesPanel.deleteSession')} @@ -365,6 +374,21 @@ export default function GamesPanel({ isActive }: GamesPanelProps) { }} /> )} + {confirmDelete && selectedSession && ( + { + setConfirmDelete(false); + }} + onConfirm={() => { + setConfirmDelete(false); + void deleteGamesSession(selectedSession.session_id); + }} + /> + )} ); } diff --git a/src/renderer/components/games/ChessBoard.test.tsx b/src/renderer/components/games/ChessBoard.test.tsx index 583856191..067e74fdc 100644 --- a/src/renderer/components/games/ChessBoard.test.tsx +++ b/src/renderer/components/games/ChessBoard.test.tsx @@ -149,4 +149,62 @@ describe('ChessBoard', () => { expect(screen.getByText('Your turn — you are in check')).toBeInTheDocument(); }); + + it('maps clicks to e7e5 on a flipped black board', async () => { + const onMove = vi.fn(); + render( + , + ); + + await userEvent.click(screen.getByRole('button', { name: /^e7,/ })); + await userEvent.click(screen.getByRole('button', { name: /^e5,/ })); + + expect(onMove).toHaveBeenCalledWith('e7e5'); + }); + + it('appends q for pawn promotion moves', async () => { + const onMove = vi.fn(); + render( + , + ); + + await userEvent.click(screen.getByRole('button', { name: /^e7,/ })); + await userEvent.click(screen.getByRole('button', { name: /^e8,/ })); + + expect(onMove).toHaveBeenCalledWith('e7e8q'); + }); }); diff --git a/src/renderer/components/games/ChessBoard.tsx b/src/renderer/components/games/ChessBoard.tsx index 73d40afdb..a864af4cc 100644 --- a/src/renderer/components/games/ChessBoard.tsx +++ b/src/renderer/components/games/ChessBoard.tsx @@ -89,7 +89,9 @@ export function ChessBoard({ session, onMove, disabled = false }: ChessBoardProp } else if (terminal === 'draw') { statusText = t('gamesPanel.chess.draw'); } else if (!isActive) { - statusText = t(`gamesPanel.status.${session.status}`); + statusText = t(`gamesPanel.status.${session.status}`, { + defaultValue: session.status, + }); } else if (isMyTurn) { statusText = inCheck ? t('gamesPanel.chess.yourTurnInCheck') : t('gamesPanel.chess.yourTurn'); } else { @@ -147,7 +149,7 @@ export function ChessBoard({ session, onMove, disabled = false }: ChessBoardProp aria-label={t('gamesPanel.chess.squareAria', { square, piece: piece - ? t(`gamesPanel.chess.pieceNames.${piece}`) + ? t(`gamesPanel.chess.pieceNames.${piece}`, { defaultValue: piece }) : t('gamesPanel.chess.emptySquare'), })} disabled={disabled || !isMyTurn} diff --git a/src/renderer/components/games/TicTacToeBoard.test.tsx b/src/renderer/components/games/TicTacToeBoard.test.tsx index bdc5a0612..60c35e27e 100644 --- a/src/renderer/components/games/TicTacToeBoard.test.tsx +++ b/src/renderer/components/games/TicTacToeBoard.test.tsx @@ -48,7 +48,7 @@ describe('TicTacToeBoard', () => { const onMove = vi.fn(); render(); - await userEvent.click(screen.getByRole('button', { name: 'Cell 5' })); + await userEvent.click(screen.getByRole('button', { name: 'Cell 5, empty' })); expect(onMove).toHaveBeenCalledWith(4); }); @@ -73,7 +73,7 @@ describe('TicTacToeBoard', () => { />, ); - const cell1 = screen.getByRole('button', { name: 'Cell 1' }); + const cell1 = screen.getByRole('button', { name: 'Cell 1, X' }); expect(cell1).toBeDisabled(); await userEvent.click(cell1); expect(onMove).not.toHaveBeenCalled(); @@ -100,7 +100,7 @@ describe('TicTacToeBoard', () => { ); expect(screen.getByText("Opponent's turn")).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Cell 2' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Cell 2, empty' })).toBeDisabled(); }); it('shows the win message and disables the board on terminal win', () => { diff --git a/src/renderer/components/games/TicTacToeBoard.tsx b/src/renderer/components/games/TicTacToeBoard.tsx index 409f1a72b..174ae08db 100644 --- a/src/renderer/components/games/TicTacToeBoard.tsx +++ b/src/renderer/components/games/TicTacToeBoard.tsx @@ -38,7 +38,9 @@ export function TicTacToeBoard({ session, onMove, disabled = false }: TicTacToeB } else if (terminal === 'draw') { statusText = t('gamesPanel.ttt.draw'); } else if (!isActive) { - statusText = t(`gamesPanel.status.${session.status}`); + statusText = t(`gamesPanel.status.${session.status}`, { + defaultValue: session.status, + }); } else if (isMyTurn) { statusText = t('gamesPanel.ttt.yourTurn'); } else { @@ -67,7 +69,11 @@ export function TicTacToeBoard({ session, onMove, disabled = false }: TicTacToeB key={index} type="button" className="flex h-14 w-14 items-center justify-center rounded border border-amber-800/50 bg-amber-950/40 text-2xl font-bold text-amber-100 enabled:hover:bg-amber-900/60 disabled:cursor-default disabled:opacity-70" - aria-label={t('gamesPanel.ttt.cellAria', { index: index + 1 })} + aria-label={ + isEmpty + ? t('gamesPanel.ttt.cellEmptyAria', { index: index + 1 }) + : t('gamesPanel.ttt.cellOccupiedAria', { index: index + 1, marker: cell }) + } disabled={cellDisabled} onClick={() => { onMove(index); diff --git a/src/renderer/components/reticulum/ReticulumGameChallengeButton.test.tsx b/src/renderer/components/reticulum/ReticulumGameChallengeButton.test.tsx index fd0a4ea11..5272283f9 100644 --- a/src/renderer/components/reticulum/ReticulumGameChallengeButton.test.tsx +++ b/src/renderer/components/reticulum/ReticulumGameChallengeButton.test.tsx @@ -41,4 +41,15 @@ describe('ReticulumGameChallengeButton', () => { 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 index c88651ff6..965697a30 100644 --- a/src/renderer/components/reticulum/ReticulumGameChallengeButton.tsx +++ b/src/renderer/components/reticulum/ReticulumGameChallengeButton.tsx @@ -37,16 +37,21 @@ export function ReticulumGameChallengeButton({ }, [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}`) }), + t('gamesPanel.challengeSent', { + app: t(`gamesPanel.apps.${appId}`, { defaultValue: appId }), + }), 'success', ); } } + const showMenu = menuOpen && !disabled; + return (
- {menuOpen && ( + {showMenu && (
{CHALLENGE_APPS.map((appId) => ( ))}
diff --git a/src/renderer/lib/reticulum/clearReticulumSessionStores.test.ts b/src/renderer/lib/reticulum/clearReticulumSessionStores.test.ts index 357b2c8c5..a7017a3e2 100644 --- a/src/renderer/lib/reticulum/clearReticulumSessionStores.test.ts +++ b/src/renderer/lib/reticulum/clearReticulumSessionStores.test.ts @@ -45,7 +45,22 @@ describe('clearReticulumSessionStores', () => { }); 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/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 index 42a487992..67bcce1a7 100644 --- a/src/renderer/lib/reticulum/reticulumGamesSession.ts +++ b/src/renderer/lib/reticulum/reticulumGamesSession.ts @@ -113,10 +113,15 @@ export async function resendGamesAction(sessionId: string): Promise { 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); - if (session && session.unread !== 0) { + // 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) { diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index d5f271250..2570824b6 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -5215,7 +5215,8 @@ "yourMarker": "Jsi {{marker}}", "moveCount": "{{count}} se pohybuje", "boardAria": "Tic-Tac-Toe deska", - "cellAria": "Buňka {{index}}" + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" }, "chess": { "youWon": "Vyhráli jste!", @@ -5249,6 +5250,8 @@ "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 20e92aef2..ad98c6eaa 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -5213,7 +5213,8 @@ "yourMarker": "Du bist {{marker}}", "moveCount": "{{count}} Züge", "boardAria": "Tic-Tac-Toe-Brett", - "cellAria": "Zelle {{index}}" + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" }, "chess": { "youWon": "Sie haben gewonnen!", @@ -5247,6 +5248,8 @@ "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 d3027ceb9..e1729dbd0 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -1913,6 +1913,8 @@ "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", @@ -1927,7 +1929,8 @@ "yourMarker": "You are {{marker}}", "moveCount": "{{count}} moves", "boardAria": "Tic-Tac-Toe board", - "cellAria": "Cell {{index}}" + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" }, "chess": { "youWon": "You won!", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index da5d4a3b6..d0465b9a2 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -5213,7 +5213,8 @@ "yourMarker": "Eres {{marker}}", "moveCount": "{{count}} SE mueve", "boardAria": "1 tablero de Tres en raya", - "cellAria": "Celda {{index}}" + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" }, "chess": { "youWon": "¡Has ganado!", @@ -5247,6 +5248,8 @@ "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 f9eadd557..98b265c4f 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -5213,7 +5213,8 @@ "yourMarker": "Vous êtes {{marker}}", "moveCount": "{{count}} se déplace", "boardAria": "Carte Tic-Tac-Toe", - "cellAria": "Cellulaire {{index}}" + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" }, "chess": { "youWon": "Vous avez gagné !", @@ -5247,6 +5248,8 @@ "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 5f1f95142..4a1716244 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -5213,7 +5213,8 @@ "yourMarker": "Anda {{marker}}", "moveCount": "{{count}} bergerak", "boardAria": "Papan Tic - Tac - Toe", - "cellAria": "Sel {{index}}" + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" }, "chess": { "youWon": "Kamu menang!", @@ -5247,6 +5248,8 @@ "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 4d4769a39..8e3ce1a7c 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -5213,7 +5213,8 @@ "yourMarker": "Lei è {{marker}}", "moveCount": "{{count}} mosse", "boardAria": "Tavola Tic-Tac-Toe", - "cellAria": "Cella {{index}}" + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" }, "chess": { "youWon": "Hai vinto!", @@ -5247,6 +5248,8 @@ "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 bcf5aef50..2e215b06c 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -5213,7 +5213,8 @@ "yourMarker": "あなたは{{marker}}です", "moveCount": "{{count}}ムーブ", "boardAria": "Tic - Tac - Toeボード", - "cellAria": "Cell {{index}}" + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" }, "chess": { "youWon": "勝ったぁ!", @@ -5247,6 +5248,8 @@ "unknownReason": "不明な理由", "resendFailed": "再送に失敗しました。", "deleteFailed": "セッションを削除できませんでした。" - } + }, + "deleteConfirmTitle": "セッションを削除", + "deleteConfirmMessage": "このゲームセッションをリストから削除しますか?これは元に戻せません。" } } diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 906705707..1337af36d 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -5213,7 +5213,8 @@ "yourMarker": "당신은 {{marker}} 입니다", "moveCount": "{{count}} 이동", "boardAria": "Tic-Tac-Toe 보드", - "cellAria": "셀 {{index}}" + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" }, "chess": { "youWon": "당신의 승리입니다!", @@ -5247,6 +5248,8 @@ "unknownReason": "알 수 없는 이유", "resendFailed": "재전송에 실패했습니다.", "deleteFailed": "세션을 삭제하지 못했습니다." - } + }, + "deleteConfirmTitle": "세션 삭제", + "deleteConfirmMessage": "목록에서 이 게임 세션을 제거하시겠습니까? 이 작업은 취소할 수 없습니다." } } diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index e7a25d22b..211c8fe9a 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -5213,7 +5213,8 @@ "yourMarker": "Jij bent {{marker}}", "moveCount": "{{count}} verhuist", "boardAria": "Tic-Tac-Toe-bord", - "cellAria": "Cel {{index}}" + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" }, "chess": { "youWon": "Je hebt gewonnen!", @@ -5247,6 +5248,8 @@ "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 115e3b30e..784a5224a 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -5217,7 +5217,8 @@ "yourMarker": "Jesteś {{marker}}", "moveCount": "{{count}} ruchy", "boardAria": "Deska Tic-Tac-Toe", - "cellAria": "Komórka {{index}}" + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" }, "chess": { "youWon": "Zwycięstwo!", @@ -5251,6 +5252,8 @@ "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 bd4943222..54f4174ec 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -5213,7 +5213,8 @@ "yourMarker": "Você é {{marker}}", "moveCount": "{{count}} movimentos", "boardAria": "Placa Tic-Tac-Toe", - "cellAria": "Célula {{index}}" + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" }, "chess": { "youWon": "Ganhaste!", @@ -5247,6 +5248,8 @@ "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 595dcc9b7..8c95fb21a 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -5215,7 +5215,8 @@ "yourMarker": "Вы {{marker}}", "moveCount": "{{count}} ходов", "boardAria": "Доска крестики-нолики", - "cellAria": "Ячейка {{index}}" + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" }, "chess": { "youWon": "Вы победили!", @@ -5249,6 +5250,8 @@ "unknownReason": "неизвесная причина", "resendFailed": "Повторная отправка не удалась.", "deleteFailed": "Не удалось удалить сеанс." - } + }, + "deleteConfirmTitle": "Удалить сессию", + "deleteConfirmMessage": "Удалить эту игровую сессию из списка? Это действие нельзя отменить." } } diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 4f1c14e10..1bb764252 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -5213,7 +5213,8 @@ "yourMarker": "Siz {{marker}}", "moveCount": "{{count}} hamle", "boardAria": "Tic - Tac - Toe tahtası", - "cellAria": "Cell {{index}}" + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" }, "chess": { "youWon": "Sen kazandın!", @@ -5247,6 +5248,8 @@ "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 63bb41b2e..a439698fd 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -5215,7 +5215,8 @@ "yourMarker": "Вас звати {{marker}}", "moveCount": "{{count}} ходів", "boardAria": "Дошка Tic-Tac-Toe", - "cellAria": "Комірка {{index}}" + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" }, "chess": { "youWon": "Ви виграли!", @@ -5249,6 +5250,8 @@ "unknownReason": "Невідома причина", "resendFailed": "Помилка повторного надсилання.", "deleteFailed": "Не вдалося видалити сеанс." - } + }, + "deleteConfirmTitle": "Вилучити сеанс...", + "deleteConfirmMessage": "Видалити цю ігрову сесію зі списку? Цю дію неможливо скасувати." } } diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 402830651..d009a013b 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -5213,7 +5213,8 @@ "yourMarker": "您是{{marker}}", "moveCount": "{{count}}步", "boardAria": "Tic-Tac-Toe板", - "cellAria": "Cell {{index}}" + "cellEmptyAria": "Cell {{index}}, empty", + "cellOccupiedAria": "Cell {{index}}, {{marker}}" }, "chess": { "youWon": "您赢了 !", @@ -5247,6 +5248,8 @@ "unknownReason": "未知原因", "resendFailed": "重新发送失败", "deleteFailed": "删除会话失败。" - } + }, + "deleteConfirmTitle": "删除会话?", + "deleteConfirmMessage": "从您的列表中删除此游戏会话?此操作无法撤消。" } } diff --git a/src/renderer/stores/reticulumGamesStore.test.ts b/src/renderer/stores/reticulumGamesStore.test.ts index 6a134a3ad..a91261b75 100644 --- a/src/renderer/stores/reticulumGamesStore.test.ts +++ b/src/renderer/stores/reticulumGamesStore.test.ts @@ -26,11 +26,44 @@ describe('reticulumGamesStore', () => { }); it('setSessions replaces the list and filters invalid rows', () => { - useReticulumGamesStore.getState().setSessions([makeSession(), { not: 'a session' }, null]); + 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()); diff --git a/src/renderer/stores/reticulumGamesStore.ts b/src/renderer/stores/reticulumGamesStore.ts index 7f63f913f..1f5dc6b0f 100644 --- a/src/renderer/stores/reticulumGamesStore.ts +++ b/src/renderer/stores/reticulumGamesStore.ts @@ -8,16 +8,53 @@ import type { 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; + 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; @@ -78,9 +115,8 @@ export const useReticulumGamesStore = create((set) => }, applyGamesUpdate: (payload) => { - if (!payload || typeof payload !== 'object') return; - const p = payload as GamesUpdateEventPayload; - const session = asGameSession(p.session); + 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); @@ -101,12 +137,7 @@ export const useReticulumGamesStore = create((set) => }, setApps: (apps) => { - const list = Array.isArray(apps) - ? apps.filter( - (a): a is GamesAppManifest => - !!a && typeof a === 'object' && typeof (a as GamesAppManifest).app_id === 'string', - ) - : []; + const list = Array.isArray(apps) ? apps.filter(isGamesAppManifest) : []; set({ apps: list }); }, diff --git a/src/shared/games-types.test.ts b/src/shared/games-types.test.ts index 44bd911f9..53bffd5a3 100644 --- a/src/shared/games-types.test.ts +++ b/src/shared/games-types.test.ts @@ -8,6 +8,7 @@ describe('games-types', () => { 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); }); diff --git a/src/shared/games-types.ts b/src/shared/games-types.ts index 741434a2e..068e262b0 100644 --- a/src/shared/games-types.ts +++ b/src/shared/games-types.ts @@ -138,7 +138,9 @@ export interface GamesActionResultEventPayload { export const GAMES_API_PREFIX = '/api/v1/games/'; export function isGamesApiPath(apiPath: string): boolean { - return apiPath === '/api/v1/games' || apiPath.startsWith(GAMES_API_PREFIX); + 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 } {