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
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:
@@ -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