feat(offline): port DAWG cursor + move generator to TS (parity-pinned)
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
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.
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
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));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user