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,418 @@
|
||||
// Local move generator: every legal play for a rack on a board, ranked by
|
||||
// descending score. Ported from the scrabble-solver engine — the Appel-Jacobson
|
||||
// two-phase algorithm (LeftPart then ExtendRight) over a plain left-to-right DAWG
|
||||
// (scrabble/gen_dawg.go, gen.go, crossset.go, solver.go, key.go). It walks the DAWG
|
||||
// with the cursor from dawg.ts and scores each play with evaluate() from validate.ts,
|
||||
// so the whole robot brain runs on-device. Faithfulness to the Go solver is pinned by
|
||||
// generate.parity.test.ts against golden fixtures (backend/cmd/movegen).
|
||||
//
|
||||
// Everything works in alphabet-index space, mirroring the Go engine. A letterSet is
|
||||
// a per-square cross-set (the letters that form a legal perpendicular word there);
|
||||
// it is a boolean membership array rather than a uint64, so alphabet indexes past
|
||||
// JS's 31-bit shift boundary (Russian has 33 letters) are handled exactly.
|
||||
|
||||
import { Dawg } from './dawg';
|
||||
import {
|
||||
evaluate,
|
||||
connected,
|
||||
Horizontal,
|
||||
Vertical,
|
||||
type Board,
|
||||
type Ruleset,
|
||||
type Move,
|
||||
type Placement,
|
||||
type Direction,
|
||||
} from './validate';
|
||||
|
||||
/** Both generates across plays (on the board) and down plays (on its transpose). */
|
||||
export const Both = 0;
|
||||
/** OnlyHorizontal generates across plays only. */
|
||||
export const OnlyHorizontal = 1;
|
||||
/** OnlyVertical generates down plays only (Эрудит plays a single orientation per turn). */
|
||||
export const OnlyVertical = 2;
|
||||
export type Mode = typeof Both | typeof OnlyHorizontal | typeof OnlyVertical;
|
||||
|
||||
function modeIncludes(mode: Mode, dir: Direction): boolean {
|
||||
if (mode === Both) return true;
|
||||
if (mode === OnlyHorizontal) return dir === Horizontal;
|
||||
return dir === Vertical;
|
||||
}
|
||||
|
||||
/**
|
||||
* GenBoard is the board view the generator needs: the validator's read view plus the
|
||||
* dimensions it iterates over. The whole board is square, so a transposed view (below)
|
||||
* satisfies the same shape.
|
||||
*/
|
||||
export interface GenBoard extends Board {
|
||||
rows: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* GenRack is a rack as per-letter tile counts plus a blank slot, mirroring
|
||||
* scrabble-solver/rack. The generator mutates a single rack in place — removing a
|
||||
* tile, recursing, putting it back — so the operations are O(1) and allocation-free.
|
||||
*/
|
||||
export class GenRack {
|
||||
private readonly counts: Int32Array;
|
||||
|
||||
/** Construct an empty rack for an alphabet of the given size (a trailing blank slot). */
|
||||
constructor(size: number) {
|
||||
this.counts = new Int32Array(size + 1);
|
||||
}
|
||||
|
||||
/** from builds a rack from a multiset of letter indexes plus a blank count. */
|
||||
static from(size: number, letters: readonly number[], blanks: number): GenRack {
|
||||
const r = new GenRack(size);
|
||||
for (const l of letters) r.counts[l]++;
|
||||
r.counts[size] += blanks;
|
||||
return r;
|
||||
}
|
||||
|
||||
/** has reports whether at least one tile of the letter index is on the rack. */
|
||||
has(letter: number): boolean {
|
||||
return this.counts[letter] > 0;
|
||||
}
|
||||
/** blanks returns how many blank tiles are on the rack. */
|
||||
blanks(): number {
|
||||
return this.counts[this.counts.length - 1];
|
||||
}
|
||||
remove(letter: number): void {
|
||||
this.counts[letter]--;
|
||||
}
|
||||
add(letter: number): void {
|
||||
this.counts[letter]++;
|
||||
}
|
||||
removeBlank(): void {
|
||||
this.counts[this.counts.length - 1]--;
|
||||
}
|
||||
addBlank(): void {
|
||||
this.counts[this.counts.length - 1]++;
|
||||
}
|
||||
/** clone returns an independent copy. */
|
||||
clone(): GenRack {
|
||||
const c = new GenRack(this.counts.length - 1);
|
||||
c.counts.set(this.counts);
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
// --- cross-sets ------------------------------------------------------------------
|
||||
//
|
||||
// A LetterSet is membership over alphabet letter indexes. Mirrors scrabble.letterSet
|
||||
// (a uint64) but as a Uint8Array so index 32 (Russian) is exact under JS bit ops.
|
||||
type LetterSet = Uint8Array;
|
||||
|
||||
function fullSet(size: number): LetterSet {
|
||||
return new Uint8Array(size).fill(1);
|
||||
}
|
||||
|
||||
// walk follows word (alphabet indexes) left to right from the root; -1 if it derails.
|
||||
function walk(dawg: Dawg, word: readonly number[]): number {
|
||||
let n = dawg.root();
|
||||
for (const l of word) {
|
||||
n = dawg.next(n, l);
|
||||
if (n < 0) return -1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// completers returns the letters X (< size) whose arc from state leads directly to an
|
||||
// accepting node — the deterministic cross-set primitive. Mirrors scrabble.completers.
|
||||
function completers(dawg: Dawg, state: number, size: number): LetterSet {
|
||||
const set = new Uint8Array(size);
|
||||
dawg.arcs(state, (label, _dest, final) => {
|
||||
if (final && label < size) set[label] = 1;
|
||||
return true;
|
||||
});
|
||||
return set;
|
||||
}
|
||||
|
||||
// dawgCrossSet returns the letters X for which above·X·below is a stored word. Mirrors
|
||||
// scrabble.dawgCrossSet: a right extension (no tiles below) just completes the prefix
|
||||
// above; a left extension (tiles below) probes each X. above/below are letter indexes.
|
||||
function dawgCrossSet(dawg: Dawg, above: number[], below: number[], size: number): LetterSet {
|
||||
if (above.length === 0 && below.length === 0) return fullSet(size);
|
||||
if (below.length === 0) {
|
||||
const node = walk(dawg, above);
|
||||
if (node < 0) return new Uint8Array(size);
|
||||
return completers(dawg, node, size);
|
||||
}
|
||||
let node = dawg.root();
|
||||
if (above.length > 0) {
|
||||
node = walk(dawg, above);
|
||||
if (node < 0) return new Uint8Array(size);
|
||||
}
|
||||
const set = new Uint8Array(size);
|
||||
for (let x = 0; x < size; x++) {
|
||||
let m = dawg.next(node, x);
|
||||
if (m < 0) continue;
|
||||
let ok = true;
|
||||
for (const l of below) {
|
||||
m = dawg.next(m, l);
|
||||
if (m < 0) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ok && dawg.final(m)) set[x] = 1;
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
// columnContext returns the contiguous run of filled cells immediately above and below
|
||||
// the empty square (r, c), each top to bottom, as letter indexes — the tiles a
|
||||
// perpendicular word through (r, c) would include. Mirrors scrabble.columnContext.
|
||||
function columnContext(b: GenBoard, r: number, c: number): { above: number[]; below: number[] } {
|
||||
const above: number[] = [];
|
||||
let start = r;
|
||||
while (start - 1 >= 0 && b.filled(start - 1, c)) start--;
|
||||
for (let rr = start; rr < r; rr++) above.push(b.cellAt(rr, c).letter);
|
||||
|
||||
const below: number[] = [];
|
||||
let end = r;
|
||||
while (end + 1 < b.rows && b.filled(end + 1, c)) end++;
|
||||
for (let rr = r + 1; rr <= end; rr++) below.push(b.cellAt(rr, c).letter);
|
||||
return { above, below };
|
||||
}
|
||||
|
||||
// transpose returns a view of b with rows and columns swapped, so down-play generation
|
||||
// runs as across generation. Mirrors board.Transpose (as a lazy view).
|
||||
function transpose(b: GenBoard): GenBoard {
|
||||
return {
|
||||
rows: b.cols,
|
||||
cols: b.rows,
|
||||
inBounds: (r, c) => b.inBounds(c, r),
|
||||
filled: (r, c) => b.filled(c, r),
|
||||
cellAt: (r, c) => b.cellAt(c, r),
|
||||
isEmpty: () => b.isEmpty(),
|
||||
};
|
||||
}
|
||||
|
||||
// A tentatively placed left-part tile; its column is fixed only at record time.
|
||||
interface TileInfo {
|
||||
letter: number;
|
||||
blank: boolean;
|
||||
}
|
||||
|
||||
// AcrossGen carries one across-generation pass over a board. Mirrors scrabble.acrossGen.
|
||||
class AcrossGen {
|
||||
private row = 0;
|
||||
private readonly left: TileInfo[] = [];
|
||||
private readonly right: Placement[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly dawg: Dawg,
|
||||
private readonly bd: GenBoard,
|
||||
private readonly rk: GenRack,
|
||||
private readonly cross: (r: number, c: number) => LetterSet,
|
||||
private readonly emit: (placements: Placement[]) => void,
|
||||
) {}
|
||||
|
||||
generateRow(row: number, firstMove: boolean, centerRow: number, centerCol: number): void {
|
||||
this.row = row;
|
||||
let limit = 0;
|
||||
for (let col = 0; col < this.bd.cols; col++) {
|
||||
if (this.bd.filled(row, col)) {
|
||||
limit = 0;
|
||||
continue;
|
||||
}
|
||||
const anchor = firstMove ? row === centerRow && col === centerCol : this.hasFilledNeighbor(row, col);
|
||||
if (!anchor) {
|
||||
limit++;
|
||||
continue;
|
||||
}
|
||||
this.left.length = 0;
|
||||
this.right.length = 0;
|
||||
if (col > 0 && this.bd.filled(row, col - 1)) {
|
||||
const pre = this.walkPrefix(row, col);
|
||||
if (pre.ok) this.extendRight(pre.node, col, col);
|
||||
} else {
|
||||
this.leftPart(this.dawg.root(), col, limit);
|
||||
}
|
||||
limit = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private hasFilledNeighbor(r: number, c: number): boolean {
|
||||
return this.bd.filled(r - 1, c) || this.bd.filled(r + 1, c) || this.bd.filled(r, c - 1) || this.bd.filled(r, c + 1);
|
||||
}
|
||||
|
||||
// walkPrefix walks the DAWG through the filled run ending at col-1, returning the
|
||||
// node reached and whether that prefix exists. Mirrors scrabble.acrossGen.walkPrefix.
|
||||
private walkPrefix(row: number, col: number): { node: number; ok: boolean } {
|
||||
let start = col - 1;
|
||||
while (start - 1 >= 0 && this.bd.filled(row, start - 1)) start--;
|
||||
let node = this.dawg.root();
|
||||
for (let c = start; c < col; c++) {
|
||||
node = this.dawg.next(node, this.bd.cellAt(row, c).letter);
|
||||
if (node < 0) return { node, ok: false };
|
||||
}
|
||||
return { node, ok: true };
|
||||
}
|
||||
|
||||
// leftPart places left-part tiles from the rack (up to limit), calling extendRight
|
||||
// after each prefix. Mirrors scrabble.acrossGen.leftPart.
|
||||
private leftPart(node: number, anchorCol: number, limit: number): void {
|
||||
this.extendRight(node, anchorCol, anchorCol);
|
||||
if (limit === 0) return;
|
||||
this.dawg.arcs(node, (label, dest) => {
|
||||
if (this.rk.has(label)) {
|
||||
this.rk.remove(label);
|
||||
this.left.push({ letter: label, blank: false });
|
||||
this.leftPart(dest, anchorCol, limit - 1);
|
||||
this.left.pop();
|
||||
this.rk.add(label);
|
||||
}
|
||||
if (this.rk.blanks() > 0) {
|
||||
this.rk.removeBlank();
|
||||
this.left.push({ letter: label, blank: true });
|
||||
this.leftPart(dest, anchorCol, limit - 1);
|
||||
this.left.pop();
|
||||
this.rk.addBlank();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// extendRight extends the word rightward from col, placing rack tiles on empty
|
||||
// squares (constrained by cross-sets) and following board tiles. A word is recorded
|
||||
// only past the anchor. Mirrors scrabble.acrossGen.extendRight.
|
||||
private extendRight(node: number, col: number, anchorCol: number): void {
|
||||
if (col >= this.bd.cols) {
|
||||
if (col > anchorCol && this.dawg.final(node)) this.record(anchorCol);
|
||||
return;
|
||||
}
|
||||
if (this.bd.filled(this.row, col)) {
|
||||
const dest = this.dawg.next(node, this.bd.cellAt(this.row, col).letter);
|
||||
if (dest >= 0) this.extendRight(dest, col + 1, anchorCol);
|
||||
return;
|
||||
}
|
||||
|
||||
if (col > anchorCol && this.dawg.final(node)) this.record(anchorCol);
|
||||
const cross = this.cross(this.row, col);
|
||||
this.dawg.arcs(node, (label, dest) => {
|
||||
if (cross[label] !== 1) return true;
|
||||
if (this.rk.has(label)) {
|
||||
this.rk.remove(label);
|
||||
this.right.push({ row: this.row, col, letter: label, blank: false });
|
||||
this.extendRight(dest, col + 1, anchorCol);
|
||||
this.right.pop();
|
||||
this.rk.add(label);
|
||||
}
|
||||
if (this.rk.blanks() > 0) {
|
||||
this.rk.removeBlank();
|
||||
this.right.push({ row: this.row, col, letter: label, blank: true });
|
||||
this.extendRight(dest, col + 1, anchorCol);
|
||||
this.right.pop();
|
||||
this.rk.addBlank();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// record assembles the play (left part at fixed columns, then the right part) and
|
||||
// reports it, skipping plays that lay no new tile. Mirrors scrabble.acrossGen.record.
|
||||
private record(anchorCol: number): void {
|
||||
if (this.left.length + this.right.length === 0) return;
|
||||
const placements: Placement[] = [];
|
||||
const leftStart = anchorCol - this.left.length;
|
||||
for (let i = 0; i < this.left.length; i++) {
|
||||
placements.push({ row: this.row, col: leftStart + i, letter: this.left[i].letter, blank: this.left[i].blank });
|
||||
}
|
||||
for (const p of this.right) placements.push(p);
|
||||
this.emit(placements);
|
||||
}
|
||||
}
|
||||
|
||||
// runAcross generates all across plays on bd and reports each via emit in bd's
|
||||
// coordinates. Cross-sets are computed lazily (vertical words on bd) and cached.
|
||||
// Mirrors DAWGGenerator.runAcross.
|
||||
function runAcross(
|
||||
dawg: Dawg,
|
||||
bd: GenBoard,
|
||||
rk: GenRack,
|
||||
size: number,
|
||||
rs: Ruleset,
|
||||
centerRow: number,
|
||||
centerCol: number,
|
||||
emit: (placements: Placement[]) => void,
|
||||
): void {
|
||||
let crossFn: (r: number, c: number) => LetterSet;
|
||||
if (rs.ignoreCrossWords) {
|
||||
const full = fullSet(size);
|
||||
crossFn = () => full;
|
||||
} else {
|
||||
const cache = new Array<LetterSet | undefined>(bd.rows * bd.cols);
|
||||
crossFn = (r, c) => {
|
||||
const i = r * bd.cols + c;
|
||||
let s = cache[i];
|
||||
if (!s) {
|
||||
const { above, below } = columnContext(bd, r, c);
|
||||
s = dawgCrossSet(dawg, above, below, size);
|
||||
cache[i] = s;
|
||||
}
|
||||
return s;
|
||||
};
|
||||
}
|
||||
|
||||
const ag = new AcrossGen(dawg, bd, rk, crossFn, emit);
|
||||
const firstMove = bd.isEmpty();
|
||||
for (let row = 0; row < bd.rows; row++) {
|
||||
ag.generateRow(row, firstMove, centerRow, centerCol);
|
||||
}
|
||||
}
|
||||
|
||||
// moveKey is a canonical string identifying a play (direction plus its placed tiles),
|
||||
// used to de-duplicate and rank generated moves. Mirrors scrabble.moveKey.
|
||||
export function moveKey(dir: Direction, placements: readonly Placement[]): string {
|
||||
const ps = placements.slice().sort((a, b) => (a.row !== b.row ? a.row - b.row : a.col - b.col));
|
||||
let s = String(dir);
|
||||
for (const p of ps) s += `;${p.row},${p.col},${p.letter}${p.blank ? '*' : ''}`;
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* generateMoves returns every legal play for rack on board in the mode's orientations,
|
||||
* ranked by descending score (ties broken by the canonical move key). It walks the DAWG
|
||||
* with the cursor and scores each play with evaluate(); the alphabet size is taken from
|
||||
* the ruleset's value table. Mirrors (*Solver).GenerateMovesOpts (the ruleset carries
|
||||
* ignoreCrossWords for the single-word rule).
|
||||
*/
|
||||
export function generateMoves(dawg: Dawg, board: GenBoard, rack: GenRack, rs: Ruleset, mode: Mode = Both): Move[] {
|
||||
const size = rs.values.length;
|
||||
const rk = rack.clone(); // generation mutates the rack in place and restores it
|
||||
const centerRow = Math.floor(rs.center / rs.cols);
|
||||
const centerCol = rs.center % rs.cols;
|
||||
|
||||
const moves: Move[] = [];
|
||||
const seen = new Set<string>();
|
||||
const emit = (dir: Direction, placements: Placement[]): void => {
|
||||
const key = moveKey(dir, placements);
|
||||
if (seen.has(key)) return;
|
||||
const res = evaluate(board, rs, dir, placements);
|
||||
if (res.err || !res.move) return;
|
||||
seen.add(key);
|
||||
moves.push(res.move);
|
||||
};
|
||||
|
||||
if (modeIncludes(mode, Horizontal)) {
|
||||
runAcross(dawg, board, rk, size, rs, centerRow, centerCol, (p) => emit(Horizontal, p));
|
||||
}
|
||||
if (modeIncludes(mode, Vertical)) {
|
||||
const tb = transpose(board);
|
||||
runAcross(dawg, tb, rk, size, rs, centerCol, centerRow, (p) => {
|
||||
const rp = p.map((pl) => ({ row: pl.col, col: pl.row, letter: pl.letter, blank: pl.blank }));
|
||||
emit(Vertical, rp);
|
||||
});
|
||||
}
|
||||
|
||||
const kept = moves.filter((m) => connected(board, rs, m));
|
||||
kept.sort((a, b) => {
|
||||
if (a.score !== b.score) return b.score - a.score;
|
||||
const ka = moveKey(a.dir, a.tiles);
|
||||
const kb = moveKey(b.dir, b.tiles);
|
||||
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
||||
});
|
||||
return kept;
|
||||
}
|
||||
Reference in New Issue
Block a user