fix(offline): keep the local composition; feat(rules): no repeated words in Erudit #287

Merged
developer merged 6 commits from feature/no-repeat-words into development 2026-07-27 17:46:35 +00:00
5 changed files with 52 additions and 13 deletions
Showing only changes of commit ebd1f05da8 - Show all commits
+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_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();
}
});
+5 -5
View File
@@ -24,9 +24,9 @@ function load(): Promise<LocalSource> {
* 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)),
+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 —
* 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,
};
}
+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 () => {
const src = await newSource('local:g5', 2024n);
src.events('local:g5', () => {});
+15 -6
View File
@@ -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<string> {
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<string> {
const entry = await this.load(gameId);
return entry.record.draft ?? '';
}
async draftSave(): Promise<void> {
/* no-op offline */
async draftSave(gameId: string, json: string): Promise<void> {
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);
}