import { describe, it, expect, vi } from 'vitest'; import type { PushEvent } from '../model'; // getDawg is mocked to the committed sample dictionary (the store's IndexedDB is absent under node, // so games live in the source's in-memory cache — created once, then driven without reload). 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, isLocalGameId } from './source'; import type { Seat } from './serialize'; import { hasAlphabet, alphabetValues } from '../alphabet'; const seats: Seat[] = [ { kind: 'human', name: 'You' }, { kind: 'robot', name: 'Robot' }, ]; async function newSource(id: string, seed: bigint): Promise { const src = new LocalSource(); await src.create({ id, variant: 'scrabble_en', dictVersion: 'sample', seed, multipleWords: true, seats }); return src; } describe('LocalSource', () => { it('detects local game ids', () => { expect(isLocalGameId('local:abc')).toBe(true); expect(isLocalGameId('deadbeef')).toBe(false); }); it('creates a vs_ai game with the human to move and a full rack', async () => { const src = await newSource('local:g1', 42n); const st = await src.gameState('local:g1'); expect(st.game.vsAi).toBe(true); expect(st.game.status).toBe('active'); expect(st.seat).toBe(0); expect(st.rack.length).toBe(7); expect(st.game.seats.map((s) => s.displayName)).toEqual(['You', 'Robot']); expect(st.game.toMove).toBe(0); }); it('seeds the per-variant alphabet from the ruleset, so offline tiles show real weights not zeros', async () => { // Offline there is no server to send the alphabet table the rack/board render from; opening a // local game must seed it from the static ruleset. Without this a cold offline boot draws zeros. const src = await newSource('local:gAlpha', 42n); await src.gameState('local:gAlpha'); expect(hasAlphabet('scrabble_en')).toBe(true); const values = alphabetValues('scrabble_en'); expect(values.length).toBe(26); expect(values[0]).toBe(1); // A = 1 (pinned to rules.go / ruleset.ts), not 0 expect(values.every((v) => v > 0)).toBe(true); }); it('replies to a human pass with a synchronous robot move, delivered via the event', async () => { const src = await newSource('local:g2', 999n); const events: PushEvent[] = []; src.events('local:g2', (e) => events.push(e)); const res = await src.pass('local:g2'); expect(res.move.action).toBe('pass'); expect(res.move.player).toBe(0); // The robot has already played (synchronously) — the state reflects both moves... const mid = await src.gameState('local:g2'); expect(mid.game.moveCount).toBeGreaterThanOrEqual(2); // ...and its move is delivered to subscribers via the per-game event. await Promise.resolve(); expect(events.some((e) => e.kind === 'opponent_moved')).toBe(true); }); it('does not wall-clock-gate the hint (the idle gate is client-side and monotonic)', async () => { const src = await newSource('local:g3', 7n); // hint() serves the engine's top move (or no_hint when the tiny sample dictionary has none for // this rack), but never the old timestamp gate 'hint_not_ready' — that moved to the client // (lib/hints, measured with performance.now()) so a device clock change cannot defeat it. await src.hint('local:g3').then( (h) => expect(h.move).toBeDefined(), (e) => expect((e as { code: string }).code).toBe('no_hint'), ); }); it('records decoded moves in the history', async () => { const src = await newSource('local:g4', 55n); await src.pass('local:g4'); const h = await src.gameHistory('local:g4'); expect(h.moves.length).toBeGreaterThanOrEqual(2); expect(h.moves[0].action).toBe('pass'); // the robot's reply, whatever it is, is decoded (a play carries glyph tiles + lower-case words) const play = h.moves.find((m) => m.action === 'play'); if (play) { expect(play.tiles.every((t) => typeof t.letter === 'string' && t.letter.length >= 1)).toBe(true); expect(play.words.every((w) => w === w.toLowerCase())).toBe(true); } }); it('drives a whole local game to completion', async () => { const src = await newSource('local:g5', 2024n); src.events('local:g5', () => {}); let turns = 0; while ((await src.gameState('local:g5')).game.status === 'active' && turns < 500) { await src.pass('local:g5'); await Promise.resolve(); turns++; } const st = await src.gameState('local:g5'); expect(st.game.status).toBe('finished'); expect(turns).toBeLessThan(500); const winners = st.game.seats.filter((s) => s.isWinner); expect(winners.length).toBeLessThanOrEqual(1); }); });