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_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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user