fix(ui): highlight the word the engine played, not the run it abuts
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 1m20s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m58s
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 1m20s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m58s
The board's green highlight re-derived the play's orientation itself
("the longer run wins"), while the engine settles a lone staged tile
under the single-word rule through the validator, because its two
orientations can differ in legality. A vertical play that merely abuts a
longer horizontal word therefore greened that horizontal run — a word
the play does not form — and anchored the score badge on it, while the
caption, the score and the committed move all stayed correct.
formedGeometry now takes the orientation from the preview, which every
preview source already carries (EvalResult.dir: the on-device evaluator,
the network evaluate and a reused hint), and no longer infers it. The
rule, not the transport, is the trigger, so this fixes online one-word
games as well as offline ones.
A differential test now walks random positions, asks the engine port for
every legal play it can find, and asserts the highlighted cells are
exactly the cells of the words the engine says that play forms.
This commit is contained in:
@@ -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<string>();
|
||||
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);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -20,14 +20,24 @@ const cells = (g: ReturnType<typeof formedGeometry>) => [...(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', () => {
|
||||
|
||||
+28
-28
@@ -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<string>();
|
||||
|
||||
// Main word: the maximal run along the play axis through the staged tiles (contiguous via any
|
||||
|
||||
Reference in New Issue
Block a user