diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 9c95ce3..5652454 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -499,11 +499,13 @@ // The composed play's word geometry (client-side, independent of the move evaluator): the cells a // formed word runs through — greened on the board — and the main word that anchors the score // badge. Derived only for a legal preview, and never while dragging a staged tile back over the - // rack (recallOverRack), so the highlight and badge clear the instant a recall begins. + // rack (recallOverRack), so the highlight and badge clear the instant a recall begins. The play's + // orientation is the preview's own (`dir`, which every preview source fills in for a legal play) + // rather than a second, independent guess — see lib/formed.ts. const EMPTY_FORMED = new Set(); const formedGeom = $derived( preview?.legal && !recallOverRack - ? formedGeometry(board, placement.pending, !!view?.game.multipleWordsPerTurn) + ? formedGeometry(board, placement.pending, !!view?.game.multipleWordsPerTurn, preview.dir === 'V' ? 'V' : 'H') : null, ); const formedCells = $derived(formedGeom?.cells ?? EMPTY_FORMED); diff --git a/ui/src/lib/formed.engine.test.ts b/ui/src/lib/formed.engine.test.ts new file mode 100644 index 0000000..abe8bfc --- /dev/null +++ b/ui/src/lib/formed.engine.test.ts @@ -0,0 +1,223 @@ +// Differential test: the board highlight (lib/formed.ts) against the engine port +// (lib/dict/{direction,validate}.ts) that decides what the play actually is. +// +// The two are separate implementations by necessity — the engine names the words, the highlight +// names the cells — and they drifted once already: formedGeometry used to infer the play's +// orientation itself, and for a lone staged tile under the single-word rule its "longer run wins" +// guess disagreed with the engine, which settles that case through the validator. The result was a +// legal vertical play greening the horizontal run it merely abuts. Nothing in either unit suite +// caught it, because each was right about its own half. +// +// So this walks random positions, asks the engine for every legal play it can find, and asserts the +// highlighted cells are exactly the cells of the words the engine says the play forms — and that +// the highlighted main word runs along the engine's orientation. Two dictionaries are used: one +// that accepts every run (the widest set of legal plays) and one that accepts a pseudo-random half +// of them (so the two orientations of a lone tile genuinely differ in legality, which is what makes +// the engine's validator tie-break matter). + +import { describe, expect, it } from 'vitest'; +import { formedGeometry, type Dir } from './formed'; +import { emptyBoard, type Board } from './board'; +import { playDirection } from './dict/direction'; +import { + validatePlay, + Horizontal, + type Board as EngineBoard, + type Dict, + type Move, + type Placement, + type Ruleset, +} from './dict/validate'; +import { BOARD_SIZE } from './premiums'; + +/** Size of the toy alphabet the fuzz draws letters from (indices 0..LETTERS-1). */ +const LETTERS = 6; + +/** rng is a small deterministic PRNG (mulberry32), so a failure is always reproducible by seed. */ +function rng(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Grid maps a "row,col" key to the letter index committed there. */ +type Grid = Map; + +// engineBoardOf adapts a grid into the validator's read view. +function engineBoardOf(grid: Grid): EngineBoard { + const inBounds = (r: number, c: number): boolean => r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE; + return { + inBounds, + filled: (r, c) => inBounds(r, c) && grid.has(`${r},${c}`), + cellAt: (r, c) => ({ letter: grid.get(`${r},${c}`)!, blank: false }), + isEmpty: () => grid.size === 0, + }; +} + +// clientBoardOf adapts the same grid into the client's letter-space board. +function clientBoardOf(grid: Grid): Board { + const b = emptyBoard(); + for (const [k, letter] of grid) { + const [r, c] = k.split(',').map(Number); + b[r][c] = { letter: String.fromCharCode(97 + letter), blank: false }; + } + return b; +} + +// ruleset builds a flat-premium ruleset (the premiums are irrelevant to geometry) with distinct +// letter values, so two legal orientations of one tile can differ in score and exercise the +// engine's higher-scoring tie-break. +function ruleset(ignoreCrossWords: boolean): Ruleset { + return { + cols: BOARD_SIZE, + center: 7 * BOARD_SIZE + 7, + rackSize: 7, + bingo: 50, + values: Array.from({ length: LETTERS }, (_, i) => i + 1), + letterMult: () => 1, + wordMult: () => 1, + ignoreCrossWords, + }; +} + +/** acceptAll is a dictionary holding every run of two or more letters. */ +const acceptAll: Dict = { indexOf: (w) => (w.length >= 2 ? 1 : -1) }; + +/** acceptHalf holds a deterministic pseudo-random half of the runs, so legality is axis-dependent. */ +const acceptHalf: Dict = { + indexOf: (w) => { + if (w.length < 2) return -1; + let h = 2166136261; + for (let i = 0; i < w.length; i++) h = Math.imul(h ^ (w[i] as number), 16777619); + return (h >>> 0) % 2 === 0 ? 1 : -1; + }, +}; + +// engineCells collects every cell covered by the words the engine says the play forms. +function engineCells(m: Move): Set { + const out = new Set(); + for (const w of [m.main, ...m.cross]) { + for (let i = 0; i < w.letters.length; i++) { + const r = w.dir === Horizontal ? w.row : w.row + i; + const c = w.dir === Horizontal ? w.col + i : w.col; + out.add(`${r},${c}`); + } + } + return out; +} + +// position lays a handful of random runs around the centre, giving hooks and abutments to play off. +function position(rnd: () => number): Grid { + const grid: Grid = new Map(); + const runs = 1 + Math.floor(rnd() * 5); + for (let i = 0; i < runs; i++) { + const horiz = rnd() < 0.5; + const len = 2 + Math.floor(rnd() * 4); + const row = 4 + Math.floor(rnd() * 7); + const col = 4 + Math.floor(rnd() * 7); + for (let j = 0; j < len; j++) { + const r = horiz ? row : row + j; + const c = horiz ? col + j : col; + if (r >= BOARD_SIZE || c >= BOARD_SIZE) break; + grid.set(`${r},${c}`, Math.floor(rnd() * LETTERS)); + } + } + return grid; +} + +// candidates enumerates every lone-tile play on the position plus a batch of random multi-tile line +// plays (walking along the axis over occupied squares, so bridging plays appear too). +function candidates(grid: Grid, rnd: () => number): Placement[][] { + const out: Placement[][] = []; + for (let r = 0; r < BOARD_SIZE; r++) { + for (let c = 0; c < BOARD_SIZE; c++) { + if (grid.has(`${r},${c}`)) continue; + out.push([{ row: r, col: c, letter: Math.floor(rnd() * LETTERS), blank: false }]); + } + } + for (let k = 0; k < 300; k++) { + const horiz = rnd() < 0.5; + const n = 2 + Math.floor(rnd() * 3); + const row = 2 + Math.floor(rnd() * 11); + const col = 2 + Math.floor(rnd() * 11); + const tiles: Placement[] = []; + let step = 0; + for (let i = 0; i < n; i++) { + for (let guard = 0; guard < 6; guard++) { + const r = horiz ? row : row + step; + const c = horiz ? col + step : col; + step++; + if (r >= BOARD_SIZE || c >= BOARD_SIZE) break; + if (!grid.has(`${r},${c}`)) { + tiles.push({ row: r, col: c, letter: Math.floor(rnd() * LETTERS), blank: false }); + break; + } + } + } + if (tiles.length >= 2) out.push(tiles); + } + return out; +} + +// compare walks `seeds` random positions under one rule and dictionary, returning how many legal +// plays were checked and a description of every disagreement found. +function compare( + multipleWords: boolean, + dict: Dict, + seeds: number, +): { checked: number; failures: string[] } { + const rs = ruleset(!multipleWords); + let checked = 0; + const failures: string[] = []; + + for (let seed = 1; seed <= seeds; seed++) { + const rnd = rng(seed); + const grid = position(rnd); + if (grid.size === 0) continue; + const eb = engineBoardOf(grid); + const cb = clientBoardOf(grid); + + for (const tiles of candidates(grid, rnd)) { + const dir = playDirection(eb, rs, dict, tiles); + const res = validatePlay(eb, rs, dict, dir, tiles); + if (!res.legal || !res.move) continue; + checked++; + + const wantDir: Dir = res.move.dir === Horizontal ? 'H' : 'V'; + const want = engineCells(res.move); + const geom = formedGeometry(cb, tiles, multipleWords, wantDir); + const got = geom?.cells ?? new Set(); + const sameCells = want.size === got.size && [...want].every((k) => got.has(k)); + if (sameCells && geom?.main.dir === wantDir) continue; + + if (failures.length < 5) { + const at = tiles.map((t) => `${t.row},${t.col}`).join(' '); + failures.push( + `seed ${seed}: staged [${at}] — engine ${wantDir} ${[...want].sort().join(' ')}` + + ` / highlight ${geom?.main.dir ?? 'none'} ${[...got].sort().join(' ')}`, + ); + } + } + } + return { checked, failures }; +} + +describe('formedGeometry matches the engine port', () => { + for (const multipleWords of [false, true]) { + for (const [dictName, dict] of [ + ['every run a word', acceptAll], + ['half the runs words', acceptHalf], + ] as const) { + const rule = multipleWords ? 'multiple words per turn' : 'one word per turn'; + it(`highlights exactly the engine's words (${rule}, ${dictName})`, () => { + const { checked, failures } = compare(multipleWords, dict, 40); + expect(checked).toBeGreaterThan(500); // the sweep must actually find legal plays + expect(failures).toEqual([]); + }); + } + } +}); diff --git a/ui/src/lib/formed.test.ts b/ui/src/lib/formed.test.ts index bde1577..57ae97b 100644 --- a/ui/src/lib/formed.test.ts +++ b/ui/src/lib/formed.test.ts @@ -20,14 +20,24 @@ const cells = (g: ReturnType) => [...(g?.cells ?? new Set describe('formedGeometry', () => { it('returns null with nothing staged', () => { - expect(formedGeometry(board(), [], true)).toBeNull(); + expect(formedGeometry(board(), [], true, 'H')).toBeNull(); + }); + + it('returns null when the staged tiles are off the named line', () => { + // Two tiles on different rows cannot form a horizontal play; only an illegal shape does this, + // and the caller never previews one. + const g = formedGeometry(board(), [ + { row: 7, col: 7 }, + { row: 8, col: 8 }, + ], true, 'H'); + expect(g).toBeNull(); }); it('finds a fresh horizontal word with no cross words', () => { const g = formedGeometry(board(), [ { row: 7, col: 7 }, { row: 7, col: 8 }, - ], true); + ], true, 'H'); expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 }); expect(cells(g)).toEqual(['7,7', '7,8']); }); @@ -36,14 +46,14 @@ describe('formedGeometry', () => { const g = formedGeometry(board(), [ { row: 7, col: 7 }, { row: 8, col: 7 }, - ], true); + ], true, 'V'); expect(g?.main).toEqual({ row: 7, col: 7, dir: 'V', len: 2 }); expect(cells(g)).toEqual(['7,7', '8,7']); }); it('extends a committed tile into the main word (committed cell in the set)', () => { // A committed A at (7,7); a lone tile to its right forms the two-letter main word. - const g = formedGeometry(board([[7, 7, 'A']]), [{ row: 7, col: 8 }], true); + const g = formedGeometry(board([[7, 7, 'A']]), [{ row: 7, col: 8 }], true, 'H'); expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 }); expect(cells(g)).toEqual(['7,7', '7,8']); }); @@ -54,7 +64,7 @@ describe('formedGeometry', () => { const g = formedGeometry(board([[6, 8, 'X'], [8, 8, 'Y']]), [ { row: 7, col: 7 }, { row: 7, col: 8 }, - ], true); + ], true, 'H'); expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 }); expect(cells(g)).toEqual(['6,8', '7,7', '7,8', '8,8']); }); @@ -66,14 +76,15 @@ describe('formedGeometry', () => { const g = formedGeometry(board([[6, 8, 'X'], [8, 8, 'Y']]), [ { row: 7, col: 7 }, { row: 7, col: 8 }, - ], false); + ], false, 'H'); expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 }); expect(cells(g)).toEqual(['7,7', '7,8']); }); - it('takes the longer axis as the main word for a lone connecting tile', () => { - // (7,8) joins a length-3 horizontal (cols 6-8) and a length-4 vertical (rows 5-8): the vertical - // is longer, so it is the main word and the horizontal becomes a cross word. + it('takes the named axis as the main word for a lone connecting tile', () => { + // (7,8) joins a length-3 horizontal (cols 6-8) and a length-4 vertical (rows 5-8). The engine + // resolved the play as vertical, so the vertical is the main word and the horizontal becomes a + // cross word. const g = formedGeometry( board([ [7, 6, 'A'], @@ -84,10 +95,32 @@ describe('formedGeometry', () => { ]), [{ row: 7, col: 8 }], true, + 'V', ); expect(g?.main).toEqual({ row: 5, col: 8, dir: 'V', len: 4 }); expect(cells(g)).toEqual(['5,8', '6,8', '7,6', '7,7', '7,8', '8,8']); }); + + it('follows the engine over the longer run for a lone tile under the single-word rule', () => { + // The reported bug: a committed horizontal DOM (row 7, cols 7-9) and a committed X at (8,10). A + // lone tile at (7,10) plays the vertical two-letter word down column 10 — it merely abuts DOM's + // last letter without crossing it. The horizontal run through the tile is the longer one (4 + // cells) but is no word at all, so the engine resolves the play vertical; the highlight must + // follow, not light up the four-cell horizontal run. + const g = formedGeometry( + board([ + [7, 7, 'D'], + [7, 8, 'O'], + [7, 9, 'M'], + [8, 10, 'X'], + ]), + [{ row: 7, col: 10 }], + false, + 'V', + ); + expect(g?.main).toEqual({ row: 7, col: 10, dir: 'V', len: 2 }); + expect(cells(g)).toEqual(['7,10', '8,10']); + }); }); describe('badgePlacement', () => { diff --git a/ui/src/lib/formed.ts b/ui/src/lib/formed.ts index 924371b..91d64d7 100644 --- a/ui/src/lib/formed.ts +++ b/ui/src/lib/formed.ts @@ -1,10 +1,13 @@ // Client-side geometry of the play being composed: which cells the formed word(s) cover, and // where to anchor the move-score badge. This is deliberately independent of the move evaluator // (lib/dict/eval.ts) — the network `evaluate` returns legality/score/word strings but no cell -// coordinates, and the board highlight plus the score badge must work even while the local -// dictionary is still warming up. Legality and the score come from the preview; the geometry is -// derived here purely from the board and the staged tiles. Both functions are pure (testable in -// lib/formed.test.ts); the board stays the source of truth for legality. +// coordinates. Legality, the score and the play's ORIENTATION come from the preview (every +// preview source carries it: the on-device evaluator, the network `evaluate` and a reused hint); +// the geometry is derived here from the board, the staged tiles and that orientation. The +// orientation is never re-derived here — inferring it a second time is what made a lone staged +// tile under the single-word rule highlight the perpendicular run instead of the word actually +// played (lib/formed.engine.test.ts pins the two against each other). Both functions are pure +// (testable in lib/formed.test.ts); the board stays the source of truth for legality. import type { Board } from './board'; import { BOARD_SIZE } from './premiums'; @@ -74,39 +77,36 @@ function span( } /** - * formedGeometry derives the composed play's word geometry from the board and the staged tiles. - * It returns the main word (the maximal run along the play axis through the staged tiles) and the - * set of every cell the main word and each cross word (a perpendicular run of two or more cells - * through a staged tile) cover. Under the **single-word rule** (multipleWords = false) cross words - * are omitted, matching the engine, which ignores them entirely — a perpendicular run there is - * neither validated nor scored (and need not even be a real word), so highlighting it would be - * misleading. It returns null when nothing is staged, or when the staged tiles are not on a single - * line — a shape only an illegal play produces, which the caller never asks about (it is invoked - * only for a legal preview). The play axis is fixed by the staged tiles when two or more are - * colinear; a lone tile takes whichever axis forms the longer word. + * formedGeometry derives the composed play's word geometry from the board, the staged tiles and + * dir — the orientation the engine resolved for this very placement, taken from the preview. It + * returns the main word (the maximal run along dir through the staged tiles) and the set of every + * cell the main word and each cross word (a perpendicular run of two or more cells through a + * staged tile) cover. Under the **single-word rule** (multipleWords = false) cross words are + * omitted, matching the engine, which ignores them entirely — a perpendicular run there is neither + * validated nor scored (and need not even be a real word), so highlighting it would be misleading. + * + * dir must be the engine's own answer rather than a local guess: for a lone staged tile under the + * single-word rule the two orientations can differ in legality, so the engine settles it through + * the validator (lib/dict/direction.ts playDirection) and the geometry must follow that verdict or + * it lights up a run the play does not form. + * + * It returns null when nothing is staged, or when the staged tiles do not all sit on one line + * along dir — a shape only an illegal play produces, which the caller never asks about (it is + * invoked only for a legal preview). */ export function formedGeometry( board: Board, pending: readonly Cell[], multipleWords: boolean, + dir: Dir, ): FormedGeometry | null { if (pending.length === 0) return null; - const filled = filledPredicate(board, pending); const first = pending[0]; + const colinear = + dir === 'H' ? pending.every((p) => p.row === first.row) : pending.every((p) => p.col === first.col); + if (!colinear) return null; - let dir: Dir; - if (pending.length === 1) { - const h = span(filled, 'H', first.row, first.col); - const v = span(filled, 'V', first.row, first.col); - dir = h.len >= v.len ? 'H' : 'V'; - } else if (pending.every((p) => p.row === first.row)) { - dir = 'H'; - } else if (pending.every((p) => p.col === first.col)) { - dir = 'V'; - } else { - return null; - } - + const filled = filledPredicate(board, pending); const cells = new Set(); // Main word: the maximal run along the play axis through the staged tiles (contiguous via any