32298595f2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
A GatewayClient-shaped facade over the offline engine, so the same game screen can drive a local vs_ai game with no backend (the wiring into Game.svelte is B3.2). - source.ts: LocalSource implements the game-loop subset (GameLoopSource) for a local game id — gameState/gameHistory via replay, submitPlay/pass/exchange/resign apply the human move then run the robot (decide(generateMoves)) synchronously, persisting both and delivering the robot's move through a per-game event emitter (no live stream). hint is gated to >30 min since the robot's last move; evaluate/checkWord are local. It translates the UI's glyph space to the engine's index space with the static letters table. - ruleset.ts: add the static per-variant letters (glyphs), pinned to the Go alphabet — offline is now fully self-contained (no reliance on a warm server alphabet cache). - engine.ts: submitPlay (infers the direction like the server SubmitPlay), evaluatePlay + dictionaryHas for the move preview / word check, and record the main-word coordinate + the words on a play (for the history MoveRecord). - source.test.ts: create -> human pass -> synchronous robot reply via the event, the hint gate, decoded history, and a whole game driven to completion. Pure additive library code; no runtime behavior change (bundle unchanged).
97 lines
3.8 KiB
TypeScript
97 lines
3.8 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';
|
|
|
|
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('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('gates the hint until 30 minutes since the robot last moved', async () => {
|
|
const src = await newSource('local:g3', 7n);
|
|
await expect(src.hint('local:g3')).rejects.toMatchObject({ code: 'hint_not_ready' });
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|