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).
74 lines
2.8 KiB
TypeScript
74 lines
2.8 KiB
TypeScript
// Play-direction inference, ported from the backend engine (direction.go
|
|
// resolveDirection/runLength and domain.go (*Game).playDirection, v1.1.1). The
|
|
// client sends tiles without an orientation; the server infers it, so the local
|
|
// move preview must infer the same one or its words and score would diverge.
|
|
|
|
import { validatePlay, Horizontal, Vertical, type Board, type Ruleset, type Placement, type Direction, type Dict } from './validate';
|
|
|
|
// runLength mirrors engine.runLength: how many cells the word through (row, col)
|
|
// along dir spans, counting the target square plus the filled runs either side.
|
|
function runLength(b: Board, row: number, col: number, dir: Direction): number {
|
|
let dr = 0;
|
|
let dc = 1;
|
|
if (dir === Vertical) {
|
|
dr = 1;
|
|
dc = 0;
|
|
}
|
|
let n = 1;
|
|
for (let r = row - dr, c = col - dc; b.filled(r, c); r -= dr, c -= dc) n++;
|
|
for (let r = row + dr, c = col + dc; b.filled(r, c); r += dr, c += dc) n++;
|
|
return n;
|
|
}
|
|
|
|
/**
|
|
* resolveDirection infers a play's orientation from geometry alone, mirroring
|
|
* engine.resolveDirection: two or more tiles by the line they share; a single tile
|
|
* by the axis along which it abuts existing tiles, preferring the longer word and
|
|
* horizontal on a tie; a tile abutting nothing falls back to horizontal.
|
|
*/
|
|
export function resolveDirection(b: Board, placements: Placement[]): Direction {
|
|
if (placements.length >= 2) {
|
|
const row = placements[0].row;
|
|
for (let i = 1; i < placements.length; i++) {
|
|
if (placements[i].row !== row) return Vertical;
|
|
}
|
|
return Horizontal;
|
|
}
|
|
if (placements.length === 1) {
|
|
const p = placements[0];
|
|
const h = runLength(b, p.row, p.col, Horizontal);
|
|
const v = runLength(b, p.row, p.col, Vertical);
|
|
if (v >= 2 && v > h) return Vertical;
|
|
if (h >= 2) return Horizontal;
|
|
if (v >= 2) return Vertical;
|
|
}
|
|
return Horizontal;
|
|
}
|
|
|
|
/**
|
|
* playDirection resolves the orientation for a live play, mirroring engine
|
|
* (*Game).playDirection. It is the geometric resolution, except a single tile under
|
|
* the single-word rule (ignoreCrossWords) is ambiguous — its two orientations may
|
|
* differ in legality — so both are tried through the validator and the
|
|
* higher-scoring legal one is kept (horizontal breaks a tie).
|
|
*/
|
|
export function playDirection(b: Board, rs: Ruleset, dict: Dict, placements: Placement[]): Direction {
|
|
const geo = resolveDirection(b, placements);
|
|
if (placements.length !== 1 || !rs.ignoreCrossWords) {
|
|
return geo;
|
|
}
|
|
let best = geo;
|
|
let found = false;
|
|
let bestScore = 0;
|
|
for (const dir of [Horizontal, Vertical] as Direction[]) {
|
|
const r = validatePlay(b, rs, dict, dir, placements);
|
|
if (!r.legal || !r.move) continue;
|
|
if (!found || r.move.score > bestScore) {
|
|
best = dir;
|
|
found = true;
|
|
bestScore = r.move.score;
|
|
}
|
|
}
|
|
return best;
|
|
}
|