feat(offline): offline lobby lists + creates local vs_ai games
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
In offline mode the lobby now shows only the device-local games and its New-vs-AI entry creates one through the in-browser engine — the visible payoff of the offline mode. - LocalSource.list() reconstructs a lobby GameView per stored local game by replay, exposed through the lazy gamesource proxy; unit-tested via an in-memory store. - Lobby.load() branches on offlineMode: lists local games and skips every gateway call (no online games/invitations/incoming); the Stats tab is disabled offline. - NewGame offline: find() creates a device-local vs_ai game via LocalSource.create using the profile's advertised dict version + a local seed; the friends flow and the random-opponent option are hidden, and the variant picker / Start are enabled offline (were gated on connection). - id.ts: newLocalGameId + randomSeed (tested). Docs: ARCHITECTURE + FUNCTIONAL(+ru) offline-mode section. Deferred to fast-follow: the Settings Friends/Profile/Feedback affordance gating, the flip-to-offline readiness wait, the offline mock e2e (needs mock-dawg support), and the local-hint UI. The offline flow is verified on the test contour — the mock e2e cannot enter offline mode (the toggle is gated to an installed PWA).
This commit is contained in:
@@ -39,6 +39,7 @@ export const localSource = {
|
||||
draftGet: (_id) => load().then((s) => s.draftGet()),
|
||||
draftSave: (_id, _json) => load().then((s) => s.draftSave()),
|
||||
create: (opts) => load().then((s) => s.create(opts)),
|
||||
list: () => load().then((s) => s.list()),
|
||||
// events must return the unsubscribe synchronously (the screen subscribes in onMount), so it wires
|
||||
// the real subscription once the engine loads and, until then, cancels a pending subscribe.
|
||||
events: (id, onEvent) => {
|
||||
@@ -52,7 +53,7 @@ export const localSource = {
|
||||
unsub();
|
||||
};
|
||||
},
|
||||
} satisfies GameLoopSource & Pick<LocalSource, 'events' | 'create'>;
|
||||
} satisfies GameLoopSource & Pick<LocalSource, 'events' | 'create' | 'list'>;
|
||||
|
||||
/**
|
||||
* gameSource returns the source that runs the game with the given id: the local engine for a local
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LOCAL_ID_PREFIX, isLocalGameId, newLocalGameId, randomSeed } from './id';
|
||||
|
||||
describe('local game id helpers', () => {
|
||||
it('recognises local ids by prefix', () => {
|
||||
expect(isLocalGameId(LOCAL_ID_PREFIX + 'x')).toBe(true);
|
||||
expect(isLocalGameId('deadbeef')).toBe(false);
|
||||
});
|
||||
|
||||
it('mints unique local ids', () => {
|
||||
const a = newLocalGameId();
|
||||
const b = newLocalGameId();
|
||||
expect(isLocalGameId(a)).toBe(true);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('produces a bigint seed in the 32-bit range', () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const s = randomSeed();
|
||||
expect(typeof s).toBe('bigint');
|
||||
expect(s >= 0n && s <= 0xffffffffn).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,3 +10,14 @@ export const LOCAL_ID_PREFIX = 'local:';
|
||||
export function isLocalGameId(id: string): boolean {
|
||||
return id.startsWith(LOCAL_ID_PREFIX);
|
||||
}
|
||||
|
||||
/** newLocalGameId mints a fresh, device-unique local game id (time + random suffix — no crypto API,
|
||||
* so it also works on the older engines the SPA still supports). */
|
||||
export function newLocalGameId(): string {
|
||||
return `${LOCAL_ID_PREFIX}${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
/** randomSeed returns a random 32-bit seed for a new local game's deterministic bag. */
|
||||
export function randomSeed(): bigint {
|
||||
return BigInt(Math.floor(Math.random() * 0xffffffff));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { LocalGameRecord } from './serialize';
|
||||
|
||||
// An in-memory stand-in for the IndexedDB store, so list() — which reads the *persisted* records,
|
||||
// not the source's in-memory cache — can be exercised in the node env (the real store is a
|
||||
// best-effort no-op without IndexedDB). Declared before the vi.mock factory that closes over it.
|
||||
const store = new Map<string, LocalGameRecord>();
|
||||
vi.mock('./store', () => ({
|
||||
saveLocalGame: async (r: LocalGameRecord) => void store.set(r.id, r),
|
||||
getLocalGame: async (id: string) => store.get(id) ?? null,
|
||||
listLocalGames: async () => [...store.values()],
|
||||
deleteLocalGame: async (id: string) => void store.delete(id),
|
||||
}));
|
||||
vi.mock('../dict', () => ({
|
||||
getDawg: async () => {
|
||||
const { Dawg } = await import('../dict/dawg');
|
||||
const { readFileSync } = await import('node:fs');
|
||||
return new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url))));
|
||||
},
|
||||
}));
|
||||
|
||||
import { LocalSource } from './source';
|
||||
import type { Seat } from './serialize';
|
||||
|
||||
const seats: Seat[] = [
|
||||
{ kind: 'human', name: 'You' },
|
||||
{ kind: 'robot', name: 'Robot' },
|
||||
];
|
||||
const opts = (id: string, seed: bigint) => ({
|
||||
id,
|
||||
variant: 'scrabble_en' as const,
|
||||
dictVersion: 'sample',
|
||||
seed,
|
||||
multipleWords: true,
|
||||
seats,
|
||||
});
|
||||
|
||||
beforeEach(() => store.clear());
|
||||
|
||||
describe('LocalSource.list', () => {
|
||||
it('is empty when no games are stored', async () => {
|
||||
expect(await new LocalSource().list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns a lobby GameView for every stored local game', async () => {
|
||||
const src = new LocalSource();
|
||||
await src.create(opts('local:a', 1n));
|
||||
await src.create(opts('local:b', 2n));
|
||||
const list = await src.list();
|
||||
expect(new Set(list.map((g) => g.id))).toEqual(new Set(['local:a', 'local:b']));
|
||||
expect(list.every((g) => g.vsAi && g.status === 'active')).toBe(true);
|
||||
});
|
||||
|
||||
it('reconstructs a game persisted by a previous session (not in the source cache)', async () => {
|
||||
// Seed the store via one source, then list from a fresh source whose in-memory cache is empty.
|
||||
await new LocalSource().create(opts('local:old', 7n));
|
||||
const list = await new LocalSource().list();
|
||||
expect(list.map((g) => g.id)).toEqual(['local:old']);
|
||||
expect(list[0].variant).toBe('scrabble_en');
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import { LocalGame, type LocalMove } from './engine';
|
||||
import { serializeGame, replayGame, type LocalGameRecord, type Seat as LocalSeat } from './serialize';
|
||||
import { getLocalGame, saveLocalGame } from './store';
|
||||
import { getLocalGame, saveLocalGame, listLocalGames } from './store';
|
||||
import { RULESETS } from './ruleset';
|
||||
import { getDawg } from '../dict';
|
||||
import { LOCAL_ID_PREFIX, isLocalGameId } from './id';
|
||||
@@ -131,6 +131,26 @@ export class LocalSource implements GameLoopSource {
|
||||
return this.stateView(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* list returns a lobby GameView for every stored local game, most-recently-updated first, so the
|
||||
* offline lobby renders them with the same card machinery as the online games. Each is
|
||||
* reconstructed by replay; a record whose dictionary is unavailable (e.g. offline without its
|
||||
* dawg) is skipped, since it cannot be rendered — never throws.
|
||||
*/
|
||||
async list(): Promise<GameView[]> {
|
||||
const records = await listLocalGames();
|
||||
records.sort((a, b) => b.updatedAtUnix - a.updatedAtUnix);
|
||||
const views: GameView[] = [];
|
||||
for (const record of records) {
|
||||
try {
|
||||
views.push(this.gameView(await this.load(record.id)));
|
||||
} catch {
|
||||
/* a record that cannot be replayed (its dawg is not cached offline) is left out */
|
||||
}
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
async gameState(gameId: string): Promise<StateView> {
|
||||
return this.stateView(await this.load(gameId));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user