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.
340 lines
11 KiB
TypeScript
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;
|
|
}
|