feat(ui): мгновенное открытие партии — предзагрузка идущих партий + кэш черновика без «прыжка» #58

Merged
developer merged 1 commits from feature/instant-game-open into development 2026-06-14 16:08:20 +00:00
7 changed files with 271 additions and 30 deletions
+4 -1
View File
@@ -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**
+26 -20
View File
@@ -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<typeof setTimeout> | 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);
+87
View File
@@ -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();
});
});
+28 -9
View File
@@ -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<string, CachedGame>();
@@ -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). */
+71
View File
@@ -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();
});
});
+50
View File
@@ -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<string>();
/**
* 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<void> {
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<void> => {
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));
}
+5
View File
@@ -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);
}