c334a9d7b7
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m38s
First engine-first step of PWA offline mode (Phase A): the client-side move generator — the "robot brain" a local vs_ai game will run on-device — with no runtime wiring yet (Phase B). - dawg.ts: add the step-by-step cursor (root/final/next/arcs), a faithful port of dafsa traverse.go over the reader's existing bitstream. - generate.ts: the Appel-Jacobson generator (leftPart/extendRight + cross-sets + counts-rack + board transpose + moveKey ranking), reusing the cursor and validate.ts evaluate/connected. A cross-set LetterSet is a Uint8Array, so the 33-letter Russian alphabet (index 32) is exact under JS bit ops. - validate.ts: export connected for the generator's connectivity filter. - backend/cmd/movegen: dev tool building small sample dictionaries and emitting golden move-generation fixtures from the real Go solver (EN + RU). - tests: dawg.cursor.test.ts (enumeration bijection vs indexOf) and generate.parity.test.ts (7/7 vs the Go solver: empty board, mid-game, blank, single-word rule, Russian index-32 cross-set). The committed EN sample also unblocks the existing skipped dawg.parity.test.ts once wired with DICT_* in CI. Pure additive library code; no runtime behavior change.
100 lines
3.2 KiB
TypeScript
100 lines
3.2 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { readFileSync } from 'node:fs';
|
|
import { Dawg } from './dawg';
|
|
import { generateMoves, GenRack, type GenBoard, type Mode } from './generate';
|
|
import type { Ruleset } from './validate';
|
|
|
|
// Conformance gate for the ported move generator: for each committed position it must
|
|
// return exactly the ranked play list the real Go solver returns (backend/cmd/movegen).
|
|
// The Russian sample reaches alphabet index 32, exercising the 33-letter cross-set.
|
|
|
|
interface Tile {
|
|
row: number;
|
|
col: number;
|
|
letter: number;
|
|
blank: boolean;
|
|
}
|
|
interface GenMove {
|
|
dir: number;
|
|
tiles: Tile[];
|
|
score: number;
|
|
}
|
|
interface Fixture {
|
|
ruleset: {
|
|
size: number;
|
|
cols: number;
|
|
center: number;
|
|
rackSize: number;
|
|
bingo: number;
|
|
values: number[];
|
|
letterMult: number[][];
|
|
wordMult: number[][];
|
|
};
|
|
cases: {
|
|
name: string;
|
|
placed: Tile[] | null;
|
|
rack: { letters: number[]; blanks: number };
|
|
mode: number;
|
|
ignoreCrossWords: boolean;
|
|
moves: GenMove[];
|
|
}[];
|
|
}
|
|
|
|
function load(tag: string): { dawg: Dawg; fx: Fixture } {
|
|
const bytes = new Uint8Array(readFileSync(new URL(`./testdata/sample_${tag}.dawg`, import.meta.url)));
|
|
const fx = JSON.parse(
|
|
readFileSync(new URL(`./testdata/sample_${tag}.gen.json`, import.meta.url), 'utf8'),
|
|
) as Fixture;
|
|
return { dawg: new Dawg(bytes), fx };
|
|
}
|
|
|
|
function buildBoard(placed: Tile[], cols: number): GenBoard {
|
|
const grid: ({ letter: number; blank: boolean } | null)[] = new Array(cols * cols).fill(null);
|
|
for (const t of placed) grid[t.row * cols + t.col] = { letter: t.letter, blank: t.blank };
|
|
const inBounds = (r: number, c: number): boolean => r >= 0 && r < cols && c >= 0 && c < cols;
|
|
return {
|
|
rows: cols,
|
|
cols,
|
|
inBounds,
|
|
filled: (r, c) => inBounds(r, c) && grid[r * cols + c] !== null,
|
|
cellAt: (r, c) => grid[r * cols + c]!,
|
|
isEmpty: () => placed.length === 0,
|
|
};
|
|
}
|
|
|
|
function rulesetFor(fx: Fixture, ignoreCrossWords: boolean): Ruleset {
|
|
const r = fx.ruleset;
|
|
return {
|
|
cols: r.cols,
|
|
center: r.center,
|
|
rackSize: r.rackSize,
|
|
bingo: r.bingo,
|
|
values: r.values,
|
|
letterMult: (row, col) => r.letterMult[row][col],
|
|
wordMult: (row, col) => r.wordMult[row][col],
|
|
ignoreCrossWords,
|
|
};
|
|
}
|
|
|
|
// sig is an order-stable signature of a move: orientation, score and its placed tiles
|
|
// sorted by square — so two lists compare equal iff they rank the same plays the same way.
|
|
function sig(m: GenMove): string {
|
|
const ts = m.tiles.slice().sort((a, b) => (a.row !== b.row ? a.row - b.row : a.col - b.col));
|
|
return `${m.dir}#${m.score}#` + ts.map((t) => `${t.row},${t.col},${t.letter}${t.blank ? '*' : ''}`).join(';');
|
|
}
|
|
|
|
for (const tag of ['en', 'ru']) {
|
|
describe(`move generator parity vs Go solver (${tag})`, () => {
|
|
const { dawg, fx } = load(tag);
|
|
for (const c of fx.cases) {
|
|
it(c.name, () => {
|
|
const board = buildBoard(c.placed ?? [], fx.ruleset.cols);
|
|
const rack = GenRack.from(fx.ruleset.size, c.rack.letters, c.rack.blanks);
|
|
const rs = rulesetFor(fx, c.ignoreCrossWords);
|
|
const got = generateMoves(dawg, board, rack, rs, c.mode as Mode);
|
|
expect(got.map(sig)).toEqual(c.moves.map(sig));
|
|
});
|
|
}
|
|
});
|
|
}
|