fix(offline): keep the local composition; feat(rules): no repeated words in Erudit #287
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user