diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 319062c..1032a4a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -585,7 +585,10 @@ and **invitation-update** carry the full invitation, and the client upserts a st 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 +renders the next turn without a self-refetch. Beyond that event-driven warming, the lobby +**preloads** the player's ongoing games — each one's `game.state`, `game.history` and saved +**draft** — into that same per-game cache, so opening one from the lobby is instant and a saved +composition paints already on the board (no rack→board step). The `notify` package owns the FlatBuffers encoding (fed wire-agnostic input structs by the domain services) and the gateway forwards every payload verbatim. Auto-match needs no match poll — `Enqueue` returns the game the player enters synchronously, and an opponent later taking the open seat arrives as the in-app **opponent-joined** diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 833efe9..c846a46 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -19,7 +19,7 @@ import { variantNameKey } from '../lib/variants'; import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { shareOrDownloadGcg } from '../lib/share'; - import { getCachedGame, setCachedGame, type CachedGame } from '../lib/gamecache'; + import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; import { patchLobbyGame } from '../lib/lobbycache'; import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta'; import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram'; @@ -136,18 +136,21 @@ // Ask for the alphabet table only on a per-variant cache miss (the first open of a // game whose variant the client has not cached yet); steady-state polls omit it. const includeAlphabet = !view || !hasAlphabet(view.game.variant); - const [st, hist] = await Promise.all([ + // Fetch the saved draft alongside state and history (best-effort) so the composition is + // applied in the same tick the board appears — never as a second, visible rack→board step. + const [st, hist, draft] = await Promise.all([ gateway.gameState(id, includeAlphabet), gateway.gameHistory(id), + gateway.draftGet(id).catch(() => ''), ]); view = st; moves = hist.moves; - setCachedGame(id, st, hist.moves); + setCachedGame(id, st, hist.moves, draft); // 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); + applyDraft(st, draft); recompute(); refreshRecent(); } catch (e) { @@ -156,28 +159,28 @@ } let draftSaveTimer: ReturnType | null = null; // scheduleDraftSave persists the composition (rack order + pending tiles) after a short - // debounce; best-effort, so a failed save never interrupts play. + // debounce; best-effort, so a failed save never interrupts play. The cache is updated at once + // so leaving and re-entering the game in the same session paints the latest composition. function scheduleDraftSave() { + const json = serializeDraft(rackIds, placement.pending); + setCachedDraft(id, json); if (draftSaveTimer) clearTimeout(draftSaveTimer); draftSaveTimer = setTimeout(() => { - void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {}); + void gateway.draftSave(id, json).catch(() => {}); }, 500); } // applyDraft restores the player's saved composition over a freshly loaded state: the rack // order (when still a valid permutation of the rack) and the board tiles whose cell is still - // free. Best-effort — a draft fetch never blocks opening the game. - async function applyDraft(st: StateView) { + // free. It takes the already-fetched draft JSON (load and the warm onMount supply it), so the + // composition is applied synchronously — no extra fetch, no second rack→board step. + function applyDraft(st: StateView, draftJson: string) { let order = st.rack.map((_, i) => i); let tiles: Tile[] = []; - try { - const parsed = parseDraft(await gateway.draftGet(id)); - if (parsed) { - order = validRackOrder(parsed.rackOrder, st.rack.length) ?? order; - const committed = replay(moves); - tiles = liveDraftTiles(parsed.tiles, (r, c) => !!committed[r]?.[c]); - } - } catch { - /* best-effort */ + const parsed = parseDraft(draftJson); + if (parsed) { + order = validRackOrder(parsed.rackOrder, st.rack.length) ?? order; + const committed = replay(moves); + tiles = liveDraftTiles(parsed.tiles, (r, c) => !!committed[r]?.[c]); } rackIds = order; const rack = order.map((i) => st.rack[i]); @@ -192,8 +195,9 @@ if (cached) { view = cached.view; moves = cached.moves; - placement = newPlacement(cached.view.rack); - rackIds = cached.view.rack.map((_, i) => i); + // Apply the cached draft synchronously so a warm/preloaded open paints the pending tiles + // already on the board (no full-rack-then-jump). load() then refreshes in the background. + applyDraft(cached.view, cached.draft ?? ''); refreshRecent(); } void load(); @@ -576,7 +580,9 @@ function applyMoveResult(r: MoveResult) { 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); + // A committed move clears the actor's draft on the server, so clear the cached draft too; + // otherwise a same-session re-entry would briefly re-apply the now-stale composition. + setCachedGame(id, view, moves, ''); patchLobbyGame(r.game); rackIds = r.rack.map((_, i) => i); placement = newPlacement(r.rack); diff --git a/ui/src/lib/gamecache.test.ts b/ui/src/lib/gamecache.test.ts new file mode 100644 index 0000000..52eb07f --- /dev/null +++ b/ui/src/lib/gamecache.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { clearGameCache, getCachedGame, setCachedDraft, setCachedGame } from './gamecache'; +import type { GameView, MoveRecord, StateView } from './model'; + +function gameView(id: string): GameView { + return { + id, + variant: 'scrabble_en', + dictVersion: 'v1', + status: 'active', + players: 2, + toMove: 0, + turnTimeoutSecs: 300, + multipleWordsPerTurn: true, + moveCount: 0, + endReason: '', + lastActivityUnix: 0, + seats: [], + }; +} + +function view(id: string, rack: string[] = ['A', 'B']): StateView { + return { game: gameView(id), seat: 0, rack, bagLen: 50, hintsRemaining: 1 }; +} + +function move(player: number): MoveRecord { + return { player, action: 'play', dir: 'H', mainRow: 7, mainCol: 7, tiles: [], words: ['AB'], count: 0, score: 10, total: 10 }; +} + +beforeEach(() => clearGameCache()); + +describe('setCachedGame', () => { + it('stores state, history and draft', () => { + setCachedGame('g1', view('g1'), [move(0)], '{"x":1}'); + const c = getCachedGame('g1'); + expect(c?.view.game.id).toBe('g1'); + expect(c?.moves).toHaveLength(1); + expect(c?.draft).toBe('{"x":1}'); + }); + + it('leaves draft undefined when none is given for a new entry', () => { + setCachedGame('g1', view('g1'), []); + expect(getCachedGame('g1')?.draft).toBeUndefined(); + }); + + it('preserves the cached draft when draft is omitted (the live-event delta path)', () => { + setCachedGame('g1', view('g1'), [], 'DRAFT'); + // A delta advances view+moves without refetching the draft; it must not be dropped. + setCachedGame('g1', view('g1', ['C', 'D']), [move(1)]); + const c = getCachedGame('g1'); + expect(c?.draft).toBe('DRAFT'); // preserved + expect(c?.view.rack).toEqual(['C', 'D']); // view advanced + expect(c?.moves).toHaveLength(1); + }); + + it('clears the draft when passed an empty string (a committed move)', () => { + setCachedGame('g1', view('g1'), [], 'DRAFT'); + setCachedGame('g1', view('g1'), [move(0)], ''); + expect(getCachedGame('g1')?.draft).toBe(''); + }); +}); + +describe('setCachedDraft', () => { + it('updates only the draft of an already-cached game', () => { + setCachedGame('g1', view('g1', ['A']), [move(0)], 'OLD'); + setCachedDraft('g1', 'NEW'); + const c = getCachedGame('g1'); + expect(c?.draft).toBe('NEW'); + expect(c?.view.rack).toEqual(['A']); // unchanged + expect(c?.moves).toHaveLength(1); + }); + + it('is a no-op when the game is not cached', () => { + setCachedDraft('absent', 'X'); + expect(getCachedGame('absent')).toBeUndefined(); + }); +}); + +describe('clearGameCache', () => { + it('drops every cached game', () => { + setCachedGame('g1', view('g1'), []); + setCachedGame('g2', view('g2'), []); + clearGameCache(); + expect(getCachedGame('g1')).toBeUndefined(); + expect(getCachedGame('g2')).toBeUndefined(); + }); +}); diff --git a/ui/src/lib/gamecache.ts b/ui/src/lib/gamecache.ts index 03671c8..ef9c3ca 100644 --- a/ui/src/lib/gamecache.ts +++ b/ui/src/lib/gamecache.ts @@ -1,15 +1,21 @@ -// In-memory per-game cache. A game the player has opened once is kept here so a -// later re-entry renders instantly from the cache while a fresh fetch updates it in -// the background — removing the blank "loading" flash and the full redraw on every -// lobby <-> game navigation. It is intentionally process-memory only (no persistence): -// stale entries are corrected by the background refresh, and the cache is cleared on -// logout. +// In-memory per-game cache. A game the player has opened once — or one warmed ahead of time +// by the lobby preload (see lib/preload) — is kept here so opening it renders instantly from +// the cache while a fresh fetch updates it in the background, removing the blank "loading" +// flash and the full redraw on every lobby <-> game navigation. It is intentionally +// process-memory only (no persistence): stale entries are corrected by the background +// refresh, and the cache is cleared on logout. import type { MoveRecord, StateView } from './model'; export interface CachedGame { view: StateView; moves: MoveRecord[]; + /** + * The player's saved draft (composition) as the opaque JSON the server stores, so a warm + * open paints the pending tiles already on the board with no rack→board jump. An empty + * string means no draft; undefined means it has not been fetched for this entry yet. + */ + draft?: string; } const cache = new Map(); @@ -19,9 +25,22 @@ export function getCachedGame(id: string): CachedGame | undefined { return cache.get(id); } -/** setCachedGame stores the latest state+history for a game. */ -export function setCachedGame(id: string, view: StateView, moves: MoveRecord[]): void { - cache.set(id, { view, moves }); +/** + * setCachedGame stores the latest state+history for a game. Omitting draft preserves the + * cached composition: the live-event delta path advances view+moves without refetching the + * draft and must not drop it, while passing an empty string clears it (a committed move does, + * mirroring the server). + */ +export function setCachedGame(id: string, view: StateView, moves: MoveRecord[], draft?: string): void { + const prev = cache.get(id); + cache.set(id, { view, moves, draft: draft ?? prev?.draft }); +} + +/** setCachedDraft updates only the saved draft of an already-cached game (a no-op otherwise), + * keeping the cache coherent with the draft persisted while composing. */ +export function setCachedDraft(id: string, draft: string): void { + const prev = cache.get(id); + if (prev) cache.set(id, { ...prev, draft }); } /** clearGameCache drops every cached game (called on logout). */ diff --git a/ui/src/lib/preload.test.ts b/ui/src/lib/preload.test.ts new file mode 100644 index 0000000..9e4823e --- /dev/null +++ b/ui/src/lib/preload.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { GameView, StateView } from './model'; + +// Mock the gateway singleton so preload's fan-out is observed without a transport. +const mocks = vi.hoisted(() => ({ + gameState: vi.fn(), + gameHistory: vi.fn(), + draftGet: vi.fn(), +})); +vi.mock('./gateway', () => ({ + gateway: { gameState: mocks.gameState, gameHistory: mocks.gameHistory, draftGet: mocks.draftGet }, +})); + +import { preloadGames } from './preload'; +import { clearGameCache, getCachedGame, setCachedGame } from './gamecache'; + +function gameView(id: string, status: GameView['status'] = 'active'): GameView { + return { + id, + variant: 'scrabble_en', + dictVersion: 'v1', + status, + players: 2, + toMove: 0, + turnTimeoutSecs: 300, + multipleWordsPerTurn: true, + moveCount: 0, + endReason: '', + lastActivityUnix: 0, + seats: [], + }; +} + +function stateView(id: string): StateView { + return { game: gameView(id), seat: 0, rack: ['A', 'B'], bagLen: 50, hintsRemaining: 1 }; +} + +beforeEach(() => { + clearGameCache(); + mocks.gameState.mockReset().mockImplementation((id: string) => Promise.resolve(stateView(id))); + mocks.gameHistory.mockReset().mockImplementation((id: string) => Promise.resolve({ gameId: id, moves: [] })); + mocks.draftGet.mockReset().mockImplementation((id: string) => Promise.resolve(id === 'g1' ? 'DRAFT1' : '')); +}); + +describe('preloadGames', () => { + it('warms ongoing, uncached games with state, history and draft', async () => { + await preloadGames([gameView('g1'), gameView('g2')]); + expect(getCachedGame('g1')?.view.game.id).toBe('g1'); + expect(getCachedGame('g1')?.draft).toBe('DRAFT1'); + expect(getCachedGame('g2')?.draft).toBe(''); + }); + + it('skips finished games', async () => { + await preloadGames([gameView('done', 'finished')]); + expect(getCachedGame('done')).toBeUndefined(); + expect(mocks.gameState).not.toHaveBeenCalled(); + }); + + it('skips games already in the cache (kept fresh by the live stream)', async () => { + setCachedGame('g1', stateView('g1'), [], 'KEEP'); + await preloadGames([gameView('g1'), gameView('g2')]); + expect(mocks.gameState).toHaveBeenCalledTimes(1); + expect(mocks.gameState).toHaveBeenCalledWith('g2', expect.any(Boolean)); + expect(getCachedGame('g1')?.draft).toBe('KEEP'); // untouched + }); + + it('does nothing for an empty list', async () => { + await preloadGames([]); + expect(mocks.gameState).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/lib/preload.ts b/ui/src/lib/preload.ts new file mode 100644 index 0000000..5cb3921 --- /dev/null +++ b/ui/src/lib/preload.ts @@ -0,0 +1,50 @@ +// Lobby preload: warm the per-game cache for the player's ongoing games ahead of time, so +// opening one from the lobby renders instantly — no "loading" flash and no rack→board jump +// (the saved draft is fetched and cached alongside state and history). It is best-effort and +// bounded; finished games are skipped (rarely reopened, no draft), as are games already cached +// (the live stream keeps those fresh) or currently being warmed. + +import { gateway } from './gateway'; +import { getCachedGame, setCachedGame } from './gamecache'; +import { hasAlphabet } from './alphabet'; +import type { GameView } from './model'; + +// Cap on games warmed at once: enough to cover a typical lobby quickly without a request burst. +const MAX_CONCURRENT = 4; +// Games being warmed right now, so overlapping calls (the lobby refreshes on every live event) +// never fetch the same game twice. +const inflight = new Set(); + +/** + * preloadGames warms the cache for the given ongoing games: it fetches each game's state, + * history and saved draft in parallel and stores them, skipping finished games and any already + * cached or in flight. Concurrency is bounded and failures are swallowed (the game just + * cold-loads on open). It resolves once every queued game has settled. + */ +export async function preloadGames(games: GameView[]): Promise { + const queue = games.filter( + (g) => g.status !== 'finished' && !getCachedGame(g.id) && !inflight.has(g.id), + ); + if (queue.length === 0) return; + for (const g of queue) inflight.add(g.id); + let next = 0; + const worker = async (): Promise => { + while (next < queue.length) { + const g = queue[next++]; + try { + const [st, hist, draft] = await Promise.all([ + gateway.gameState(g.id, !hasAlphabet(g.variant)), + gateway.gameHistory(g.id), + gateway.draftGet(g.id).catch(() => ''), + ]); + // A live event may have seeded or advanced this entry while we fetched; don't clobber it. + if (!getCachedGame(g.id)) setCachedGame(g.id, st, hist.moves, draft); + } catch { + /* best-effort: leave the game to cold-load on open */ + } finally { + inflight.delete(g.id); + } + } + }; + await Promise.all(Array.from({ length: Math.min(MAX_CONCURRENT, queue.length) }, worker)); +} diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index f005bbc..847f896 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -9,6 +9,7 @@ import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { resultBadge } from '../lib/result'; import { getLobby, setLobby } from '../lib/lobbycache'; + import { preloadGames } from '../lib/preload'; import { groupGames } from '../lib/lobbysort'; import type { AccountRef, GameView, Invitation } from '../lib/model'; @@ -28,6 +29,10 @@ app.notifications = incoming.length; } setLobby({ games, invitations, incoming }); + // Warm the cache for the ongoing games so opening one from the lobby is instant. The list + // just loaded, so the connection is up; the call is non-blocking and re-running it on each + // lobby refresh cheaply warms any newly appeared game (already-cached ones are skipped). + if (connection.online) void preloadGames(games); } catch (e) { handleError(e); }