4f4768b092
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Failing after 13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
Composing an already-played word read as a perfectly legal move: the tiles
drew as legal, the caption showed "БРА = 6" and the ✅ was enabled, and
only the server refused the play with the bare "illegal move" toast.
The on-device evaluator already applied the rule, but it reported the
refusal as a plain illegal play, so nothing distinguished a repeated word
from a misspelling and the caption fell back to the turn label. It now
names the word it rejected, and the game screen reads
"БРА: уже использовано" above the scores while the tiles stay marked
invalid — the composition is wrong, but for a reason the player cannot
read off the board, since the word itself is in the dictionary. The
verdict is reached on the device before the play is ever offered to the
server, in an online game exactly as in an offline one; the offline
source reports the same reason through the same field.
Only the main word is ever refused: a cross-word the game has already
used still stands (it is incidental to laying the main word) and merely
scores nothing, so the rule cannot falsely block a play in a game with
several words per turn.
The scoring of such a play is now pinned to the point on both sides: the
engine test and the client test evaluate the same position — РОТ laid
across a standing КО, completing an already-played КОТ — and assert the
same totals (10 unrestricted, 5 with the rule), so a divergence between
the two engines breaks one of them. The client test also replays the
exact test-contour position this was reported from.
117 lines
5.0 KiB
TypeScript
117 lines
5.0 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 { noRepeatScore } from './norepeat';
|
|
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: readonly 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. playedWords is the game's already-played words (decoded, as the
|
|
* history carries them) when the game takes the no-repeat-words rule, and absent
|
|
* otherwise — with it a play repeating a word is reported illegal and an already-played
|
|
* cross-word scores nothing, exactly as the server decides. 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,
|
|
playedWords?: ReadonlySet<string>,
|
|
): 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));
|
|
const score = playedWords ? noRepeatScore(m, playedWords, (l) => decodeWord(variant, l)) : m.score;
|
|
if (score === null) {
|
|
// A real word, but one this game has already used: illegal, and named so the caption can say
|
|
// why rather than leaving the player staring at a word they know is in the dictionary.
|
|
return { legal: false, score: 0, words: [], dir: '', repeatedWord: words[0] };
|
|
}
|
|
return { legal: true, score, words, dir: dir === Horizontal ? 'H' : 'V' };
|
|
}
|