Merge pull request 'release: promote development → master (v1.25.1)' (#292) from development into master
This commit was merged in pull request #292.
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
|
// 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);
|
||||||
|
|||||||
@@ -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', () => {
|
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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user