diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index ee92f94..206678b 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -48,7 +48,15 @@ Login uses `Screen`. collapse to nothing under reduce-motion. Per-game and lobby in-memory caches (`lib/gamecache.ts`, `lib/lobbycache.ts`) render a re-opened game or the lobby instantly and refresh in the background, removing the blank-loading flash and the lobby's "draw-in" - on lobby ↔ game navigation. + on lobby ↔ game navigation. The caches are kept fresh **across** screens, so the instant render + is already current and not a stale frame the background refresh then corrects. The single global + 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 + 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 scheme is forced from `Telegram.WebApp.colorScheme` (over the OS `prefers-color-scheme`, which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 4e1de95..e4c928d 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -20,6 +20,7 @@ import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; 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 { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram'; import { @@ -138,6 +139,9 @@ view = st; moves = hist.moves; setCachedGame(id, st, hist.moves); + // Mirror the fresh status into the lobby snapshot so returning there shows it without a + // stale flash before the lobby's own background refresh lands. + patchLobbyGame(st.game); selected = null; await applyDraft(st); recompute(); @@ -559,6 +563,7 @@ view = { game: r.game, seat: r.move.player, rack: r.rack, bagLen: r.bagLen, hintsRemaining: view?.hintsRemaining ?? 0 }; moves = [...moves, r.move]; setCachedGame(id, view, moves); + patchLobbyGame(r.game); rackIds = r.rack.map((_, i) => i); placement = newPlacement(r.rack); selected = null; diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index df11a9c..60ca96c 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -25,8 +25,9 @@ import { parseStartParam } from './deeplink'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte'; import { isConnectionCode } from './retry'; -import { clearGameCache, setCachedGame } from './gamecache'; -import { clearLobby } from './lobbycache'; +import { clearGameCache, getCachedGame, setCachedGame } from './gamecache'; +import { advanceCached } from './gamedelta'; +import { clearLobby, patchLobbyGame } from './lobbycache'; import type { BoardLabelMode } from './boardlabels'; export interface Toast { @@ -144,6 +145,16 @@ export function handleError(err: unknown): void { showToast(t(code ? errorKey(code) : 'error.generic'), 'error'); } +/** + * viewingGame reports whether the game board for the given game id is the current route. That screen + * maintains its own cache from live events, so the global stream handler must not also advance it (a + * double application). The chat / dictionary sub-screens leave the board unmounted, so they do not + * count as viewing — there the global handler keeps the cache warm for the return to the board. + */ +function viewingGame(gameId: string): boolean { + return router.route.name === 'game' && router.route.params.id === gameId; +} + function openStream(): void { closeStream(); app.streamAlive = true; @@ -168,15 +179,36 @@ function openStream(): void { showToast(t('chat.nudge'), 'info'); } else if (e.kind === 'your_turn') { showToast(t('game.yourTurn'), 'info'); + } else if (e.kind === 'opponent_moved' || e.kind === 'game_over') { + // Keep both caches fresh from any screen, so neither the lobby nor the game flashes a stale + // frame on the next navigation. The lobby snapshot is patched from the event's own GameView + // (independent of whether this device has the game cached). The per-game cache is advanced + // only for a game not currently in view — the mounted game board owns its own cache (see + // game/Game.svelte), so skipping it avoids a double apply. + if (e.game) patchLobbyGame(e.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 === '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); } else if (e.kind === 'match_found') { - // Seed the cache from the event's initial state so the game renders instantly on arrival, - // then navigate. - if (e.state) setCachedGame(e.state.game.id, e.state, []); + // 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. + if (e.state) { + setCachedGame(e.state.game.id, e.state, []); + patchLobbyGame(e.state.game); + } navigate(`/game/${e.gameId}`); } else if (e.kind === 'notify') { - // A started invited game seeds its cache so opening it is instant; the lobby badge stays - // on the authoritative refresh. - if (e.sub === 'game_started' && e.state) setCachedGame(e.state.game.id, e.state, []); + // A started invited game seeds its cache so opening it is instant and lands in the lobby + // snapshot from any screen; the lobby badge stays on the authoritative refresh. + if (e.sub === 'game_started' && e.state) { + setCachedGame(e.state.game.id, e.state, []); + patchLobbyGame(e.state.game); + } void refreshNotifications(); } }, diff --git a/ui/src/lib/gamedelta.test.ts b/ui/src/lib/gamedelta.test.ts index 5e09f7f..c61c1cc 100644 --- a/ui/src/lib/gamedelta.test.ts +++ b/ui/src/lib/gamedelta.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { applyGameOver, applyMoveDelta, seedInitialState, type MoveDelta } from './gamedelta'; +import { advanceCached, applyGameOver, applyMoveDelta, seedInitialState, type MoveDelta } from './gamedelta'; import type { CachedGame } from './gamecache'; -import type { GameView, MoveRecord, StateView } from './model'; +import type { GameView, MoveRecord, PushEvent, StateView } from './model'; function gameView(moveCount: number, over = false): GameView { return { @@ -98,3 +98,46 @@ describe('applyGameOver', () => { expect(res.cache?.view.game.moveCount).toBe(10); }); }); + +function movedEvent(moveCount: number, player: number, bagLen = 45): PushEvent { + return { kind: 'opponent_moved', gameId: 'g1', move: move(player), game: gameView(moveCount), bagLen }; +} + +function gameOverEvent(moveCount: number): PushEvent { + return { kind: 'game_over', gameId: 'g1', result: 'win', scoreLine: '10:0', game: gameView(moveCount, true) }; +} + +describe('advanceCached', () => { + it("advances the cache from an opponent's move for a game not on screen", () => { + const res = advanceCached(cache(3, 0), movedEvent(4, 1, 42)); + expect(res?.view.game.moveCount).toBe(4); + expect(res?.view.bagLen).toBe(42); + expect(res?.moves).toHaveLength(1); + }); + + it('returns undefined on a gap, so the cache is left for the next open to cold-load', () => { + expect(advanceCached(cache(3, 0), movedEvent(6, 1))).toBeUndefined(); + }); + + it('returns undefined for an already-applied move (idempotent re-delivery / own echo)', () => { + expect(advanceCached(cache(4, 0), movedEvent(4, 1))).toBeUndefined(); + }); + + it('settles a game_over event when the cached board is current', () => { + const res = advanceCached(cache(10, 0), gameOverEvent(10)); + expect(res?.view.game.status).toBe('finished'); + }); + + it('returns undefined for game_over when the cached board is behind the final count', () => { + expect(advanceCached(cache(9, 0), gameOverEvent(10))).toBeUndefined(); + }); + + it('returns undefined when nothing is cached for the game', () => { + expect(advanceCached(undefined, movedEvent(1, 1))).toBeUndefined(); + }); + + it('ignores event kinds that do not advance a game board', () => { + const yourTurn: PushEvent = { kind: 'your_turn', gameId: 'g1', deadlineUnix: 0, moveCount: 4 }; + expect(advanceCached(cache(3, 0), yourTurn)).toBeUndefined(); + }); +}); diff --git a/ui/src/lib/gamedelta.ts b/ui/src/lib/gamedelta.ts index 9e7b46c..9466c2f 100644 --- a/ui/src/lib/gamedelta.ts +++ b/ui/src/lib/gamedelta.ts @@ -4,7 +4,7 @@ // `refetch` — which keeps the gap / own-move / idempotency logic unit-testable in isolation. import type { CachedGame } from './gamecache'; -import type { GameView, MoveRecord, StateView } from './model'; +import type { GameView, MoveRecord, PushEvent, StateView } from './model'; /** The fields an opponent_moved delta carries that advance a cached game. */ export interface MoveDelta { @@ -67,3 +67,20 @@ export function applyGameOver(cached: CachedGame | undefined, game: GameView | u const view: StateView = { ...cached.view, game }; return { cache: { view, moves: cached.moves }, refetch: false }; } + +/** + * advanceCached applies a live game event to a cached game off-screen, for the global stream handler + * to keep a game the player is not currently viewing warm: opening it from the lobby then renders the + * opponent's move (or the final result) with no stale-board flash. It returns the advanced cache, or + * undefined when nothing changes — no cache, an idempotent re-delivery, an unrelated event kind, or a + * gap / missing payload that the next open cold-loads (off-screen there is no game screen to honour a + * refetch, so a fall-back simply leaves the cache for load() to repair). The mounted game screen owns + * its own cache, so the caller skips the game currently in view to avoid applying a delta twice. + */ +export function advanceCached(cached: CachedGame | undefined, e: PushEvent): CachedGame | undefined { + if (e.kind === 'opponent_moved') { + return applyMoveDelta(cached, { move: e.move, game: e.game, bagLen: e.bagLen }).cache; + } + if (e.kind === 'game_over') return applyGameOver(cached, e.game).cache; + return undefined; +} diff --git a/ui/src/lib/lobbycache.test.ts b/ui/src/lib/lobbycache.test.ts new file mode 100644 index 0000000..d7d0035 --- /dev/null +++ b/ui/src/lib/lobbycache.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { clearLobby, getLobby, patchLobbyGame, setLobby } from './lobbycache'; +import type { AccountRef, GameView } from './model'; + +function gameView(id: string, status: GameView['status'] = 'active', toMove = 0): GameView { + return { + id, + variant: 'scrabble_en', + dictVersion: 'v1', + status, + players: 2, + toMove, + turnTimeoutSecs: 300, + multipleWordsPerTurn: true, + moveCount: 0, + endReason: '', + lastActivityUnix: 0, + seats: [], + }; +} + +beforeEach(() => clearLobby()); + +describe('patchLobbyGame', () => { + it('replaces the matching game by id and leaves the others untouched', () => { + setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [] }); + // The player's own move flipped game "a" to the opponent's turn. + patchLobbyGame(gameView('a', 'active', 1)); + const snap = getLobby(); + expect(snap?.games.map((g) => g.id)).toEqual(['a', 'b']); + expect(snap?.games.find((g) => g.id === 'a')?.toMove).toBe(1); + expect(snap?.games.find((g) => g.id === 'b')?.toMove).toBe(0); + }); + + it('preserves invitations and incoming when patching a game', () => { + const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }]; + setLobby({ games: [gameView('a')], invitations: [], incoming }); + patchLobbyGame(gameView('a', 'finished')); + expect(getLobby()?.incoming).toEqual(incoming); + expect(getLobby()?.games[0].status).toBe('finished'); + }); + + it('adds the game when it is not yet in the cached lobby (a game started elsewhere)', () => { + setLobby({ games: [gameView('a')], invitations: [], incoming: [] }); + patchLobbyGame(gameView('z', 'active')); + // Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership. + expect(getLobby()?.games.map((g) => g.id).sort()).toEqual(['a', 'z']); + }); + + it('is a no-op when there is no cached lobby yet', () => { + patchLobbyGame(gameView('a')); + expect(getLobby()).toBeNull(); + }); + + it('does not mutate the previous snapshot array', () => { + const games = [gameView('a', 'active', 0)]; + setLobby({ games, invitations: [], incoming: [] }); + patchLobbyGame(gameView('a', 'active', 1)); + expect(games[0].toMove).toBe(0); // the original array/object is left intact + }); +}); diff --git a/ui/src/lib/lobbycache.ts b/ui/src/lib/lobbycache.ts index 5184bf8..51bbd41 100644 --- a/ui/src/lib/lobbycache.ts +++ b/ui/src/lib/lobbycache.ts @@ -24,6 +24,24 @@ export function setLobby(s: LobbySnapshot): void { snapshot = s; } +/** + * patchLobbyGame upserts the given game into the cached lobby — replacing the matching entry, or + * prepending it when absent — so a game-state change seen while the lobby is unmounted (the player's + * own move, or any live game event the global stream handler applies from another screen) is already + * reflected the next time the lobby renders from the snapshot, instead of showing the stale status + * until the background refresh lands. Prepending a freshly started game likewise lets it appear at + * once. It is a no-op only when there is no snapshot yet (the lobby cold-loads on its first mount). + * The lobby re-groups and re-sorts on render, so the insert position carries no meaning. + */ +export function patchLobbyGame(g: GameView): void { + if (!snapshot) return; + const i = snapshot.games.findIndex((x) => x.id === g.id); + const games = snapshot.games.slice(); + if (i === -1) games.unshift(g); + else games[i] = g; + snapshot = { ...snapshot, games }; +} + /** clearLobby drops the cached lobby (called on logout). */ export function clearLobby(): void { snapshot = null;