Files
scrabble-game/ui/src/lib/dict/eval.ts
T
Ilia Denisov 5689f7f6a3
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
feat: on-device move preview (local eval) with network fallback
Score and validate a tentative move on-device instead of a per-arrangement network
round trip. The dawg reader and the validate/score/direction slice of the
scrabble-solver engine are ported to TypeScript (ui/src/lib/dict), pinned
byte-for-byte to the Go engine by a `conformance` CI job (full-dictionary reader
parity plus a battery of plays across every variant and both cross-word rules,
including the inferred orientation). The server stays authoritative — submit_play
re-validates — so the local result is an advisory accelerator only.

- backend: Registry.DictBytes + an authed GET /api/v1/user/dict/{variant}/{version}
  (immutable) streaming the pinned per-game dawg; caddy routes /dict to the gateway.
- gateway: a session-gated /dict edge route proxying it; fetchDict on the transport.
- client: the dictionary loads on game open (low priority so it never starves the
  game on a slow link; aborted at a 5s cap or when leaving the game), is cached in
  IndexedDB (best-effort, self-healing on a rejected blob) and reused across
  sessions; a warm-up overlay covers a cold load, then the network preview is the
  fallback; a bad-connection breaker stops warming after repeated misses; the move
  preview cancels its in-flight request when the tiles change.
- parity generators backend/cmd/{dictgen,validategen} + gated Vitest suites, run in
  CI against the release dictionaries. A hidden debug readout lists the cached
  dictionaries + breaker state, and its reset clears the cache.
- docs: ARCHITECTURE §5, TESTING, UI_DESIGN, FUNCTIONAL (+ru).
2026-07-01 22:58:40 +02:00

105 lines
4.2 KiB
TypeScript

// The local move preview: it produces the same EvalResult the network `evaluate`
// returns (legality, score, the words formed, the inferred orientation), computed
// on-device from a loaded dictionary so composing a move needs no round trip. It
// adapts the client's letter-space board and placements into the validator's
// index space (lib/dict/validate.ts), assembling the per-variant ruleset from the
// static geometry/constants (lib/premiums.ts) and the server-sent tile values
// (lib/alphabet.ts). The server stays authoritative — submit re-validates — so any
// throw here is caught by the caller, which falls back to the network preview.
import { validatePlay, Horizontal, type Board as VBoard, type Ruleset, type Placement as VPlacement, type Dict } from './validate';
import { playDirection } from './direction';
import type { Board as ClientBoard } from '../board';
import type { PlacedTile } from '../client';
import type { EvalResult, Variant } from '../model';
import { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums';
import { alphabetValues, indexForLetter, letterForIndex } from '../alphabet';
function letterMultOf(p: Premium): number {
return p === 'DL' ? 2 : p === 'TL' ? 3 : 1;
}
function wordMultOf(p: Premium): number {
return p === 'DW' ? 2 : p === 'TW' ? 3 : 1;
}
// buildBoard adapts the client's letter-space board into the validator's
// index-space read view, precomputing the letter indices once.
function buildBoard(variant: Variant, board: ClientBoard): VBoard {
const grid: ({ letter: number; blank: boolean } | null)[] = new Array(BOARD_SIZE * BOARD_SIZE).fill(null);
let empty = true;
for (let r = 0; r < BOARD_SIZE; r++) {
for (let c = 0; c < BOARD_SIZE; c++) {
const cell = board[r][c];
if (cell) {
grid[r * BOARD_SIZE + c] = { letter: indexForLetter(variant, cell.letter), blank: cell.blank };
empty = false;
}
}
}
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[r * BOARD_SIZE + c] !== null,
cellAt: (r, c) => grid[r * BOARD_SIZE + c]!,
isEmpty: () => empty,
};
}
// buildRuleset assembles the per-variant scoring rules the validator needs.
function buildRuleset(variant: Variant, multipleWords: boolean): Ruleset {
const prem = premiumGrid(variant);
const ctr = centre(variant);
const values = alphabetValues(variant);
return {
cols: BOARD_SIZE,
center: ctr.row * BOARD_SIZE + ctr.col,
rackSize: RACK_SIZE,
bingo: BINGO[variant],
values,
letterMult: (r, c) => letterMultOf(prem[r][c]),
wordMult: (r, c) => wordMultOf(prem[r][c]),
ignoreCrossWords: !multipleWords,
};
}
// decodeWord turns a word's alphabet indices back into a lower-cased string, the
// form the network EvalResult carries (the caption renders it directly).
function decodeWord(variant: Variant, letters: number[]): string {
let s = '';
for (const i of letters) s += letterForIndex(variant, i);
return s.toLowerCase();
}
/**
* evaluateLocal computes the move preview for tiles placed on board in the given
* game, returning the same shape as the network `evaluate`. dict is the loaded
* dictionary reader for the game's (variant, version); multipleWords is the game's
* cross-word rule. It may throw if the variant's alphabet table is not loaded — the
* caller guards with hasAlphabet and falls back to the network on any failure.
*/
export function evaluateLocal(
dict: Dict,
variant: Variant,
board: ClientBoard,
tiles: PlacedTile[],
multipleWords: boolean,
): EvalResult {
const vboard = buildBoard(variant, board);
const rs = buildRuleset(variant, multipleWords);
const vtiles: VPlacement[] = tiles.map((t) => ({
row: t.row,
col: t.col,
letter: indexForLetter(variant, t.letter),
blank: t.blank,
}));
const dir = playDirection(vboard, rs, dict, vtiles);
const res = validatePlay(vboard, rs, dict, dir, vtiles);
if (!res.legal || !res.move) {
return { legal: false, score: 0, words: [], dir: '' };
}
const m = res.move;
const words = [m.main, ...m.cross].map((w) => decodeWord(variant, w.letters));
return { legal: true, score: m.score, words, dir: dir === Horizontal ? 'H' : 'V' };
}