Files
scrabble-game/ui/src/lib/dict/validate.ts
T
Ilia Denisov 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
feat(offline): port DAWG cursor + move generator to TS (parity-pinned)
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.
2026-07-06 01:35:11 +02:00

340 lines
11 KiB
TypeScript

// Local move validator + scorer, ported from the scrabble-solver engine
// (scrabble/score.go and scrabble/solver.go, v1.1.1). It answers exactly what the
// server `evaluate` endpoint answers — is a play legal, what words does it form,
// and for how many points — so the client can preview a move without a round
// trip. The server stays authoritative: `submit_play` re-validates on commit, so
// this is an advisory accelerator, never a source of truth.
//
// Everything here works in alphabet-index space, mirroring the Go engine. The
// caller adapts the client's board/placements (letters) into indices via
// lib/alphabet.ts and the premium geometry via lib/premiums.ts. Faithfulness to
// the Go engine is pinned by validate.parity.test.ts against golden fixtures.
/** Horizontal is an across play (fixed row, axis along columns). Mirrors scrabble.Horizontal. */
export const Horizontal = 0;
/** Vertical is a down play (fixed column, axis along rows). Mirrors scrabble.Vertical. */
export const Vertical = 1;
export type Direction = typeof Horizontal | typeof Vertical;
/** A single newly-placed tile (alphabet-index letter; blank scores 0). */
export interface Placement {
row: number;
col: number;
letter: number;
blank: boolean;
}
/** An occupied board square: an alphabet-index letter and whether it is a blank. */
export interface Cell {
letter: number;
blank: boolean;
}
/**
* Board is the minimal read view the validator needs over the current position.
* cellAt is only consulted for squares that filled() reports as occupied.
*/
export interface Board {
inBounds(row: number, col: number): boolean;
/** filled reports whether (row, col) is on the board AND occupied. */
filled(row: number, col: number): boolean;
cellAt(row: number, col: number): Cell;
/** isEmpty reports whether the whole board is empty (first-move detection). */
isEmpty(): boolean;
}
/**
* Ruleset carries the per-variant scoring data the validator needs. values is
* indexed by alphabet letter index; letterMult/wordMult return the premium
* multipliers of a square (1 when none); center is the row-major centre index;
* ignoreCrossWords selects the single-word-per-turn rule.
*/
export interface Ruleset {
cols: number;
center: number;
rackSize: number;
bingo: number;
values: readonly number[];
letterMult(row: number, col: number): number;
wordMult(row: number, col: number): number;
ignoreCrossWords: boolean;
}
/** Dict is the dictionary membership test (see Dawg.indexOf). */
export interface Dict {
indexOf(word: ArrayLike<number>): number;
}
/** A word formed by a play, with its location, letters (indices) and score. */
export interface Word {
row: number;
col: number;
dir: Direction;
letters: number[];
blanks: boolean[];
score: number;
}
/** A scored play: the main word, any cross words, the bingo bonus and the total. */
export interface Move {
dir: Direction;
tiles: Placement[];
main: Word;
cross: Word[];
bonus: number;
score: number;
}
/** Reason a play is rejected. Mirrors the error conditions in the Go engine. */
export type EvalError =
| 'empty'
| 'not-one-line'
| 'off-board'
| 'occupied'
| 'same-square'
| 'gap'
| 'too-short'
| 'main-not-in-dict'
| 'cross-not-in-dict'
| 'not-connected';
/** Result of {@link validatePlay}: a scored move plus whether it is legal. */
export interface ValidateResult {
move?: Move;
legal: boolean;
err?: EvalError;
}
// coord maps a line coordinate (fixed, axis) to a board (row, col) for dir.
function coord(dir: Direction, fixed: number, axis: number): [number, number] {
return dir === Horizontal ? [fixed, axis] : [axis, fixed];
}
// fixedAxis is the inverse of coord: it splits (row, col) into (fixed, axis).
function fixedAxis(dir: Direction, row: number, col: number): [number, number] {
return dir === Horizontal ? [row, col] : [col, row];
}
function perpendicular(d: Direction): Direction {
return d === Horizontal ? Vertical : Horizontal;
}
/**
* evaluate computes the words and score for placing tiles on b in direction dir.
* It checks geometry only (tiles on one line, on empty squares, contiguous); the
* dictionary and connectivity are layered on by {@link validatePlay}. Mirrors
* scrabble.EvaluateOpts.
*/
export function evaluate(
b: Board,
rs: Ruleset,
dir: Direction,
tiles: Placement[],
): { move?: Move; err?: EvalError } {
if (tiles.length === 0) {
return { err: 'empty' };
}
const ts = tiles.slice().sort((x, y) => fixedAxis(dir, x.row, x.col)[1] - fixedAxis(dir, y.row, y.col)[1]);
const fixed = fixedAxis(dir, ts[0].row, ts[0].col)[0];
let prevAxis = 0;
for (let i = 0; i < ts.length; i++) {
const t = ts[i];
const [f, a] = fixedAxis(dir, t.row, t.col);
if (f !== fixed) return { err: 'not-one-line' };
if (!b.inBounds(t.row, t.col)) return { err: 'off-board' };
if (b.filled(t.row, t.col)) return { err: 'occupied' };
if (i > 0 && a === prevAxis) return { err: 'same-square' };
prevAxis = a;
}
const main = buildMainWord(b, rs, dir, fixed, ts);
if ('err' in main) return { err: main.err };
const move: Move = { dir, tiles: ts, main: main.word, cross: [], bonus: 0, score: main.word.score };
if (!rs.ignoreCrossWords) {
for (const t of ts) {
const cw = crossWord(b, rs, dir, t);
if (cw) {
move.cross.push(cw);
move.score += cw.score;
}
}
}
if (ts.length === rs.rackSize) {
move.bonus = rs.bingo;
move.score += rs.bingo;
}
return { move };
}
// buildMainWord assembles and scores the word along dir through the sorted
// placements plus the existing tiles that extend and bridge them. Mirrors
// scrabble.buildMainWord.
function buildMainWord(
b: Board,
rs: Ruleset,
dir: Direction,
fixed: number,
ts: Placement[],
): { word: Word } | { err: EvalError } {
const minA = fixedAxis(dir, ts[0].row, ts[0].col)[1];
const maxA = fixedAxis(dir, ts[ts.length - 1].row, ts[ts.length - 1].col)[1];
let start = minA;
for (;;) {
const [r, c] = coord(dir, fixed, start - 1);
if (!b.filled(r, c)) break;
start--;
}
let end = maxA;
for (;;) {
const [r, c] = coord(dir, fixed, end + 1);
if (!b.filled(r, c)) break;
end++;
}
const letters: number[] = [];
const blanks: boolean[] = [];
let letterSum = 0;
let wordMult = 1;
let ti = 0;
for (let a = start; a <= end; a++) {
const [r, c] = coord(dir, fixed, a);
if (ti < ts.length) {
const ta = fixedAxis(dir, ts[ti].row, ts[ti].col)[1];
if (ta === a) {
const t = ts[ti];
ti++;
if (!t.blank) letterSum += rs.values[t.letter] * rs.letterMult(r, c);
wordMult *= rs.wordMult(r, c);
letters.push(t.letter);
blanks.push(t.blank);
continue;
}
}
if (b.filled(r, c)) {
const cell = b.cellAt(r, c);
if (!cell.blank) letterSum += rs.values[cell.letter];
letters.push(cell.letter);
blanks.push(cell.blank);
continue;
}
return { err: 'gap' };
}
const [wr, wc] = coord(dir, fixed, start);
return { word: { row: wr, col: wc, dir, letters, blanks, score: letterSum * wordMult } };
}
// crossWord builds the perpendicular word formed by a single new tile, or null
// when the tile has no perpendicular neighbour. Mirrors scrabble.crossWord.
function crossWord(b: Board, rs: Ruleset, dir: Direction, t: Placement): Word | null {
const cdir = perpendicular(dir);
const [fixed, axis] = fixedAxis(cdir, t.row, t.col);
let start = axis;
for (;;) {
const [r, c] = coord(cdir, fixed, start - 1);
if (!b.filled(r, c)) break;
start--;
}
let end = axis;
for (;;) {
const [r, c] = coord(cdir, fixed, end + 1);
if (!b.filled(r, c)) break;
end++;
}
if (start === end) return null;
const letters: number[] = [];
const blanks: boolean[] = [];
let letterSum = 0;
let wordMult = 1;
for (let a = start; a <= end; a++) {
const [r, c] = coord(cdir, fixed, a);
if (a === axis) {
if (!t.blank) letterSum += rs.values[t.letter] * rs.letterMult(r, c);
wordMult *= rs.wordMult(r, c);
letters.push(t.letter);
blanks.push(t.blank);
} else {
const cell = b.cellAt(r, c);
if (!cell.blank) letterSum += rs.values[cell.letter];
letters.push(cell.letter);
blanks.push(cell.blank);
}
}
const [wr, wc] = coord(cdir, fixed, start);
return { row: wr, col: wc, dir: cdir, letters, blanks, score: letterSum * wordMult };
}
/**
* validatePlay scores a play and checks that every word it forms is in the
* dictionary and that it connects to the board (or covers the centre on the
* first move). legal is true exactly when the play is legal. Mirrors
* scrabble.ValidatePlayOpts.
*/
export function validatePlay(
b: Board,
rs: Ruleset,
dict: Dict,
dir: Direction,
tiles: Placement[],
): ValidateResult {
const res = evaluate(b, rs, dir, tiles);
if (res.err) return { legal: false, err: res.err };
const m = res.move!;
if (m.main.letters.length < 2) return { move: m, legal: false, err: 'too-short' };
if (dict.indexOf(m.main.letters) < 0) return { move: m, legal: false, err: 'main-not-in-dict' };
for (const cw of m.cross) {
if (dict.indexOf(cw.letters) < 0) return { move: m, legal: false, err: 'cross-not-in-dict' };
}
if (!connected(b, rs, m)) return { move: m, legal: false, err: 'not-connected' };
return { move: m, legal: true };
}
// connected reports whether the play connects to the position (or covers the
// centre on the first move). Mirrors (*Solver).connected. Exported so the move
// generator can apply the same post-generation connectivity filter.
export function connected(b: Board, rs: Ruleset, m: Move): boolean {
if (b.isEmpty()) {
const cr = Math.floor(rs.center / rs.cols);
const cc = rs.center % rs.cols;
return wordCovers(m.main, cr, cc);
}
if (rs.ignoreCrossWords) {
return m.main.letters.length > m.tiles.length;
}
return m.main.letters.length > m.tiles.length || touchesPerpendicular(b, m);
}
// touchesPerpendicular reports whether any new tile abuts an existing tile
// perpendicular to the main word. Mirrors scrabble.touchesPerpendicular.
function touchesPerpendicular(b: Board, m: Move): boolean {
const cdir = perpendicular(m.dir);
for (const t of m.tiles) {
const [fixed, axis] = fixedAxis(cdir, t.row, t.col);
let [r, c] = coord(cdir, fixed, axis - 1);
if (b.filled(r, c)) return true;
[r, c] = coord(cdir, fixed, axis + 1);
if (b.filled(r, c)) return true;
}
return false;
}
// wordCovers reports whether word w passes through square (r, c). Mirrors
// scrabble.wordCovers.
function wordCovers(w: Word, r: number, c: number): boolean {
for (let i = 0; i < w.letters.length; i++) {
let rr = w.row;
let cc = w.col;
if (w.dir === Horizontal) cc += i;
else rr += i;
if (rr === r && cc === c) return true;
}
return false;
}