537a265409
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m28s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
# Conflicts: # ui/src/lib/localgame/source.ts
116 lines
4.9 KiB
TypeScript
116 lines
4.9 KiB
TypeScript
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<LocalSource> {
|
|
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);
|
|
});
|
|
});
|