feat(ui): preload ongoing games and cache the draft for an instant, jump-free game open
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m2s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m2s
Opening a game from the lobby for the first time this session showed a brief loading flash, and every open showed a two-step rack->board jump: the saved draft (pending composition) was fetched separately and applied only after the board had already painted the full rack. Both stem from the full state and the draft not being available synchronously at first paint. Cache the draft alongside view+history (CachedGame.draft), make applyDraft take the already-fetched JSON so it runs synchronously, and fetch the draft in the same Promise.all as state+history. setCachedGame preserves the cached draft when the delta path omits it and clears it on a committed move (mirroring the server). A new preload module warms the per-game cache (state, history, draft) for the lobby's ongoing games with bounded concurrency, so opening any of them is instant. Tests: gamecache (preserve/clear/setCachedDraft) and preload (warm/skip) units; existing draft-restore e2e still green.
This commit is contained in:
+26
-20
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user