// 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(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(); 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; }