fix(offline): keep the local game's composition across a state reload

A local (offline) game persisted no draft: draftGet returned an empty
string and draftSave was a no-op, on the assumption that the arrangement
is only ever rebuilt from the rack when the game is reopened. The game
screen, however, refetches the state on several triggers, and every one
of them applied that empty draft over the live composition — dropping the
player's pending tiles back into the rack and resetting the rack order.

Offline the most visible trigger fires on its own: with no network the
live-stream watchdog declares the stream dead after 25 s, the reconnect
4 s later flips app.streamAlive back to true, and the game screen's
reconnect effect refetches. Composing a move offline was therefore reset
roughly every half minute.

Store the draft in the game's own record instead, so a reload restores it
and it survives leaving and reopening the app, and clear it wherever the
turn advances (a committed move, a resignation, a host skip) so a hotseat
seat never inherits the previous player's arrangement. Gate the two
stream-driven refetches on the game being a network one: a local game
takes its events from the source, so reacting to the stream only cost it
a pointless reload.
This commit is contained in:
Ilia Denisov
2026-07-27 18:12:09 +02:00
parent 6ec557d33f
commit ebd1f05da8
5 changed files with 52 additions and 13 deletions
+7 -2
View File
@@ -419,6 +419,11 @@
// opponent_moved and game_over self-heal via the next event's move-count gap check, but // opponent_moved and game_over self-heal via the next event's move-count gap check, but
// opponent_joined has no follow-up event, so the open-game wait needs its own recovery. Two // opponent_joined has no follow-up event, so the open-game wait needs its own recovery. Two
// fallbacks, mirroring the matchmaking poll PR #51 moved in here from the lobby: // fallbacks, mirroring the matchmaking poll PR #51 moved in here from the lobby:
//
// All three recover from the live stream, which a local (offline) game does not use at all — its
// events come from the source itself (see onMount). Reacting to the stream there would refetch
// the state for no reason, so (A) and (C) are gated on the game being a network one; (B) needs no
// gate, as a local game is never 'open' and so never waits for an opponent.
// (A) On a stream reconnect (alive false -> true) refetch once to catch up on anything missed // (A) On a stream reconnect (alive false -> true) refetch once to catch up on anything missed
// while it was down. The common case is a mobile suspend that drops the stream, the opponent // while it was down. The common case is a mobile suspend that drops the stream, the opponent
@@ -426,7 +431,7 @@
let wasStreamAlive = app.streamAlive; let wasStreamAlive = app.streamAlive;
$effect(() => { $effect(() => {
const alive = app.streamAlive; const alive = app.streamAlive;
if (alive && !wasStreamAlive) void load(); if (alive && !wasStreamAlive && !isLocalGameId(id)) void load();
wasStreamAlive = alive; wasStreamAlive = alive;
}); });
@@ -446,7 +451,7 @@
const r = app.resync; const r = app.resync;
if (r !== lastResync) { if (r !== lastResync) {
lastResync = r; lastResync = r;
void load(); if (!isLocalGameId(id)) void load();
} }
}); });
+5 -5
View File
@@ -24,9 +24,9 @@ function load(): Promise<LocalSource> {
* local-only create() and the robot-reply event subscription events(). * local-only create() and the robot-reply event subscription events().
*/ */
export const localSource = { export const localSource = {
// Offline scoring is self-contained, so the local source ignores includeAlphabet, the evaluate // Offline scoring is self-contained, so the local source ignores includeAlphabet and the evaluate
// abort signal, and the draft (drafts are not persisted offline); the proxy accepts the full // abort signal; the proxy accepts the full gateway signatures but forwards only what the local
// gateway signatures but forwards only what the local source uses. // source uses.
gameState: (id, _includeAlphabet) => load().then((s) => s.gameState(id)), gameState: (id, _includeAlphabet) => load().then((s) => s.gameState(id)),
gameHistory: (id) => load().then((s) => s.gameHistory(id)), gameHistory: (id) => load().then((s) => s.gameHistory(id)),
submitPlay: (id, tiles, variant) => load().then((s) => s.submitPlay(id, tiles, variant)), submitPlay: (id, tiles, variant) => load().then((s) => s.submitPlay(id, tiles, variant)),
@@ -36,8 +36,8 @@ export const localSource = {
hint: (id) => load().then((s) => s.hint(id)), hint: (id) => load().then((s) => s.hint(id)),
evaluate: (id, tiles, variant, _signal) => load().then((s) => s.evaluate(id, tiles, variant)), evaluate: (id, tiles, variant, _signal) => load().then((s) => s.evaluate(id, tiles, variant)),
checkWord: (id, word, variant) => load().then((s) => s.checkWord(id, word, variant)), checkWord: (id, word, variant) => load().then((s) => s.checkWord(id, word, variant)),
draftGet: (_id) => load().then((s) => s.draftGet()), draftGet: (id) => load().then((s) => s.draftGet(id)),
draftSave: (_id, _json) => load().then((s) => s.draftSave()), draftSave: (id, json) => load().then((s) => s.draftSave(id, json)),
create: (opts) => load().then((s) => s.create(opts)), create: (opts) => load().then((s) => s.create(opts)),
list: () => load().then((s) => s.list()), list: () => load().then((s) => s.list()),
delete: (id) => load().then((s) => s.delete(id)), delete: (id) => load().then((s) => s.delete(id)),
+9
View File
@@ -53,6 +53,13 @@ export interface LocalGameRecord {
* read through a sanitiser (cap at now + window) so a device clock set back cannot freeze it — * read through a sanitiser (cap at now + window) so a device clock set back cannot freeze it —
* see lib/hints. */ * see lib/hints. */
hintUnlockAtMs: number; hintUnlockAtMs: number;
/** The seated player's in-progress composition (rack order + pending board tiles) as the draft
* JSON the game screen serialises, or absent when there is none. A local game keeps it here —
* there is no server to hold it — so a reload of the game state restores the arrangement instead
* of dropping the tiles back into the rack. It belongs to the turn: the source clears it wherever
* the turn advances (a committed move, a resignation, a host skip), so a hotseat seat never
* inherits the previous player's arrangement. */
draft?: string;
} }
/** The record fields the caller owns (the engine supplies the rest). */ /** The record fields the caller owns (the engine supplies the rest). */
@@ -64,6 +71,7 @@ export interface RecordMeta {
createdAtUnix: number; createdAtUnix: number;
updatedAtUnix: number; updatedAtUnix: number;
hintUnlockAtMs: number; hintUnlockAtMs: number;
draft?: string;
} }
/** /**
@@ -88,6 +96,7 @@ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecor
createdAtUnix: meta.createdAtUnix, createdAtUnix: meta.createdAtUnix,
updatedAtUnix: meta.updatedAtUnix, updatedAtUnix: meta.updatedAtUnix,
hintUnlockAtMs: meta.hintUnlockAtMs, hintUnlockAtMs: meta.hintUnlockAtMs,
draft: meta.draft,
}; };
} }
+16
View File
@@ -97,6 +97,22 @@ describe('LocalSource', () => {
} }
}); });
it('persists the draft, so reloading the state keeps the composition', async () => {
// The game screen refetches the state on several triggers (a stream reconnect, a resume). With
// no stored draft every one of those wiped the player's arrangement back into the rack.
const src = await newSource('local:gDraft', 42n);
expect(await src.draftGet('local:gDraft')).toBe('');
await src.draftSave('local:gDraft', '{"rackOrder":[3,1,0,2,4,5,6],"tiles":[]}');
expect(await src.draftGet('local:gDraft')).toBe('{"rackOrder":[3,1,0,2,4,5,6],"tiles":[]}');
});
it('drops the draft when the turn advances, so a hotseat seat never inherits it', async () => {
const src = await newSource('local:gDraft2', 42n);
await src.draftSave('local:gDraft2', '{"rackOrder":[3,1,0,2,4,5,6],"tiles":[]}');
await src.pass('local:gDraft2');
expect(await src.draftGet('local:gDraft2')).toBe('');
});
it('drives a whole local game to completion', async () => { it('drives a whole local game to completion', async () => {
const src = await newSource('local:g5', 2024n); const src = await newSource('local:g5', 2024n);
src.events('local:g5', () => {}); src.events('local:g5', () => {});
+15 -6
View File
@@ -195,6 +195,7 @@ export class LocalSource implements GameLoopSource {
const seat = entry.game.currentPlayer; const seat = entry.game.currentPlayer;
const move = entry.game.resign(); const move = entry.game.resign();
entry.unlockedSeat = null; entry.unlockedSeat = null;
entry.record.draft = undefined; // the turn is over; the abandoned composition goes with it
await this.persist(entry); await this.persist(entry);
return this.moveResult(entry, move, seat); return this.moveResult(entry, move, seat);
} }
@@ -251,6 +252,7 @@ export class LocalSource implements GameLoopSource {
if (action === 'skip') entry.game.pass(); if (action === 'skip') entry.game.pass();
else entry.game.resignSeat(targetSeat ?? entry.game.currentPlayer); else entry.game.resignSeat(targetSeat ?? entry.game.currentPlayer);
entry.unlockedSeat = null; entry.unlockedSeat = null;
entry.record.draft = undefined; // the host advanced the turn; the seat's composition is stale
await this.persist(entry); await this.persist(entry);
return this.stateView(entry); return this.stateView(entry);
} }
@@ -282,13 +284,18 @@ export class LocalSource implements GameLoopSource {
return { word, legal: game.dictionaryHas(idx) }; return { word, legal: game.dictionaryHas(idx) };
} }
// Drafts (the in-progress composition) are not persisted for local games — the arrangement is // Drafts (the in-progress composition: rack order + pending board tiles) live in the game's own
// rebuilt from the rack on reopen. The screen tolerates an empty draft. // record — a local game has no server to hold them. Persisting them keeps every reload of the
async draftGet(): Promise<string> { // state (the game screen refetches on several triggers) from dropping the arrangement back into
return ''; // the rack, and carries it across leaving and reopening the app.
async draftGet(gameId: string): Promise<string> {
const entry = await this.load(gameId);
return entry.record.draft ?? '';
} }
async draftSave(): Promise<void> { async draftSave(gameId: string, json: string): Promise<void> {
/* no-op offline */ const entry = await this.load(gameId);
entry.record.draft = json || undefined;
await this.persist(entry);
} }
/** events subscribes to a local game's push events (the robot's reply, game over). Returns an /** events subscribes to a local game's push events (the robot's reply, game over). Returns an
@@ -325,6 +332,7 @@ export class LocalSource implements GameLoopSource {
const seat = entry.game.currentPlayer; const seat = entry.game.currentPlayer;
const move = act(entry.game); const move = act(entry.game);
entry.unlockedSeat = null; // the turn has advanced; a hotseat seat re-locks (no effect for vs_ai) entry.unlockedSeat = null; // the turn has advanced; a hotseat seat re-locks (no effect for vs_ai)
entry.record.draft = undefined; // the composition was committed (or swapped away) with the turn
const robotMoves = this.runRobots(entry); const robotMoves = this.runRobots(entry);
await this.persist(entry); await this.persist(entry);
const result = this.moveResult(entry, move, seat); const result = this.moveResult(entry, move, seat);
@@ -378,6 +386,7 @@ export class LocalSource implements GameLoopSource {
createdAtUnix: entry.record.createdAtUnix, createdAtUnix: entry.record.createdAtUnix,
updatedAtUnix: nowUnix(), updatedAtUnix: nowUnix(),
hintUnlockAtMs: entry.record.hintUnlockAtMs, hintUnlockAtMs: entry.record.hintUnlockAtMs,
draft: entry.record.draft,
}); });
await saveLocalGame(entry.record); await saveLocalGame(entry.record);
} }