From 56dbf86472cfb6ecf28228e5eae3fe3d903d0129 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 14 Jun 2026 11:22:47 +0200 Subject: [PATCH 1/5] feat(lobby): keep lobby/game caches fresh from any screen + invitation delta channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/internal/inttest/lobby_test.go | 29 ++++++++++++ backend/internal/lobby/invitations.go | 39 +++++++++++++++- backend/internal/notify/events.go | 15 +++++- backend/internal/notify/notify.go | 8 +++- backend/internal/notify/notify_test.go | 17 +++++++ docs/ARCHITECTURE.md | 15 ++++-- docs/UI_DESIGN.md | 6 ++- ui/src/game/Game.svelte | 9 ++-- ui/src/lib/app.svelte.ts | 21 +++++++-- ui/src/lib/gamedelta.test.ts | 29 +++++++++++- ui/src/lib/gamedelta.ts | 14 ++++++ ui/src/lib/lobbycache.test.ts | 64 +++++++++++++++++++++++++- ui/src/lib/lobbycache.ts | 25 ++++++++++ 13 files changed, 269 insertions(+), 22 deletions(-) diff --git a/backend/internal/inttest/lobby_test.go b/backend/internal/inttest/lobby_test.go index 859be04..5a2a2e5 100644 --- a/backend/internal/inttest/lobby_test.go +++ b/backend/internal/inttest/lobby_test.go @@ -13,6 +13,7 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/engine" "scrabble/backend/internal/lobby" + "scrabble/backend/internal/notify" ) // newInvitationService builds an invitation service over the shared pool, starting @@ -97,6 +98,8 @@ func TestMatchmakingReEnqueueReturnsOwnOpenGame(t *testing.T) { func TestInvitationAllAcceptStartsGame(t *testing.T) { ctx := context.Background() svc := newInvitationService() + pub := &capturePublisher{} + svc.SetNotifier(pub) inviter := provisionAccount(t) invitees := []uuid.UUID{provisionAccount(t), provisionAccount(t)} @@ -118,6 +121,16 @@ func TestInvitationAllAcceptStartsGame(t *testing.T) { if final.Status != "started" || final.GameID == nil { t.Fatalf("invitation not started: %+v", final) } + // Every participant is told the invitation changed so their lobby drops the (now started) card + // from any screen, and the started-game notification lands too. + for _, u := range append([]uuid.UUID{inviter}, invitees...) { + if !pub.notified(u, notify.NotifyInvitationUpdate) { + t.Errorf("participant %s missing invitation_update", u) + } + } + if !pub.notified(invitees[0], notify.NotifyGameStarted) { + t.Errorf("invitee missing game_started") + } seats, _, status, err := newGameService().Participants(ctx, *final.GameID) if err != nil { t.Fatalf("participants: %v", err) @@ -130,6 +143,8 @@ func TestInvitationAllAcceptStartsGame(t *testing.T) { func TestInvitationDeclineCancels(t *testing.T) { ctx := context.Background() svc := newInvitationService() + pub := &capturePublisher{} + svc.SetNotifier(pub) inviter := provisionAccount(t) invitees := []uuid.UUID{provisionAccount(t), provisionAccount(t)} inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite()) @@ -143,6 +158,12 @@ func TestInvitationDeclineCancels(t *testing.T) { if got.Status != "declined" || got.GameID != nil { t.Fatalf("after decline: %+v", got) } + // A decline kills the whole invitation: every participant is notified to drop it. + for _, u := range append([]uuid.UUID{inviter}, invitees...) { + if !pub.notified(u, notify.NotifyInvitationUpdate) { + t.Errorf("participant %s missing invitation_update after decline", u) + } + } // A further response is refused. if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[1], true); !errors.Is(err, lobby.ErrInvitationNotPending) { t.Fatalf("respond after decline = %v, want ErrInvitationNotPending", err) @@ -184,6 +205,8 @@ func TestInvitationBlockedInvitee(t *testing.T) { func TestInvitationCancelByInviter(t *testing.T) { ctx := context.Background() svc := newInvitationService() + pub := &capturePublisher{} + svc.SetNotifier(pub) inviter := provisionAccount(t) invitees := []uuid.UUID{provisionAccount(t)} inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite()) @@ -197,6 +220,12 @@ func TestInvitationCancelByInviter(t *testing.T) { if err := svc.CancelInvitation(ctx, inv.ID, inviter); err != nil { t.Fatalf("inviter cancel: %v", err) } + // The cancellation reaches every participant so an invitee's lobby drops the card from any screen. + for _, u := range append([]uuid.UUID{inviter}, invitees...) { + if !pub.notified(u, notify.NotifyInvitationUpdate) { + t.Errorf("participant %s missing invitation_update after cancel", u) + } + } if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], true); !errors.Is(err, lobby.ErrInvitationNotPending) { t.Fatalf("respond after cancel = %v, want ErrInvitationNotPending", err) } diff --git a/backend/internal/lobby/invitations.go b/backend/internal/lobby/invitations.go index 4e53f4d..8a91e34 100644 --- a/backend/internal/lobby/invitations.go +++ b/backend/internal/lobby/invitations.go @@ -136,6 +136,25 @@ func (svc *InvitationService) emitGameStarted(ctx context.Context, g game.Game, svc.pub.Publish(intents...) } +// emitInvitationUpdate publishes a changed invitation to every participant (the inviter and all +// invitees), so each one's lobby patches its invitations list without a refetch: a still-pending +// invitation is upserted (an updated response) and a terminal one (started, declined, cancelled, +// expired) removed. It is in-app only — notify.NotifyInvitationUpdate carries no out-of-app push — +// so a withdrawal or decline never becomes a platform notification. +func (svc *InvitationService) emitInvitationUpdate(ctx context.Context, inv Invitation) { + summary := svc.invitationSummary(ctx, inv) + recipients := make([]uuid.UUID, 0, len(inv.Invitees)+1) + recipients = append(recipients, inv.InviterID) + for _, iv := range inv.Invitees { + recipients = append(recipients, iv.AccountID) + } + intents := make([]notify.Intent, 0, len(recipients)) + for _, id := range recipients { + intents = append(intents, notify.NotificationInvitationUpdate(id, summary)) + } + svc.pub.Publish(intents...) +} + // invitationSummary projects an Invitation into the notify.InvitationSummary the event carries, // resolving the inviter's and invitees' display names from the account store. func (svc *InvitationService) invitationSummary(ctx context.Context, inv Invitation) notify.InvitationSummary { @@ -251,7 +270,15 @@ func (svc *InvitationService) RespondInvitation(ctx context.Context, invitationI return Invitation{}, err } } - return svc.store.loadInvitation(ctx, invitationID) + inv, err := svc.store.loadInvitation(ctx, invitationID) + if err != nil { + return Invitation{}, err + } + // Tell every participant the invitation changed, so their lobby patches its list from any + // screen: an updated response is upserted, a terminal one (declined, or started after the final + // accept) removed. startGame already emitted the started-game notification separately. + svc.emitInvitationUpdate(ctx, inv) + return inv, nil } // startGame creates the game for a fully-accepted invitation and marks it started. @@ -289,7 +316,15 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U // CancelInvitation lets the inviter withdraw a pending invitation. func (svc *InvitationService) CancelInvitation(ctx context.Context, invitationID, inviterID uuid.UUID) error { - return svc.store.cancelInvitation(ctx, invitationID, inviterID, svc.now()) + if err := svc.store.cancelInvitation(ctx, invitationID, inviterID, svc.now()); err != nil { + return err + } + // The invitation is now cancelled: tell every participant so an invitee's lobby drops the card + // from any screen. Best-effort — a failed reload never fails the cancel. + if inv, err := svc.store.loadInvitation(ctx, invitationID); err == nil { + svc.emitInvitationUpdate(ctx, inv) + } + return nil } // GetInvitation loads an invitation with its invitees. diff --git a/backend/internal/notify/events.go b/backend/internal/notify/events.go index 970fda5..65ee14e 100644 --- a/backend/internal/notify/events.go +++ b/backend/internal/notify/events.go @@ -182,8 +182,21 @@ func NotificationGameStarted(userID uuid.UUID, state PlayerState) Intent { // NotificationInvitation builds the NotifyInvitation notification carrying the new invitation, // so the client adds it to its lobby invitations list without a refetch. func NotificationInvitation(userID uuid.UUID, inv InvitationSummary) Intent { + return notificationInvitation(userID, NotifyInvitation, inv) +} + +// NotificationInvitationUpdate builds the NotifyInvitationUpdate notification carrying a changed +// invitation, so the client patches its lobby invitations list without a refetch — upserting a +// still-pending one (an updated response) and removing one that reached a terminal status. +func NotificationInvitationUpdate(userID uuid.UUID, inv InvitationSummary) Intent { + return notificationInvitation(userID, NotifyInvitationUpdate, inv) +} + +// notificationInvitation builds a KindNotification intent of the given sub-kind carrying the +// invitation summary; it backs both the new-invitation and invitation-update notifications. +func notificationInvitation(userID uuid.UUID, sub string, inv InvitationSummary) Intent { b := flatbuffers.NewBuilder(512) - k := b.CreateString(NotifyInvitation) + k := b.CreateString(sub) invOff := buildInvitation(b, inv) fb.NotificationEventStart(b) fb.NotificationEventAddKind(b, k) diff --git a/backend/internal/notify/notify.go b/backend/internal/notify/notify.go index ce34df8..2ecc030 100644 --- a/backend/internal/notify/notify.go +++ b/backend/internal/notify/notify.go @@ -46,7 +46,13 @@ const ( // game screen watching that opponent re-derives its "add to friends" state. NotifyFriendDeclined = "friend_declined" NotifyInvitation = "invitation" - NotifyGameStarted = "game_started" + // NotifyInvitationUpdate carries a changed invitation — an updated invitee response, or a + // terminal status (started, declined, cancelled, expired) — so the client patches its lobby + // invitations list without a refetch. Unlike NotifyInvitation (a brand-new invitation), it is + // in-app only: the Telegram connector renders no message for it, so a withdrawal or decline never + // becomes an out-of-app push. + NotifyInvitationUpdate = "invitation_update" + NotifyGameStarted = "game_started" ) // Intent is one live event destined for a single user. Payload is the diff --git a/backend/internal/notify/notify_test.go b/backend/internal/notify/notify_test.go index 0aec6be..7680360 100644 --- a/backend/internal/notify/notify_test.go +++ b/backend/internal/notify/notify_test.go @@ -181,6 +181,23 @@ func TestNotificationInvitationCarriesInvitation(t *testing.T) { } } +func TestNotificationInvitationUpdateCarriesTerminalInvitation(t *testing.T) { + uid := uuid.New() + inv := notify.InvitationSummary{ID: "inv-9", Variant: "scrabble_en", Status: "declined"} + in := notify.NotificationInvitationUpdate(uid, inv) + if in.Kind != notify.KindNotification { + t.Fatalf("kind = %q", in.Kind) + } + ev := fb.GetRootAsNotificationEvent(in.Payload, 0) + if string(ev.Kind()) != notify.NotifyInvitationUpdate { + t.Fatalf("sub-kind = %q, want %q", ev.Kind(), notify.NotifyInvitationUpdate) + } + got := ev.Invitation(nil) + if got == nil || string(got.Id()) != "inv-9" || string(got.Status()) != "declined" { + t.Fatalf("invitation wrong: %+v", got) + } +} + func TestNotificationAccountCarriesAccount(t *testing.T) { uid := uuid.New() in := notify.NotificationAccount(uid, notify.NotifyFriendRequest, notify.AccountRef{AccountID: "a-1", DisplayName: "Ann"}) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b3a545e..1ab4251 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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). diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 206678b..1affad6 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -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 diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index e4c928d..c6cc2e8 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -21,7 +21,7 @@ import { shareOrDownloadGcg } from '../lib/share'; import { getCachedGame, setCachedGame, type CachedGame } from '../lib/gamecache'; import { patchLobbyGame } from '../lib/lobbycache'; - import { applyGameOver, applyMoveDelta, type DeltaResult } from '../lib/gamedelta'; + import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta'; import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram'; import { BLANK, @@ -232,9 +232,10 @@ } else if (e.kind === 'opponent_joined' && e.gameId === id && e.state) { // The opponent took the empty seat: adopt the new participants and status in place, // leaving the board, rack and any pending placement untouched (no refetch, no flicker). - if (view) { - view = { ...view, game: { ...view.game, seats: e.state.game.seats, status: e.state.game.status, players: e.state.game.players } }; - setCachedGame(id, view, moves); + const r = applyOpponentJoined(cacheSnapshot(), e.state); + if (r) { + view = r.view; + setCachedGame(id, r.view, r.moves); } } else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) { // A request the player sent was answered: re-derive the in-game "add friend" state. diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 60ca96c..4994b71 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -27,7 +27,7 @@ import { connection, reportOffline, reportOnline, resetConnection } from './conn import { isConnectionCode } from './retry'; import { clearGameCache, getCachedGame, setCachedGame } from './gamecache'; import { advanceCached } from './gamedelta'; -import { clearLobby, patchLobbyGame } from './lobbycache'; +import { clearLobby, patchLobbyGame, patchLobbyInvitation } from './lobbycache'; import type { BoardLabelMode } from './boardlabels'; export interface Toast { @@ -191,9 +191,16 @@ function openStream(): void { if (c) setCachedGame(e.gameId, c.view, c.moves); } } else if (e.kind === 'opponent_joined') { - // The opponent took an open game's empty seat: mirror the new status into the lobby snapshot - // from any screen. A mounted game board adopts the change in place itself (game/Game.svelte). - if (e.state) patchLobbyGame(e.state.game); + // The opponent took an open game's empty seat: mirror the new status into the lobby snapshot, + // and warm a not-currently-viewed game's own cache, so opening it from any screen is flash-free. + // The mounted game board owns its cache (game/Game.svelte), so skip the game in view. + if (e.state) { + patchLobbyGame(e.state.game); + if (!viewingGame(e.gameId)) { + const c = advanceCached(getCachedGame(e.gameId), e); + if (c) setCachedGame(e.gameId, c.view, c.moves); + } + } } else if (e.kind === 'match_found') { // Seed both caches from the event's initial state so the game renders instantly on arrival // and the new game is already in the lobby on a later return, then navigate. @@ -209,6 +216,12 @@ function openStream(): void { setCachedGame(e.state.game.id, e.state, []); patchLobbyGame(e.state.game); } + // An invitation created / updated / withdrawn elsewhere: keep the lobby's invitations list + // fresh from any screen — a new pending one is added, a consumed (started), declined, + // cancelled or expired one is removed (see patchLobbyInvitation). + if ((e.sub === 'invitation' || e.sub === 'invitation_update') && e.invitation) { + patchLobbyInvitation(e.invitation); + } void refreshNotifications(); } }, diff --git a/ui/src/lib/gamedelta.test.ts b/ui/src/lib/gamedelta.test.ts index c61c1cc..f31f9d4 100644 --- a/ui/src/lib/gamedelta.test.ts +++ b/ui/src/lib/gamedelta.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { advanceCached, applyGameOver, applyMoveDelta, seedInitialState, type MoveDelta } from './gamedelta'; +import { advanceCached, applyGameOver, applyMoveDelta, applyOpponentJoined, seedInitialState, type MoveDelta } from './gamedelta'; import type { CachedGame } from './gamecache'; import type { GameView, MoveRecord, PushEvent, StateView } from './model'; @@ -141,3 +141,30 @@ describe('advanceCached', () => { expect(advanceCached(cache(3, 0), yourTurn)).toBeUndefined(); }); }); + +describe('applyOpponentJoined', () => { + // joinedState is the refreshed StateView an opponent_joined event carries: the open game is now + // active and both seats are filled. + function joinedState(): StateView { + const game: GameView = { ...gameView(0), status: 'active', players: 2, seats: [ + { seat: 0, accountId: 'me', displayName: 'Me', score: 0, hintsUsed: 0, isWinner: false }, + { seat: 1, accountId: 'opp', displayName: 'Opp', score: 0, hintsUsed: 0, isWinner: false }, + ] }; + return { game, seat: 0, rack: ['x'], bagLen: 90, hintsRemaining: 0 }; + } + + it("adopts the joined seats and status while preserving the cached rack and moves", () => { + // The cached open game is still "searching": empty seats, status open, the starter's own rack. + const cached: CachedGame = { view: { game: { ...gameView(2), status: 'open', seats: [] }, seat: 0, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 }, moves: [move(0)] }; + const res = applyOpponentJoined(cached, joinedState()); + expect(res?.view.game.status).toBe('active'); + expect(res?.view.game.seats).toHaveLength(2); + expect(res?.view.rack).toEqual(['a', 'b']); // the starter's rack is untouched by the join + expect(res?.view.game.moveCount).toBe(2); // the cached count/board is preserved + expect(res?.moves).toHaveLength(1); + }); + + it('returns undefined when nothing is cached for the game', () => { + expect(applyOpponentJoined(undefined, joinedState())).toBeUndefined(); + }); +}); diff --git a/ui/src/lib/gamedelta.ts b/ui/src/lib/gamedelta.ts index 9466c2f..b5ead5c 100644 --- a/ui/src/lib/gamedelta.ts +++ b/ui/src/lib/gamedelta.ts @@ -82,5 +82,19 @@ export function advanceCached(cached: CachedGame | undefined, e: PushEvent): Cac return applyMoveDelta(cached, { move: e.move, game: e.game, bagLen: e.bagLen }).cache; } if (e.kind === 'game_over') return applyGameOver(cached, e.game).cache; + if (e.kind === 'opponent_joined' && e.state) return applyOpponentJoined(cached, e.state); return undefined; } + +/** + * applyOpponentJoined adopts an opponent_joined event into a cached open game: it overlays the + * refreshed seats, status and player count from the event's StateView onto the cached game, leaving + * the board, the starter's rack and everything else intact (the join changes who is seated, not the + * position). It returns undefined when the game is not cached (the next open cold-loads it). It backs + * both the mounted game board's in-place update and the global handler's off-screen cache warming. + */ +export function applyOpponentJoined(cached: CachedGame | undefined, state: StateView): CachedGame | undefined { + if (!cached) return undefined; + const game: GameView = { ...cached.view.game, seats: state.game.seats, status: state.game.status, players: state.game.players }; + return { view: { ...cached.view, game }, moves: cached.moves }; +} diff --git a/ui/src/lib/lobbycache.test.ts b/ui/src/lib/lobbycache.test.ts index d7d0035..ffbd934 100644 --- a/ui/src/lib/lobbycache.test.ts +++ b/ui/src/lib/lobbycache.test.ts @@ -1,6 +1,23 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { clearLobby, getLobby, patchLobbyGame, setLobby } from './lobbycache'; -import type { AccountRef, GameView } from './model'; +import { clearLobby, getLobby, patchLobbyGame, patchLobbyInvitation, setLobby } from './lobbycache'; +import type { AccountRef, GameView, Invitation } from './model'; + +function invitation(id: string, status: string): Invitation { + return { + id, + inviter: { accountId: 'inv', displayName: 'Inv' }, + invitees: [], + variant: 'scrabble_en', + turnTimeoutSecs: 300, + hintsAllowed: true, + hintsPerPlayer: 0, + multipleWordsPerTurn: true, + dropoutTiles: 'remove', + status, + gameId: '', + expiresAtUnix: 0, + }; +} function gameView(id: string, status: GameView['status'] = 'active', toMove = 0): GameView { return { @@ -59,3 +76,46 @@ describe('patchLobbyGame', () => { expect(games[0].toMove).toBe(0); // the original array/object is left intact }); }); + +describe('patchLobbyInvitation', () => { + it('adds a new pending invitation to the cached lobby', () => { + setLobby({ games: [], invitations: [], incoming: [] }); + patchLobbyInvitation(invitation('i1', 'pending')); + expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1']); + }); + + it('replaces a pending invitation already in the list (e.g. an updated response)', () => { + setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] }); + const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 }; + patchLobbyInvitation(updated); + expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']); + expect(getLobby()?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999); + }); + + it('removes an invitation that reached a terminal status', () => { + for (const terminal of ['declined', 'cancelled', 'started', 'expired']) { + setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] }); + patchLobbyInvitation(invitation('i1', terminal)); + expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']); + } + }); + + it('is a no-op for a terminal invitation that is not in the list', () => { + setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [] }); + patchLobbyInvitation(invitation('i1', 'declined')); + expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']); + }); + + it('is a no-op when there is no cached lobby yet', () => { + patchLobbyInvitation(invitation('i1', 'pending')); + expect(getLobby()).toBeNull(); + }); + + it('preserves games and incoming when patching an invitation', () => { + const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }]; + setLobby({ games: [gameView('g1')], invitations: [], incoming }); + patchLobbyInvitation(invitation('i1', 'pending')); + expect(getLobby()?.games.map((g) => g.id)).toEqual(['g1']); + expect(getLobby()?.incoming).toEqual(incoming); + }); +}); diff --git a/ui/src/lib/lobbycache.ts b/ui/src/lib/lobbycache.ts index 51bbd41..bdda22f 100644 --- a/ui/src/lib/lobbycache.ts +++ b/ui/src/lib/lobbycache.ts @@ -42,6 +42,31 @@ export function patchLobbyGame(g: GameView): void { snapshot = { ...snapshot, games }; } +/** + * patchLobbyInvitation applies a live invitation change to the cached lobby's invitations list, so a + * change seen while the lobby is unmounted is reflected on its next render instead of a stale card + * lingering until the background refresh. A still-pending invitation is upserted (a new one + * prepended newest-first, an updated one replaced in place); an invitation that reached any terminal + * status (started, declined, cancelled, expired) is removed — the authoritative list holds only + * pending invitations. It is a no-op when there is no snapshot yet, or a terminal invitation is not + * in the list. The lobby re-renders from the snapshot, so the in-place upsert needs no re-sort. + */ +export function patchLobbyInvitation(inv: Invitation): void { + if (!snapshot) return; + const i = snapshot.invitations.findIndex((x) => x.id === inv.id); + let invitations: Invitation[]; + if (inv.status !== 'pending') { + if (i === -1) return; // a terminal invitation already absent — nothing to remove + invitations = snapshot.invitations.filter((x) => x.id !== inv.id); + } else if (i === -1) { + invitations = [inv, ...snapshot.invitations]; + } else { + invitations = snapshot.invitations.slice(); + invitations[i] = inv; + } + snapshot = { ...snapshot, invitations }; +} + /** clearLobby drops the cached lobby (called on logout). */ export function clearLobby(): void { snapshot = null; -- 2.52.0 From f3914af7934a5ed8c7e1019e463f413d0341628a Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 14 Jun 2026 11:44:04 +0200 Subject: [PATCH 2/5] fix(ui): recentre the board on a hint taken while already zoomed in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The board only scrolled to the hint's word when the hint also toggled zoom (the zoom-out case): the recenter effect tracked `zoomed` and read `focus` untracked, so a hint taken while ALREADY zoomed in — no zoom change — never recentred and the board stayed parked where the player was looking. Add a `recenter` nonce the hint bumps; the board's scroll effect tracks it and, when it fires without a zoom toggle, pans straight to `focus` (the board width is stable, so the width-driven zoom tween has nothing to ride). Placing a 2nd+ tile or hovering a dragged tile still set `focus` without the nonce, so the board never jumps on those — the original no-jump intent is preserved. e2e: zoom into the corner, then hint (the mock plays at the centre) — the board pans toward the centre. Verified RED without the fix (both engines), GREEN with. --- ui/e2e/zoom.spec.ts | 34 ++++++++++++++++++++++++++++++++++ ui/src/game/Board.svelte | 30 ++++++++++++++++++++++++++---- ui/src/game/Game.svelte | 9 +++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/ui/e2e/zoom.spec.ts b/ui/e2e/zoom.spec.ts index 0893b45..de2ff71 100644 --- a/ui/e2e/zoom.spec.ts +++ b/ui/e2e/zoom.spec.ts @@ -52,3 +52,37 @@ test('zoomed board clamps overscroll at the edge', async ({ page }) => { expect(ob.x).toBe('none'); expect(ob.y).toBe('none'); }); + +// A hint taken while the board is ALREADY zoomed in must still scroll to the played word — the +// zoom state does not change, so without an explicit recenter the board stays parked where the +// player was looking (the reported rough edge). The mock hint plays at the centre star (7,7), so +// zooming into the top-left corner first and then hinting must pan the board toward the centre. +test('a hint recentres an already-zoomed board on the played word', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await page.getByRole('button', { name: /Ann/ }).click(); + + // Zoom into the top-left cell (far from the centre where the hint lands). + await page + .locator('[data-cell]:not(.filled)') + .first() + .evaluate((el: HTMLElement) => { + el.click(); + el.click(); + }); + const viewport = page.locator('.viewport.zoomed'); + await expect(viewport).toBeVisible(); + await page.waitForTimeout(400); // let the zoom-in settle near the top-left corner + const before = await viewport.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop })); + + // Take a hint (the control confirms on a second tap), which plays at the centre. + const hint = page.getByRole('button', { name: 'Hint' }); + await hint.click(); + await hint.click(); + await page.waitForTimeout(300); // the board pans to the hint word + + const after = await viewport.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop })); + // The board panned from the top-left toward the centre word: both offsets grew. + expect(after.left).toBeGreaterThan(before.left + 20); + expect(after.top).toBeGreaterThan(before.top + 20); +}); diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 9c33563..6f4557a 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -20,6 +20,7 @@ lines, locale, focus, + recenter, dropTarget, oncell, ontogglezoom, @@ -39,6 +40,9 @@ lines: boolean; locale: Locale; focus: { row: number; col: number } | null; + /** A monotonic nonce the parent bumps to request a scroll to `focus` even when the zoom state + * is unchanged — the hint recentring an already-zoomed board on the word it played. */ + recenter: number; /** The cell a dragged tile is currently aimed at, highlighted as a drop target. */ dropTarget: { row: number; col: number } | null; oncell: (row: number, col: number) => void; @@ -60,10 +64,17 @@ // the board's real width grows (zoom-in) or shrinks (zoom-out), so it magnifies evenly // from A to B in one motion instead of chasing a per-frame target that the scroll bounds // clamp — which made the board lurch one way and then snap back near the edges/centre. - // It runs only on a zoom toggle (`zoomed`); changing `focus` while already zoomed does - // not recentre, so placing a 2nd+ tile or hovering a dragged tile never jumps the board. + // It tracks the zoom toggle (`zoomed`) and the `recenter` nonce; `focus` stays untracked, + // so placing a 2nd+ tile or hovering a dragged tile never jumps the board — only a zoom + // toggle or an explicit recenter request (the hint) moves it. A recenter with no zoom change + // pans straight to `focus`, since the board width is stable and the width-driven tween below + // has nothing to ride (the case the owner hit: a hint taken while already zoomed in). + // The previous zoom state, to tell a zoom toggle from a recenter request. The board always mounts + // zoomed-out, so it starts false and is updated to the live zoom on each effect run. + let prevZoom = false; $effect(() => { const on = zoomed; + void recenter; // re-run on an explicit recenter request even when the zoom state is unchanged const vp = viewport; if (!vp) return; const f = untrack(() => focus); @@ -72,8 +83,6 @@ if (clientW === 0) return; const fitW = clientW; // board width at scale 1 (fills the viewport) const fullW = clientW * Z; // board width at full zoom - const startSL = vp.scrollLeft; - const startST = vp.scrollTop; let finalSL = 0; let finalST = 0; if (on && f) { @@ -81,6 +90,19 @@ finalSL = Math.max(0, Math.min((f.col + 0.5) * cell - clientW / 2, fullW - clientW)); finalST = Math.max(0, Math.min((f.row + 0.5) * cell - clientH / 2, fullW - clientH)); } + const toggled = on !== prevZoom; + prevZoom = on; + if (!toggled) { + // No zoom change: an explicit recenter (the hint) pans straight to the focused word; a stale + // run with nothing focused leaves the scroll alone. + if (on && f) { + vp.scrollLeft = finalSL; + vp.scrollTop = finalST; + } + return; + } + const startSL = vp.scrollLeft; + const startST = vp.scrollTop; const fromW = on ? fitW : fullW; // board width when this transition begins const toW = on ? fullW : fitW; let raf = requestAnimationFrame(function tick() { diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index c6cc2e8..cc7aa14 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -47,6 +47,10 @@ let zoomed = $state(false); let selected = $state(null); let focus = $state<{ row: number; col: number } | null>(null); + // Bumped to ask the board to scroll to `focus` even when the zoom state does not change — the + // hint recentring an already-zoomed board on the word it just played (a plain focus change is + // deliberately ignored by the board so placing/dragging tiles never jumps it). + let recenter = $state(0); // A stable id per rack slot, permuted together with the letters on shuffle, so the rack // tiles fly to their new positions (Rack's hop animation) instead of relabelling in place. let rackIds = $state([]); @@ -629,6 +633,10 @@ row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2), col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2), }; + // Ask the board to scroll to the hint word. A zoom-out board zooms in (and centres) on + // the next line; this nonce also recentres a board that is already zoomed in, where the + // unchanged zoom state would otherwise leave it parked where the player was looking. + recenter++; } if (isCoarse()) zoomed = true; view = { ...view, hintsRemaining: h.hintsRemaining }; @@ -929,6 +937,7 @@ lines={app.boardLines} locale={app.locale} {focus} + {recenter} {dropTarget} oncell={onCell} ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }} -- 2.52.0 From 1cc6c0d56e18131a856b1016bb443434c7cc68c5 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 14 Jun 2026 12:39:57 +0200 Subject: [PATCH 3/5] fix(ui): stop the game-screen freeze when an opponent joins (reactive self-loop) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-game live-event $effect read `view`/`moves`/`placement` (via cacheSnapshot and direct reads) AND wrote `view` in its branches, so those reads became the effect's own dependencies: writing `view = …` re-ran the effect, and with app.lastEvent unchanged it re-entered the same branch and wrote `view` again — a tight self-invalidating loop that pinned the main thread, freezing the board and rack. opponent_moved escaped it only because applyMoveDelta is idempotent on the move count (no cache → no write on the second pass); opponent_joined and game_over have no such guard, so an opponent joining hung the whole screen. Tracking `placement` similarly re-fired the handler on every tile the player placed after an opponent's move (a spurious reload). Fix: the effect must depend only on app.lastEvent and process each event once. Wrap the branch body in `untrack`, scoping its view/moves/placement reads out of the effect's dependency set; the writes inside no longer re-trigger it. e2e: after the opponent joins, placing a rack tile must render a pending tile — verified RED (frozen, 0 pending tiles, both engines) before the fix, GREEN after. --- ui/e2e/quickmatch.spec.ts | 14 ++++++++++ ui/src/game/Game.svelte | 55 +++++++++++++++++++++++---------------- 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/ui/e2e/quickmatch.spec.ts b/ui/e2e/quickmatch.spec.ts index 4e3b388..9271dc2 100644 --- a/ui/e2e/quickmatch.spec.ts +++ b/ui/e2e/quickmatch.spec.ts @@ -76,3 +76,17 @@ test('quick game: a foreground resync recovers a join shed while the stream stay await expect(page.getByText('Robo')).toBeVisible(); await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); }); + +// Regression: an opponent joining must not freeze the screen. The in-game live-event effect tracked +// the `view` it also writes, so opponent_joined re-invalidated itself in a tight loop (opponent_moved +// was spared only by its delta's move-count idempotency). The board must still respond afterwards. +test('quick game: the board stays interactive after the opponent joins', async ({ page }) => { + await enterOpenGame(page); + await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent()); + await expect(page.getByText('Robo')).toBeVisible(); + + // Place a rack tile on the centre star: a frozen screen renders no pending tile. + await page.locator('.rack .tile').first().click(); + await page.locator('[data-cell]:not(.filled)').nth(112).click(); // row 7, col 7 + await expect(page.locator('[data-cell].pending')).toHaveCount(1); +}); diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index cc7aa14..580d93a 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -1,5 +1,5 @@