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
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).
105 lines
4.2 KiB
TypeScript
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' };
|
|
}
|