diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 47be9db..56ab932 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -419,6 +419,11 @@ // 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 // 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 // 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; $effect(() => { const alive = app.streamAlive; - if (alive && !wasStreamAlive) void load(); + if (alive && !wasStreamAlive && !isLocalGameId(id)) void load(); wasStreamAlive = alive; }); @@ -446,7 +451,7 @@ const r = app.resync; if (r !== lastResync) { lastResync = r; - void load(); + if (!isLocalGameId(id)) void load(); } }); diff --git a/ui/src/lib/gamesource.ts b/ui/src/lib/gamesource.ts index ed87fc6..b95fc57 100644 --- a/ui/src/lib/gamesource.ts +++ b/ui/src/lib/gamesource.ts @@ -24,9 +24,9 @@ function load(): Promise { * local-only create() and the robot-reply event subscription events(). */ export const localSource = { - // Offline scoring is self-contained, so the local source ignores includeAlphabet, the evaluate - // abort signal, and the draft (drafts are not persisted offline); the proxy accepts the full - // gateway signatures but forwards only what the local source uses. + // Offline scoring is self-contained, so the local source ignores includeAlphabet and the evaluate + // abort signal; the proxy accepts the full gateway signatures but forwards only what the local + // source uses. gameState: (id, _includeAlphabet) => load().then((s) => s.gameState(id)), gameHistory: (id) => load().then((s) => s.gameHistory(id)), 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)), 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)), - draftGet: (_id) => load().then((s) => s.draftGet()), - draftSave: (_id, _json) => load().then((s) => s.draftSave()), + draftGet: (id) => load().then((s) => s.draftGet(id)), + draftSave: (id, json) => load().then((s) => s.draftSave(id, json)), create: (opts) => load().then((s) => s.create(opts)), list: () => load().then((s) => s.list()), delete: (id) => load().then((s) => s.delete(id)), diff --git a/ui/src/lib/localgame/serialize.ts b/ui/src/lib/localgame/serialize.ts index d635c66..f8b923e 100644 --- a/ui/src/lib/localgame/serialize.ts +++ b/ui/src/lib/localgame/serialize.ts @@ -53,6 +53,13 @@ export interface LocalGameRecord { * read through a sanitiser (cap at now + window) so a device clock set back cannot freeze it — * see lib/hints. */ 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). */ @@ -64,6 +71,7 @@ export interface RecordMeta { createdAtUnix: number; updatedAtUnix: number; hintUnlockAtMs: number; + draft?: string; } /** @@ -88,6 +96,7 @@ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecor createdAtUnix: meta.createdAtUnix, updatedAtUnix: meta.updatedAtUnix, hintUnlockAtMs: meta.hintUnlockAtMs, + draft: meta.draft, }; } diff --git a/ui/src/lib/localgame/source.test.ts b/ui/src/lib/localgame/source.test.ts index ddf58c8..96c0e8b 100644 --- a/ui/src/lib/localgame/source.test.ts +++ b/ui/src/lib/localgame/source.test.ts @@ -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 () => { const src = await newSource('local:g5', 2024n); src.events('local:g5', () => {}); diff --git a/ui/src/lib/localgame/source.ts b/ui/src/lib/localgame/source.ts index bf6f04a..1ee39fd 100644 --- a/ui/src/lib/localgame/source.ts +++ b/ui/src/lib/localgame/source.ts @@ -195,6 +195,7 @@ export class LocalSource implements GameLoopSource { const seat = entry.game.currentPlayer; const move = entry.game.resign(); entry.unlockedSeat = null; + entry.record.draft = undefined; // the turn is over; the abandoned composition goes with it await this.persist(entry); return this.moveResult(entry, move, seat); } @@ -251,6 +252,7 @@ export class LocalSource implements GameLoopSource { if (action === 'skip') entry.game.pass(); else entry.game.resignSeat(targetSeat ?? entry.game.currentPlayer); entry.unlockedSeat = null; + entry.record.draft = undefined; // the host advanced the turn; the seat's composition is stale await this.persist(entry); return this.stateView(entry); } @@ -282,13 +284,18 @@ export class LocalSource implements GameLoopSource { return { word, legal: game.dictionaryHas(idx) }; } - // Drafts (the in-progress composition) are not persisted for local games — the arrangement is - // rebuilt from the rack on reopen. The screen tolerates an empty draft. - async draftGet(): Promise { - return ''; + // Drafts (the in-progress composition: rack order + pending board tiles) live in the game's own + // record — a local game has no server to hold them. Persisting them keeps every reload of the + // state (the game screen refetches on several triggers) from dropping the arrangement back into + // the rack, and carries it across leaving and reopening the app. + async draftGet(gameId: string): Promise { + const entry = await this.load(gameId); + return entry.record.draft ?? ''; } - async draftSave(): Promise { - /* no-op offline */ + async draftSave(gameId: string, json: string): Promise { + 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 @@ -325,6 +332,7 @@ export class LocalSource implements GameLoopSource { const seat = entry.game.currentPlayer; const move = act(entry.game); 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); await this.persist(entry); const result = this.moveResult(entry, move, seat); @@ -378,6 +386,7 @@ export class LocalSource implements GameLoopSource { createdAtUnix: entry.record.createdAtUnix, updatedAtUnix: nowUnix(), hintUnlockAtMs: entry.record.hintUnlockAtMs, + draft: entry.record.draft, }); await saveLocalGame(entry.record); }