feat(lobby): keep lobby/game caches fresh from any screen + invitation delta channel
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s

Builds on the cross-screen cache work: the global stream handler now keeps both
caches current no matter which screen is mounted, and invitations become a live
delta channel so the lobby's invitations list is fresh from any screen too.

Client (boundary already started):
- advanceCached now also folds opponent_joined into a not-currently-viewed game's
  cache via a new pure reducer applyOpponentJoined (extracted and reused by the
  mounted game board), so opening an open game that filled while you were elsewhere
  is flash-free.
- patchLobbyInvitation upserts a still-pending invitation and removes a terminal
  one (started/declined/cancelled/expired); the global notify handler calls it on
  the invitation / invitation_update sub-kinds.

Invitations delta channel (no wire/gateway/connector change — the notification
already carries the full invitation with id/status/game_id end to end):
- notify: a new in-app-only NotifyInvitationUpdate sub + NotificationInvitationUpdate
  constructor (shares encoding with NotificationInvitation). The Telegram connector
  renders no message for it, so a decline/cancel never becomes an out-of-app push.
- lobby: emit the changed invitation to every participant on respond (accept/decline),
  on the final accept's game start, and on cancel — so each participant's lobby patches
  its list in place. The authoritative list holds only pending invitations, so the
  client's pending-vs-terminal rule matches it exactly.

Tests: applyOpponentJoined + patchLobbyInvitation unit tests (TDD), the
NotificationInvitationUpdate encoding unit test, and integration assertions that
decline/cancel/accept publish invitation_update to every participant. Full local
suite green (backend unit+integration, UI check/unit/build/bundle/e2e). Docs:
ARCHITECTURE §10 (notify catalog + in-app-only note) and UI_DESIGN updated.
This commit is contained in:
Ilia Denisov
2026-06-14 11:22:47 +02:00
parent 5312b11f0e
commit 56dbf86472
13 changed files with 269 additions and 22 deletions
+10 -5
View File
@@ -549,10 +549,11 @@ including the mover**, so the mover's own other devices and their lobby refresh
in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge**
(from the social service), **opponent-joined** (from the matchmaker, §8), and **notify**
(a lightweight "re-poll" signal carrying a sub-kind: friend-request,
friend-added, friend-declined, invitation or game-started; emitted on a friend-request,
friend-added, friend-declined, invitation, invitation-update or game-started; emitted on a friend-request,
on answering one (accept → friend-added, decline → friend-declined — to the original
requester, so a game screen watching that opponent re-derives its "add to friends" state),
and on an invitation create or its game start). **game-over** is emitted to every
and on an invitation create (**invitation**) or any later change to it (**invitation-update**: an
updated response, a decline, a cancel, or its game start — to every participant)). **game-over** is emitted to every
seat from the same game commit when a game finishes — any path: a closing play, all-pass,
resign or timeout — and **your-turn** is enriched so the out-of-app push reads in full: it
also carries the mover's display name, their last action and the main word of a scoring play,
@@ -566,7 +567,10 @@ refetch); **your-turn** carries that move count as a consistency check; the **ga
notify carries the recipient's full **initial `StateView`** so opening a freshly started game is
instant, and **opponent-joined** carries the waiting starter's refreshed `StateView` so the
opponent card and the resign/chat controls update **in place**; **game-over** carries the final summary; the lobby **notify** sub-kinds
carry the changed account / invitation. The move-commit **response** (`submit_play` / `pass` /
carry the changed account / invitation, so the client patches its lobby lists in place: **invitation**
and **invitation-update** carry the full invitation, and the client upserts a still-pending one and
drops a terminal one (started, declined, cancelled, expired) — the invitations list is a delta channel
too, fresh from any screen without a refetch. The move-commit **response** (`submit_play` / `pass` /
`exchange` / `resign`) likewise returns the actor's own refilled rack and bag size, so the mover
renders the next turn without a self-refetch. The `notify` package owns the FlatBuffers encoding
(fed wire-agnostic input structs by the domain services) and the gateway forwards every payload
@@ -589,8 +593,9 @@ not the recipient's latest-login bot. It then asks the **Telegram connector** to
localized message with a Mini App deep-link button — only when the recipient has a Telegram
identity and has not confined notifications to the app, so the two channels never duplicate. The
connector routes by that language to the matching bot and renders the message in it. The out-of-app set is
your-turn, game-over, nudge and the invitation / friend-request notify sub-kinds;
the connector renders the message and skips the rest. Operator broadcasts
your-turn, game-over, nudge and the **invitation** (a new invitation) / friend-request notify sub-kinds;
the connector renders the message and skips the rest — so the in-app-only **invitation-update** (a
response/withdrawal lobby sync) never becomes a platform push. Operator broadcasts
(`SendToUser` / `SendToGameChannel`, §10 admin) instead pick the bot by an
**operator-chosen** language in the console, unrelated to the recipient's login. Session-revocation events and
cursor-based stream resume stay deferred (single-instance MVP).
+4 -2
View File
@@ -53,8 +53,10 @@ Login uses `Screen`.
stream handler (`lib/app.svelte.ts`) is the one place that runs for every live event regardless of
the mounted screen, so it owns cross-screen freshness: it upserts the affected game's `GameView`
into the lobby snapshot (`patchLobbyGame`) on every game event — opponent move, game-over,
opponent-joined, and a match/started game (which it also adds) — and advances a not-currently-viewed
game's own cache from the move/over delta (`advanceCached`, skipping the game in view, whose mounted
opponent-joined, and a match/started game (which it also adds) — patches the lobby's invitations
list (`patchLobbyInvitation`: a still-pending invitation upserted, a terminal one removed) on the
`invitation` / `invitation_update` notifications, and advances a not-currently-viewed game's own
cache from the move/over/joined delta (`advanceCached`, skipping the game in view, whose mounted
board owns its cache). The game board additionally mirrors the player's **own** move and its own
`load()` into the lobby snapshot — the two updates no live event carries.
- **Telegram integration** (`lib/telegram.ts`): inside the Mini App the colour