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.
485 lines
19 KiB
TypeScript
485 lines
19 KiB
TypeScript
// The offline game engine — a faithful port of backend/internal/engine/game.go. It owns the
|
|
// board, the bag, each seat's hand, the scores, whose turn it is and the move log, applies plays
|
|
// (reusing the already-ported validator lib/dict/validate.ts and generator lib/dict/generate.ts),
|
|
// and detects the end of a game (out-of-tiles / scoreless / resign) with the standard end-of-game
|
|
// rack adjustment. It is pure in-memory logic with no I/O; a local vs_ai game is driven by feeding
|
|
// it the robot's choice (lib/robot/strategy.ts) each turn. The bag RNG is our own deterministic
|
|
// PRNG (see bag.ts), so a game replays from its seed but is not bit-identical to a server game.
|
|
//
|
|
// End-of-game scoring, the winner rule and the rack value are exported as pure functions so they
|
|
// can be pinned against the Go engine (engine.parity.test.ts) on constructed positions.
|
|
|
|
import { LocalBoard } from './board';
|
|
import { Bag } from './bag';
|
|
import { RULESETS } from './ruleset';
|
|
import { generateMoves, GenRack, Both } from '../dict/generate';
|
|
import { validatePlay, type Direction, type Placement, type Ruleset, type Move } from '../dict/validate';
|
|
import { noRepeatScore, playedWordKeys } from '../dict/norepeat';
|
|
import { playDirection } from '../dict/direction';
|
|
import { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums';
|
|
import { BLANK_INDEX } from '../alphabet';
|
|
import type { Dawg } from '../dict/dawg';
|
|
import type { Variant } from '../model';
|
|
|
|
/** scorelessLimit is the number of consecutive scoreless turns (passes and exchanges) that ends
|
|
* a game, mirroring engine.scorelessLimit. */
|
|
export const scorelessLimit = 6;
|
|
|
|
/** EndReason explains why a game finished, using the engine's stable labels. */
|
|
export type EndReason = 'not_over' | 'out_of_tiles' | 'scoreless' | 'resign' | 'aborted';
|
|
|
|
/** Disposition of a dropped-out (resigned/timed-out) seat's tiles in a 3-4 player game. */
|
|
export type DropoutTiles = 'remove' | 'return';
|
|
|
|
/** LocalMove is one recorded turn. tiles/dir are set on a play, count on an exchange. */
|
|
export interface LocalMove {
|
|
player: number;
|
|
action: 'play' | 'pass' | 'exchange' | 'resign';
|
|
dir?: Direction;
|
|
tiles?: Placement[];
|
|
/** ActionPlay only: the main word's first-letter coordinate. */
|
|
mainRow?: number;
|
|
mainCol?: number;
|
|
/** ActionPlay only: the words formed as alphabet-index letter arrays — the main word first, then
|
|
* the cross words. Decoded to glyphs when building the history's MoveRecord. */
|
|
words?: number[][];
|
|
/** ActionExchange only: number of tiles swapped. */
|
|
count?: number;
|
|
/** ActionExchange only: the swapped tiles (alphabet-index bytes, BLANK_INDEX for a blank),
|
|
* recorded so the game can be reconstructed by replaying the journal (see localgame/serialize). */
|
|
exchanged?: number[];
|
|
score: number;
|
|
total: number;
|
|
}
|
|
|
|
/** GameError carries a stable code for the engine's rejection reasons. */
|
|
export class GameError extends Error {
|
|
constructor(public readonly code: string) {
|
|
super(code);
|
|
this.name = 'GameError';
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* buildRuleset assembles the validator/generator ruleset for a variant from the static offline
|
|
* tile values (ruleset.ts) and the board geometry (premiums.ts). multipleWords false selects the
|
|
* single-word-per-turn rule (perpendicular cross-words ignored). Mirrors the server engine's
|
|
* ruleset for the same variant; online scoring instead reads the server-sent alphabet values.
|
|
*/
|
|
export function buildRuleset(variant: Variant, multipleWords: boolean): Ruleset {
|
|
const prem = premiumGrid(variant);
|
|
const ctr = centre(variant);
|
|
return {
|
|
cols: BOARD_SIZE,
|
|
center: ctr.row * BOARD_SIZE + ctr.col,
|
|
rackSize: RACK_SIZE,
|
|
bingo: BINGO[variant],
|
|
values: RULESETS[variant].values,
|
|
letterMult: (r, c) => letterMultOf(prem[r][c]),
|
|
wordMult: (r, c) => wordMultOf(prem[r][c]),
|
|
ignoreCrossWords: !multipleWords,
|
|
};
|
|
}
|
|
|
|
/** rackValue sums the tile values left on a hand; blanks (BLANK_INDEX) count zero. Mirrors
|
|
* engine (*Game).rackValue. */
|
|
export function rackValue(hand: readonly number[], values: readonly number[]): number {
|
|
let v = 0;
|
|
for (const t of hand) if (t !== BLANK_INDEX) v += values[t];
|
|
return v;
|
|
}
|
|
|
|
/**
|
|
* applyEndAdjustment settles the unplayed racks and returns the adjusted scores. Out-of-tiles: the
|
|
* player who went out (toMove) gains the sum of every opponent's rack value and each opponent loses
|
|
* their own. Scoreless: everyone loses their own rack value. Resign/aborted: no adjustment.
|
|
* Mirrors engine (*Game).applyEndAdjustment.
|
|
*/
|
|
export function applyEndAdjustment(
|
|
reason: EndReason,
|
|
hands: readonly (readonly number[])[],
|
|
scores: readonly number[],
|
|
toMove: number,
|
|
values: readonly number[],
|
|
): number[] {
|
|
const out = [...scores];
|
|
if (reason === 'out_of_tiles') {
|
|
let bonus = 0;
|
|
for (let i = 0; i < hands.length; i++) {
|
|
if (i === toMove) continue;
|
|
const v = rackValue(hands[i], values);
|
|
out[i] -= v;
|
|
bonus += v;
|
|
}
|
|
out[toMove] += bonus;
|
|
} else if (reason === 'scoreless') {
|
|
for (let i = 0; i < hands.length; i++) out[i] -= rackValue(hands[i], values);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* winner returns the index of the single highest-scoring seat, or -1 on a tie for the lead, an
|
|
* unfinished game or an aborted (draw) game. Resigned seats are excluded, so a two-player game
|
|
* returns the remaining player even when the resigner led. Mirrors engine (*Game).winner.
|
|
*/
|
|
export function winner(over: boolean, reason: EndReason, scores: readonly number[], resigned: readonly boolean[]): number {
|
|
if (!over || reason === 'aborted') return -1;
|
|
let best = -1;
|
|
let tie = false;
|
|
for (let i = 0; i < scores.length; i++) {
|
|
if (resigned[i]) continue;
|
|
if (best === -1 || scores[i] > scores[best]) {
|
|
best = i;
|
|
tie = false;
|
|
} else if (scores[i] === scores[best]) {
|
|
tie = true;
|
|
}
|
|
}
|
|
return tie ? -1 : best;
|
|
}
|
|
|
|
/** Options for a new local game. */
|
|
export interface LocalGameOptions {
|
|
variant: Variant;
|
|
version: string;
|
|
/** Seeds the bag; the whole game replays from it. Also the strategy seed the caller uses. */
|
|
seed: bigint;
|
|
players: number;
|
|
dawg: Dawg;
|
|
multipleWords: boolean;
|
|
/** Forbids laying a word that is already on the board (the Erudit rule, see dict/norepeat).
|
|
* Pinned per game, so a game started before the rule existed replays and scores unchanged;
|
|
* absent reads as off. */
|
|
noRepeatWords?: boolean;
|
|
dropoutTiles?: DropoutTiles;
|
|
}
|
|
|
|
// placementTiles maps placements to the tiles they consume (BLANK_INDEX for a blank).
|
|
function placementTiles(tiles: readonly Placement[]): number[] {
|
|
return tiles.map((p) => (p.blank ? BLANK_INDEX : p.letter));
|
|
}
|
|
|
|
/**
|
|
* LocalGame is the in-memory state of one local match and the rules engine over it. Construct it
|
|
* with a loaded dictionary; drive it with play/pass/exchange/resign. It performs no I/O.
|
|
*/
|
|
export class LocalGame {
|
|
readonly variant: Variant;
|
|
readonly version: string;
|
|
readonly seed: bigint;
|
|
private readonly vrs: Ruleset;
|
|
private readonly values: readonly number[];
|
|
private readonly rackSize: number;
|
|
private readonly dawg: Dawg;
|
|
private readonly multipleWords: boolean;
|
|
private readonly noRepeatWords: boolean;
|
|
private readonly dropoutTiles: DropoutTiles;
|
|
|
|
private readonly board: LocalBoard;
|
|
private readonly bag: Bag;
|
|
private readonly hands: number[][];
|
|
private readonly scores: number[];
|
|
private readonly resigned: boolean[];
|
|
private toMove = 0;
|
|
private scorelessRun = 0;
|
|
private over = false;
|
|
private reason: EndReason = 'not_over';
|
|
private readonly log: LocalMove[] = [];
|
|
|
|
constructor(opts: LocalGameOptions) {
|
|
if (opts.players < 2 || opts.players > 4) {
|
|
throw new GameError('players_out_of_range');
|
|
}
|
|
this.variant = opts.variant;
|
|
this.version = opts.version;
|
|
this.seed = opts.seed;
|
|
this.dawg = opts.dawg;
|
|
this.multipleWords = opts.multipleWords;
|
|
this.noRepeatWords = opts.noRepeatWords ?? false;
|
|
this.dropoutTiles = opts.dropoutTiles ?? 'remove';
|
|
this.vrs = buildRuleset(opts.variant, opts.multipleWords);
|
|
this.values = RULESETS[opts.variant].values;
|
|
this.rackSize = RULESETS[opts.variant].rackSize;
|
|
|
|
this.board = new LocalBoard();
|
|
this.bag = new Bag(opts.variant, Number(BigInt.asUintN(32, opts.seed)));
|
|
this.hands = [];
|
|
this.scores = [];
|
|
this.resigned = [];
|
|
for (let i = 0; i < opts.players; i++) {
|
|
this.hands.push(this.bag.draw(this.rackSize));
|
|
this.scores.push(0);
|
|
this.resigned.push(false);
|
|
}
|
|
}
|
|
|
|
// --- queries ---------------------------------------------------------------
|
|
get playerCount(): number {
|
|
return this.hands.length;
|
|
}
|
|
get currentPlayer(): number {
|
|
return this.toMove;
|
|
}
|
|
get isOver(): boolean {
|
|
return this.over;
|
|
}
|
|
get endReason(): EndReason {
|
|
return this.reason;
|
|
}
|
|
get bagLength(): number {
|
|
return this.bag.length;
|
|
}
|
|
get moveCount(): number {
|
|
return this.log.length;
|
|
}
|
|
scoreOf(player: number): number {
|
|
return this.scores[player];
|
|
}
|
|
/** rackOf returns a copy of a seat's hand (alphabet-index bytes, BLANK_INDEX for blanks). */
|
|
handOf(player: number): number[] {
|
|
return [...this.hands[player]];
|
|
}
|
|
/** resignedOf reports whether a seat has resigned or been excluded by the host. */
|
|
resignedOf(player: number): boolean {
|
|
return this.resigned[player] ?? false;
|
|
}
|
|
/** winnerIndex is the finished game's winner, or -1 (tie / in progress / aborted). */
|
|
get winnerIndex(): number {
|
|
return winner(this.over, this.reason, this.scores, this.resigned);
|
|
}
|
|
/** config returns the rule settings the game was created with — the bits, alongside the seed and
|
|
* journal, needed to reconstruct it (see localgame/serialize). */
|
|
get config(): { multipleWords: boolean; noRepeatWords: boolean; dropoutTiles: DropoutTiles } {
|
|
return { multipleWords: this.multipleWords, noRepeatWords: this.noRepeatWords, dropoutTiles: this.dropoutTiles };
|
|
}
|
|
/** history returns a copy of the move log. */
|
|
get history(): LocalMove[] {
|
|
return this.log.map((m) => ({ ...m }));
|
|
}
|
|
|
|
/** generateMoves returns every legal play for the current player, ranked by descending score.
|
|
* Under the no-repeat-words rule the plays it forbids are dropped and the rest are rescored and
|
|
* re-ranked, so neither the robot nor the hint can offer a move the engine would then refuse. */
|
|
generateMoves(): Move[] {
|
|
const moves = generateMoves(this.dawg, this.board, this.rackOf(this.toMove), this.vrs, Both);
|
|
if (!this.noRepeatWords) return moves;
|
|
const played = this.playedKeys();
|
|
const out: Move[] = [];
|
|
for (const m of moves) {
|
|
const score = noRepeatScore(m, played);
|
|
if (score === null) continue;
|
|
out.push(score === m.score ? m : { ...m, score });
|
|
}
|
|
// A stable sort, so plays the generator ranked equally keep its order.
|
|
return out.sort((a, b) => b.score - a.score);
|
|
}
|
|
|
|
/** playedKeys is the no-repeat-words rule's lookup set: every word this game has already
|
|
* formed, main words and cross-words alike (see dict/norepeat). */
|
|
private playedKeys(): ReadonlySet<string> {
|
|
const words: number[][] = [];
|
|
for (const m of this.log) {
|
|
if (m.action === 'play' && m.words) words.push(...m.words);
|
|
}
|
|
return playedWordKeys(words);
|
|
}
|
|
|
|
/** evaluatePlay scores a candidate placement for the current position without committing it,
|
|
* returning its legality (dictionary + connectivity), score, the words it forms (as alphabet-index
|
|
* arrays, main first) and the inferred direction. Backs the local move preview. */
|
|
evaluatePlay(tiles: Placement[]): {
|
|
legal: boolean;
|
|
score: number;
|
|
words: number[][];
|
|
dir: Direction;
|
|
/** The main word when the no-repeat-words rule is what rejects the play: a real word the game
|
|
* has already used. Absent on every other outcome. */
|
|
repeated?: number[];
|
|
} {
|
|
const dir = playDirection(this.board, this.vrs, this.dawg, tiles);
|
|
const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
|
|
if (!res.legal || !res.move) return { legal: false, score: 0, words: [], dir };
|
|
const m = res.move;
|
|
const score = this.noRepeatWords ? noRepeatScore(m, this.playedKeys()) : m.score;
|
|
if (score === null) return { legal: false, score: 0, words: [], dir, repeated: m.main.letters };
|
|
return { legal: true, score, words: [m.main.letters, ...m.cross.map((w) => w.letters)], dir };
|
|
}
|
|
|
|
/** dictionaryHas reports whether the word (alphabet-index letters) is in the game's dictionary. */
|
|
dictionaryHas(word: readonly number[]): boolean {
|
|
return this.dawg.indexOf(word) >= 0;
|
|
}
|
|
|
|
// --- turns -----------------------------------------------------------------
|
|
|
|
/** submitPlay infers the play's orientation from the placement (like the server's SubmitPlay),
|
|
* then plays it — the client submits tiles without a direction and the engine resolves it. */
|
|
submitPlay(tiles: Placement[]): LocalMove {
|
|
const dir = playDirection(this.board, this.vrs, this.dawg, tiles);
|
|
return this.play(dir, tiles);
|
|
}
|
|
|
|
/** play validates and applies the current player's placement, scores it, refills the rack and
|
|
* advances the turn (or ends the game). Throws GameError on an illegal play. */
|
|
play(dir: Direction, tiles: Placement[]): LocalMove {
|
|
if (this.over) throw new GameError('game_over');
|
|
const player = this.toMove;
|
|
const used = placementTiles(tiles);
|
|
if (!this.holds(player, used)) throw new GameError('tiles_not_on_rack');
|
|
|
|
const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
|
|
if (!res.legal || !res.move) throw new GameError('illegal_play');
|
|
const move = res.move;
|
|
const score = this.noRepeatWords ? noRepeatScore(move, this.playedKeys()) : move.score;
|
|
if (score === null) throw new GameError('illegal_play');
|
|
|
|
for (const t of tiles) this.board.set(t.row, t.col, t.letter, t.blank);
|
|
this.removeFromHand(player, used);
|
|
this.scores[player] += score;
|
|
this.refill(player);
|
|
this.scorelessRun = 0;
|
|
|
|
const rec: LocalMove = {
|
|
player,
|
|
action: 'play',
|
|
dir,
|
|
tiles: tiles.map((t) => ({ ...t })),
|
|
mainRow: move.main.row,
|
|
mainCol: move.main.col,
|
|
words: [move.main.letters, ...move.cross.map((w) => w.letters)],
|
|
score,
|
|
total: this.scores[player],
|
|
};
|
|
this.log.push(rec);
|
|
|
|
if (this.hands[player].length === 0 && this.bag.length === 0) this.finish('out_of_tiles');
|
|
else this.advance();
|
|
return rec;
|
|
}
|
|
|
|
/** pass forfeits the current turn, extending the scoreless run (which may end the game). */
|
|
pass(): LocalMove {
|
|
if (this.over) throw new GameError('game_over');
|
|
const player = this.toMove;
|
|
this.scorelessRun++;
|
|
const rec: LocalMove = { player, action: 'pass', score: 0, total: this.scores[player] };
|
|
this.log.push(rec);
|
|
this.endTurnAfterScoreless();
|
|
return rec;
|
|
}
|
|
|
|
/** exchange swaps the given tiles (alphabet-index bytes, BLANK_INDEX for blanks) for fresh ones.
|
|
* Legal only while the bag holds at least a full rack; the fresh tiles are drawn before the
|
|
* swapped ones return, so a player cannot draw back their own. Extends the scoreless run. */
|
|
exchange(tiles: number[]): LocalMove {
|
|
if (this.over) throw new GameError('game_over');
|
|
if (tiles.length === 0) throw new GameError('nothing_to_exchange');
|
|
if (this.bag.length < this.rackSize) throw new GameError('not_enough_tiles_to_exchange');
|
|
const player = this.toMove;
|
|
if (!this.holds(player, tiles)) throw new GameError('tiles_not_on_rack');
|
|
|
|
this.removeFromHand(player, tiles);
|
|
const drawn = this.bag.draw(tiles.length);
|
|
for (const t of drawn) this.hands[player].push(t);
|
|
this.bag.return(tiles);
|
|
this.scorelessRun++;
|
|
|
|
const rec: LocalMove = { player, action: 'exchange', count: tiles.length, exchanged: [...tiles], score: 0, total: this.scores[player] };
|
|
this.log.push(rec);
|
|
this.endTurnAfterScoreless();
|
|
return rec;
|
|
}
|
|
|
|
/** resign drops the current player out of the game (they forfeit the win, keep their score). */
|
|
resign(): LocalMove {
|
|
return this.resignSeat(this.toMove);
|
|
}
|
|
|
|
/** resignSeat resigns a specific seat regardless of whose turn it is. */
|
|
resignSeat(seat: number): LocalMove {
|
|
if (this.over) throw new GameError('game_over');
|
|
if (seat < 0 || seat >= this.hands.length || this.resigned[seat]) throw new GameError('game_over');
|
|
this.resigned[seat] = true;
|
|
this.disposeHand(seat);
|
|
const rec: LocalMove = { player: seat, action: 'resign', score: 0, total: this.scores[seat] };
|
|
this.log.push(rec);
|
|
if (this.activeCount() <= 1) this.finish('resign');
|
|
else if (seat === this.toMove) this.advance();
|
|
return rec;
|
|
}
|
|
|
|
// --- internals -------------------------------------------------------------
|
|
|
|
private rackOf(player: number): GenRack {
|
|
const letters: number[] = [];
|
|
let blanks = 0;
|
|
for (const t of this.hands[player]) {
|
|
if (t === BLANK_INDEX) blanks++;
|
|
else letters.push(t);
|
|
}
|
|
return GenRack.from(this.values.length, letters, blanks);
|
|
}
|
|
|
|
private finish(reason: EndReason): void {
|
|
this.over = true;
|
|
this.reason = reason;
|
|
const adjusted = applyEndAdjustment(reason, this.hands, this.scores, this.toMove, this.values);
|
|
for (let i = 0; i < adjusted.length; i++) this.scores[i] = adjusted[i];
|
|
}
|
|
|
|
private endTurnAfterScoreless(): void {
|
|
if (this.scorelessRun >= scorelessLimit) this.finish('scoreless');
|
|
else this.advance();
|
|
}
|
|
|
|
private advance(): void {
|
|
const n = this.hands.length;
|
|
for (let i = 1; i <= n; i++) {
|
|
const next = (this.toMove + i) % n;
|
|
if (!this.resigned[next]) {
|
|
this.toMove = next;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
private activeCount(): number {
|
|
return this.resigned.reduce((n, r) => (r ? n : n + 1), 0);
|
|
}
|
|
|
|
private disposeHand(player: number): void {
|
|
if (this.dropoutTiles === 'return') this.bag.return(this.hands[player]);
|
|
this.hands[player] = [];
|
|
}
|
|
|
|
private holds(player: number, want: readonly number[]): boolean {
|
|
const avail = new Map<number, number>();
|
|
for (const t of this.hands[player]) avail.set(t, (avail.get(t) ?? 0) + 1);
|
|
const need = new Map<number, number>();
|
|
for (const t of want) need.set(t, (need.get(t) ?? 0) + 1);
|
|
for (const [t, n] of need) if ((avail.get(t) ?? 0) < n) return false;
|
|
return true;
|
|
}
|
|
|
|
private removeFromHand(player: number, used: readonly number[]): void {
|
|
const hand = this.hands[player];
|
|
for (const t of used) {
|
|
const i = hand.indexOf(t);
|
|
if (i >= 0) hand.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
private refill(player: number): void {
|
|
const need = this.rackSize - this.hands[player].length;
|
|
if (need > 0) for (const t of this.bag.draw(need)) this.hands[player].push(t);
|
|
}
|
|
}
|