feat(ui): мгновенное открытие партии — предзагрузка идущих партий + кэш черновика без «прыжка» #58
@@ -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**
|
||||
|
||||
+22
-16
@@ -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,29 +159,29 @@
|
||||
}
|
||||
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));
|
||||
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]);
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
rackIds = order;
|
||||
const rack = order.map((i) => st.rack[i]);
|
||||
placement = tiles.length ? placementFromHint(tiles, rack) : newPlacement(rack);
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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). */
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user