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.
The per-screen in-memory caches (lobbycache, gamecache) were refreshed only by
the screen that owns them while it was mounted, so a state change that crossed
screens left the other screen's cache stale and it visibly redrew on the next
navigation:
- game -> lobby: the player's own move advanced the game cache but not the lobby
snapshot, and an own move carries no self-directed push event, so returning to
the lobby painted the pre-move status until the background refresh corrected it.
- lobby -> game (and from any other screen): an opponent's move / game-over
refreshed the lobby (while mounted) but never the per-game cache, so opening
that game flashed the pre-move board.
Make cache freshness cross-screen, owned by the single global stream handler
that runs for every live event regardless of the mounted screen:
- patchLobbyGame upserts the affected game's GameView into the lobby snapshot;
the global handler calls it on opponent_moved / game_over / opponent_joined and
on a match_found / game_started seed (so a game started elsewhere is present
too). The game board still mirrors the player's own move and its own load() —
the two updates no live event carries.
- advanceCached (a pure wrapper over the existing delta reducers) advances a
not-currently-viewed game's cache from opponent_moved / game_over; the game in
view is skipped so its mounted board stays the sole owner (no double apply).
End-state behaviour is unchanged (the background refresh always reconciled);
this removes the transient stale frame. Unit-tested patchLobbyGame and
advanceCached; docs/UI_DESIGN.md updated.
Enrich the in-app live stream into a delta channel so the UI renders a move from the event without a follow-up game.state, and make the matchmaking poll a stream-down fallback.
- pkg/fbs: trailing fields on opponent_moved (move+game+bag_len), your_turn (move_count), match_found (state), game_over (game), notify (account/invitation/state), MoveResult (rack+bag_len); regenerate Go + TS.
- backend: notify owns the FB encoding (encode.go + payload.go input structs); game/lobby/social map their domain types in. emitMove builds the move delta; game.Service.InitialState feeds match_found/game_started the recipient's initial StateView; friends/invitations notify carry their account/invitation. The move-commit response (submit_play/pass/exchange/resign) returns the actor's refilled rack + bag size.
- gateway: MoveResult transcode carries rack+bag_len.
- ui: pure lib/gamedelta.ts reducer advances the per-game cache keyed on move_count (idempotent + gap-safe); app.svelte seeds the cache on match_found/game_started; Game.svelte applies the delta (commit/pass/exchange/resign drop their load()); NewGame polls only while app.streamAlive is false.
- docs: ARCHITECTURE §10, FUNCTIONAL(+ru), backend/gateway/ui READMEs; PRERELEASE R4 marked done + Refinements.