Merge pull request 'fix(ui): highlight the word the engine played, not the run it abuts' (#291) from fix/offline-highlight-direction into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m18s
CI / conformance (push) Successful in 11s
CI / gate (push) Successful in 1s
CI / deploy (push) Successful in 1m55s
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped

This commit was merged in pull request #291.
This commit is contained in:
2026-07-27 21:46:32 +00:00
4 changed files with 297 additions and 39 deletions
+4 -2
View File
@@ -499,11 +499,13 @@
// The composed play's word geometry (client-side, independent of the move evaluator): the cells a // 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 // 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 // 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<string>(); const EMPTY_FORMED = new Set<string>();
const formedGeom = $derived( const formedGeom = $derived(
preview?.legal && !recallOverRack preview?.legal && !recallOverRack
? formedGeometry(board, placement.pending, !!view?.game.multipleWordsPerTurn) ? formedGeometry(board, placement.pending, !!view?.game.multipleWordsPerTurn, preview.dir === 'V' ? 'V' : 'H')
: null, : null,
); );
const formedCells = $derived(formedGeom?.cells ?? EMPTY_FORMED); const formedCells = $derived(formedGeom?.cells ?? EMPTY_FORMED);
+223
View File
@@ -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<string, number>;
// 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<string> {
const out = new Set<string>();
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<string>();
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([]);
});
}
}
});
+42 -9
View File
@@ -20,14 +20,24 @@ const cells = (g: ReturnType<typeof formedGeometry>) => [...(g?.cells ?? new Set
describe('formedGeometry', () => { describe('formedGeometry', () => {
it('returns null with nothing staged', () => { 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', () => { it('finds a fresh horizontal word with no cross words', () => {
const g = formedGeometry(board(), [ const g = formedGeometry(board(), [
{ row: 7, col: 7 }, { row: 7, col: 7 },
{ row: 7, col: 8 }, { row: 7, col: 8 },
], true); ], true, 'H');
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 }); expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
expect(cells(g)).toEqual(['7,7', '7,8']); expect(cells(g)).toEqual(['7,7', '7,8']);
}); });
@@ -36,14 +46,14 @@ describe('formedGeometry', () => {
const g = formedGeometry(board(), [ const g = formedGeometry(board(), [
{ row: 7, col: 7 }, { row: 7, col: 7 },
{ row: 8, col: 7 }, { row: 8, col: 7 },
], true); ], true, 'V');
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'V', len: 2 }); expect(g?.main).toEqual({ row: 7, col: 7, dir: 'V', len: 2 });
expect(cells(g)).toEqual(['7,7', '8,7']); expect(cells(g)).toEqual(['7,7', '8,7']);
}); });
it('extends a committed tile into the main word (committed cell in the set)', () => { 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. // 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(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
expect(cells(g)).toEqual(['7,7', '7,8']); expect(cells(g)).toEqual(['7,7', '7,8']);
}); });
@@ -54,7 +64,7 @@ describe('formedGeometry', () => {
const g = formedGeometry(board([[6, 8, 'X'], [8, 8, 'Y']]), [ const g = formedGeometry(board([[6, 8, 'X'], [8, 8, 'Y']]), [
{ row: 7, col: 7 }, { row: 7, col: 7 },
{ row: 7, col: 8 }, { row: 7, col: 8 },
], true); ], true, 'H');
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 }); expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
expect(cells(g)).toEqual(['6,8', '7,7', '7,8', '8,8']); 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']]), [ const g = formedGeometry(board([[6, 8, 'X'], [8, 8, 'Y']]), [
{ row: 7, col: 7 }, { row: 7, col: 7 },
{ row: 7, col: 8 }, { row: 7, col: 8 },
], false); ], false, 'H');
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 }); expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
expect(cells(g)).toEqual(['7,7', '7,8']); expect(cells(g)).toEqual(['7,7', '7,8']);
}); });
it('takes the longer axis as the main word for a lone connecting tile', () => { 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 vertical // (7,8) joins a length-3 horizontal (cols 6-8) and a length-4 vertical (rows 5-8). The engine
// is longer, so it is the main word and the horizontal becomes a cross word. // resolved the play as vertical, so the vertical is the main word and the horizontal becomes a
// cross word.
const g = formedGeometry( const g = formedGeometry(
board([ board([
[7, 6, 'A'], [7, 6, 'A'],
@@ -84,10 +95,32 @@ describe('formedGeometry', () => {
]), ]),
[{ row: 7, col: 8 }], [{ row: 7, col: 8 }],
true, true,
'V',
); );
expect(g?.main).toEqual({ row: 5, col: 8, dir: 'V', len: 4 }); 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']); 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', () => { describe('badgePlacement', () => {
+28 -28
View File
@@ -1,10 +1,13 @@
// Client-side geometry of the play being composed: which cells the formed word(s) cover, and // 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 // 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 // (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 // coordinates. Legality, the score and the play's ORIENTATION come from the preview (every
// dictionary is still warming up. Legality and the score come from the preview; the geometry is // preview source carries it: the on-device evaluator, the network `evaluate` and a reused hint);
// derived here purely from the board and the staged tiles. Both functions are pure (testable in // the geometry is derived here from the board, the staged tiles and that orientation. The
// lib/formed.test.ts); the board stays the source of truth for legality. // 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 type { Board } from './board';
import { BOARD_SIZE } from './premiums'; 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. * formedGeometry derives the composed play's word geometry from the board, the staged tiles and
* It returns the main word (the maximal run along the play axis through the staged tiles) and the * dir — the orientation the engine resolved for this very placement, taken from the preview. It
* set of every cell the main word and each cross word (a perpendicular run of two or more cells * returns the main word (the maximal run along dir through the staged tiles) and the set of every
* through a staged tile) cover. Under the **single-word rule** (multipleWords = false) cross words * cell the main word and each cross word (a perpendicular run of two or more cells through a
* are omitted, matching the engine, which ignores them entirely — a perpendicular run there is * staged tile) cover. Under the **single-word rule** (multipleWords = false) cross words are
* neither validated nor scored (and need not even be a real word), so highlighting it would be * omitted, matching the engine, which ignores them entirely — a perpendicular run there is neither
* misleading. It returns null when nothing is staged, or when the staged tiles are not on a single * validated nor scored (and need not even be a real word), so highlighting it would be misleading.
* 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 * dir must be the engine's own answer rather than a local guess: for a lone staged tile under the
* colinear; a lone tile takes whichever axis forms the longer word. * 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( export function formedGeometry(
board: Board, board: Board,
pending: readonly Cell[], pending: readonly Cell[],
multipleWords: boolean, multipleWords: boolean,
dir: Dir,
): FormedGeometry | null { ): FormedGeometry | null {
if (pending.length === 0) return null; if (pending.length === 0) return null;
const filled = filledPredicate(board, pending);
const first = pending[0]; 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; const filled = filledPredicate(board, pending);
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 cells = new Set<string>(); const cells = new Set<string>();
// Main word: the maximal run along the play axis through the staged tiles (contiguous via any // Main word: the maximal run along the play axis through the staged tiles (contiguous via any