// 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; } /** 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; }