From e4cf143e9fecf50458dec9e0e2bdeb5fd29c66a0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 08:02:05 +0200 Subject: [PATCH] feat(offline): local game engine (Phase B1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The offline vs_ai game engine — a faithful TS port of backend/internal/engine that drives a whole local game with no backend. Composes with the move generator (#188) and robot strategy (#189) from Phase A; not yet wired into the UI (Phase B2/B3). - ui/src/lib/localgame/ruleset.ts: static per-variant tile values / bag counts / blank count, mirrored from rules.go (offline scoring is self-contained; online uses the server alphabet). Pinned by ruleset.parity.test.ts against a Go fixture. - bag.ts: the tile bag (fill from counts/blanks, draw-from-end, return+reshuffle) on a deterministic in-house PRNG — a game replays from its seed, not bit-identical to a server game (per plan). - board.ts: the mutable board, satisfying the validator/generator read view + set(). - engine.ts: LocalGame — deal / play (reusing validate.ts) / pass / exchange / resign, scoreless(6) & out-of-tiles end detection, end-of-game rack penalties, winner; mirrors game.go. The end-game math is exported as pure functions, pinned against the Go engine (engine.parity.test.ts, 9 constructed positions). - engine.test.ts: a full-loop smoke — two robots play a whole vs_ai game to completion via generateMoves + decide, and it is reproducible from the seed. - backend: movegen now dumps the per-variant rulesets; a new in-package engine emitter (endfixture_test.go, env-gated) produces the end-game golden. Pure additive library code; no runtime behavior change (unused at runtime, bundle unchanged). --- backend/cmd/movegen/main.go | 33 ++ backend/internal/engine/endfixture_test.go | 123 ++++++ ui/src/lib/localgame/bag.test.ts | 50 +++ ui/src/lib/localgame/bag.ts | 76 ++++ ui/src/lib/localgame/board.ts | 44 +++ ui/src/lib/localgame/engine.parity.test.ts | 35 ++ ui/src/lib/localgame/engine.test.ts | 78 ++++ ui/src/lib/localgame/engine.ts | 395 ++++++++++++++++++++ ui/src/lib/localgame/ruleset.parity.test.ts | 37 ++ ui/src/lib/localgame/ruleset.ts | 56 +++ ui/src/lib/localgame/testdata/endgame.json | 246 ++++++++++++ ui/src/lib/localgame/testdata/rulesets.json | 216 +++++++++++ 12 files changed, 1389 insertions(+) create mode 100644 backend/internal/engine/endfixture_test.go create mode 100644 ui/src/lib/localgame/bag.test.ts create mode 100644 ui/src/lib/localgame/bag.ts create mode 100644 ui/src/lib/localgame/board.ts create mode 100644 ui/src/lib/localgame/engine.parity.test.ts create mode 100644 ui/src/lib/localgame/engine.test.ts create mode 100644 ui/src/lib/localgame/engine.ts create mode 100644 ui/src/lib/localgame/ruleset.parity.test.ts create mode 100644 ui/src/lib/localgame/ruleset.ts create mode 100644 ui/src/lib/localgame/testdata/endgame.json create mode 100644 ui/src/lib/localgame/testdata/rulesets.json diff --git a/backend/cmd/movegen/main.go b/backend/cmd/movegen/main.go index 0898722..5e5dfe2 100644 --- a/backend/cmd/movegen/main.go +++ b/backend/cmd/movegen/main.go @@ -121,6 +121,8 @@ func main() { return } + emitRulesets() + buildSample(*out, "en", rules.English(), sampleWordsEN, []genCase{ emptyCase("empty-cared", englishRack("caredts", 0), scrabble.Both, false), emptyCase("empty-dogs", englishRack("dogsent", 0), scrabble.Both, false), @@ -363,6 +365,37 @@ func russianRack(letters string, blanks int) genRack { return genRack{Letters: idx, Blanks: blanks} } +// emitRulesets writes the per-variant static ruleset data (tile values, bag counts, blanks, +// bingo, rack size) the offline engine mirrors in ui/src/lib/localgame/ruleset.ts, so a TS +// parity test can pin that hand-copied table to the Go rulesets (scrabble-solver/rules). +func emitRulesets() { + type rsFix struct { + Size int `json:"size"` + RackSize int `json:"rackSize"` + Bingo int `json:"bingo"` + Blanks int `json:"blanks"` + Values []int `json:"values"` + Counts []int `json:"counts"` + } + out := map[string]rsFix{} + for _, v := range []struct { + name string + rs *rules.Ruleset + }{ + {"scrabble_en", rules.English()}, + {"scrabble_ru", rules.RussianScrabble()}, + {"erudit_ru", rules.Erudit()}, + } { + out[v.name] = rsFix{Size: v.rs.Size(), RackSize: v.rs.RackSize, Bingo: v.rs.Bingo, Blanks: v.rs.Blanks, Values: v.rs.Values, Counts: v.rs.Counts} + } + dir := filepath.Join("ui", "src", "lib", "localgame", "testdata") + if err := os.MkdirAll(dir, 0o755); err != nil { + log.Fatalf("movegen: mkdir %s: %v", dir, err) + } + writeJSON(filepath.Join(dir, "rulesets.json"), out) + log.Printf("movegen: wrote %s (3 variants)", filepath.Join(dir, "rulesets.json")) +} + func writeJSON(path string, v any) { data, err := json.MarshalIndent(v, "", " ") if err != nil { diff --git a/backend/internal/engine/endfixture_test.go b/backend/internal/engine/endfixture_test.go new file mode 100644 index 0000000..f3490f4 --- /dev/null +++ b/backend/internal/engine/endfixture_test.go @@ -0,0 +1,123 @@ +package engine + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "gitea.iliadenisov.ru/developer/scrabble-solver/rules" +) + +// The offline engine (ui/src/lib/localgame) reproduces the end-of-game rack settlement and the +// winner rule so a local game finishes with the same scores as the server. These golden fixtures +// pin the ported pure functions (applyEndAdjustment / winner / rackValue) to the real Go engine. +// Being in-package, this emitter constructs Game values directly and drives the unexported +// end-game math on chosen positions. + +type endCaseIn struct { + Name string `json:"name"` + Variant string `json:"variant"` + Reason string `json:"reason"` + Hands [][]int `json:"hands"` + Scores []int `json:"scores"` + Resigned []bool `json:"resigned"` + ToMove int `json:"toMove"` +} + +type endCaseOut struct { + endCaseIn + ScoresAfter []int `json:"scoresAfter"` + Winner int `json:"winner"` +} + +func rulesetFor(variant string) *rules.Ruleset { + switch variant { + case "scrabble_ru": + return rules.RussianScrabble() + case "erudit_ru": + return rules.Erudit() + default: + return rules.English() + } +} + +func reasonFor(s string) EndReason { + switch s { + case "out_of_tiles": + return EndOutOfTiles + case "scoreless": + return EndScoreless + case "resign": + return EndResign + case "aborted": + return EndAborted + } + return EndNotOver +} + +func handsBytes(hands [][]int) [][]byte { + out := make([][]byte, len(hands)) + for i, h := range hands { + b := make([]byte, len(h)) + for j, x := range h { + b[j] = byte(x) + } + out[i] = b + } + return out +} + +// TestEmitEndgameFixtures regenerates ui/src/lib/localgame/testdata/endgame.json. Gated by +// EMIT_ENGINE_FIXTURES. Regenerate with: +// +// EMIT_ENGINE_FIXTURES=1 go test ./backend/internal/engine -run TestEmitEndgameFixtures +func TestEmitEndgameFixtures(t *testing.T) { + if os.Getenv("EMIT_ENGINE_FIXTURES") == "" { + t.Skip("set EMIT_ENGINE_FIXTURES=1 to regenerate ui/src/lib/localgame/testdata/endgame.json") + } + + cases := []endCaseIn{ + {"out-basic", "scrabble_en", "out_of_tiles", [][]int{{}, {0, 1, 2}}, []int{50, 40}, []bool{false, false}, 0}, + {"out-blank", "scrabble_en", "out_of_tiles", [][]int{{}, {255, 0}}, []int{30, 30}, []bool{false, false}, 0}, + {"out-erudit-yo", "erudit_ru", "out_of_tiles", [][]int{{}, {6, 32}}, []int{10, 10}, []bool{false, false}, 0}, + {"out-tie", "scrabble_en", "out_of_tiles", [][]int{{}, {}}, []int{30, 30}, []bool{false, false}, 0}, + {"out-3p", "scrabble_ru", "out_of_tiles", [][]int{{}, {0, 1}, {2}}, []int{10, 10, 10}, []bool{false, false, false}, 0}, + {"scoreless", "scrabble_en", "scoreless", [][]int{{0, 1}, {2, 3}}, []int{20, 20}, []bool{false, false}, 0}, + {"resign-2p", "scrabble_en", "resign", [][]int{{}, {0}}, []int{100, 10}, []bool{true, false}, 1}, + {"resign-3p", "scrabble_en", "resign", [][]int{{}, {}, {}}, []int{50, 60, 40}, []bool{false, true, false}, 0}, + {"aborted", "scrabble_en", "aborted", [][]int{{0}, {1}}, []int{40, 30}, []bool{false, false}, 0}, + } + + out := make([]endCaseOut, 0, len(cases)) + for _, c := range cases { + rs := rulesetFor(c.Variant) + scores := append([]int(nil), c.Scores...) + g := &Game{ + rules: rs, + hands: handsBytes(c.Hands), + scores: scores, + resigned: c.Resigned, + toMove: c.ToMove, + } + reason := reasonFor(c.Reason) + g.over = true + g.reason = reason + g.applyEndAdjustment(reason) + out = append(out, endCaseOut{endCaseIn: c, ScoresAfter: g.scores, Winner: g.winner()}) + } + + dir := filepath.Join("..", "..", "..", "ui", "src", "lib", "localgame", "testdata") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + data, err := json.MarshalIndent(map[string]any{"cases": out}, "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + path := filepath.Join(dir, "endgame.json") + if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + t.Logf("wrote %s (%d cases)", path, len(out)) +} diff --git a/ui/src/lib/localgame/bag.test.ts b/ui/src/lib/localgame/bag.test.ts new file mode 100644 index 0000000..98b4b23 --- /dev/null +++ b/ui/src/lib/localgame/bag.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { Bag } from './bag'; +import { RULESETS } from './ruleset'; +import { BLANK_INDEX } from '../alphabet'; + +describe('offline bag', () => { + it('holds exactly the variant tile distribution', () => { + const rs = RULESETS.scrabble_en; + const total = rs.counts.reduce((a, b) => a + b, 0) + rs.blanks; + const bag = new Bag('scrabble_en', 42); + expect(bag.length).toBe(total); + + const drawn = bag.draw(total); + expect(drawn.length).toBe(total); + expect(bag.length).toBe(0); + + const tally = new Map(); + for (const t of drawn) tally.set(t, (tally.get(t) ?? 0) + 1); + for (let i = 0; i < rs.counts.length; i++) expect(tally.get(i) ?? 0).toBe(rs.counts[i]); + expect(tally.get(BLANK_INDEX) ?? 0).toBe(rs.blanks); + }); + + it('draws beyond the remaining, returning all and emptying', () => { + const bag = new Bag('scrabble_ru', 7); + const total = bag.length; + const all = bag.draw(total + 5); + expect(all.length).toBe(total); + expect(bag.length).toBe(0); + expect(bag.draw(3)).toEqual([]); + }); + + it('returns tiles back to the bag', () => { + const bag = new Bag('erudit_ru', 1); + const before = bag.length; + const seven = bag.draw(7); + expect(bag.length).toBe(before - 7); + bag.return(seven); + expect(bag.length).toBe(before); + }); + + it('is deterministic for a given seed and operation sequence', () => { + const a = new Bag('scrabble_en', 123); + const b = new Bag('scrabble_en', 123); + expect(a.draw(30)).toEqual(b.draw(30)); + // A return reshuffles both identically, so subsequent draws still agree. + a.return([0, 1, 2]); + b.return([0, 1, 2]); + expect(a.draw(10)).toEqual(b.draw(10)); + }); +}); diff --git a/ui/src/lib/localgame/bag.ts b/ui/src/lib/localgame/bag.ts new file mode 100644 index 0000000..8a3f981 --- /dev/null +++ b/ui/src/lib/localgame/bag.ts @@ -0,0 +1,76 @@ +// The offline tile bag — the shuffled draw pile for one local game. Structurally a port of +// backend/internal/engine/bag.go (fill from the variant's counts + blanks, draw from the end, +// return-and-reshuffle for an exchange), but shuffled with a small DETERMINISTIC in-house PRNG +// rather than Go's math/rand: a local game only needs to be reproducible from its own seed and +// sequence of operations (for replay from the stored journal), not bit-identical to a server +// game (docs plan). Blanks ride as BLANK_INDEX, matching lib/alphabet.ts (and the engine's +// blankTile = 0xff = 255). + +import { RULESETS } from './ruleset'; +import { BLANK_INDEX } from '../alphabet'; +import type { Variant } from '../model'; + +// mulberry32 is a compact deterministic PRNG returning a float in [0, 1). Seeded from the game +// seed so the shuffle sequence — and thus the draws — replay identically for the same seed and +// the same sequence of returns. +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Bag is one local game's draw pile. Construct it with the variant and a numeric seed; draw() + * and return() mutate it in place. It is reproducible: the same seed and the same sequence of + * operations yield the same draws. + */ +export class Bag { + private tiles: number[]; + private readonly rand: () => number; + + constructor(variant: Variant, seed: number) { + const rs = RULESETS[variant]; + const tiles: number[] = []; + for (let i = 0; i < rs.counts.length; i++) { + for (let n = 0; n < rs.counts[i]; n++) tiles.push(i); + } + for (let n = 0; n < rs.blanks; n++) tiles.push(BLANK_INDEX); + this.tiles = tiles; + this.rand = mulberry32(seed); + this.shuffle(); + } + + /** length is the number of tiles left in the bag. */ + get length(): number { + return this.tiles.length; + } + + /** + * draw removes up to n tiles from the end of the bag and returns them. Drawing more than + * remain returns all of them; drawing from an empty bag returns an empty array. + */ + draw(n: number): number[] { + const take = Math.min(n, this.tiles.length); + return this.tiles.splice(this.tiles.length - take, take); + } + + /** return puts tiles back into the bag and reshuffles, as when a player exchanges tiles. */ + return(tiles: readonly number[]): void { + for (const t of tiles) this.tiles.push(t); + this.shuffle(); + } + + // shuffle randomises the remaining tiles in place with the bag's own PRNG (Fisher–Yates). + private shuffle(): void { + for (let i = this.tiles.length - 1; i > 0; i--) { + const j = Math.floor(this.rand() * (i + 1)); + const tmp = this.tiles[i]; + this.tiles[i] = this.tiles[j]; + this.tiles[j] = tmp; + } + } +} diff --git a/ui/src/lib/localgame/board.ts b/ui/src/lib/localgame/board.ts new file mode 100644 index 0000000..c3f98a8 --- /dev/null +++ b/ui/src/lib/localgame/board.ts @@ -0,0 +1,44 @@ +// The mutable local-game board: a 15x15 row-major grid of placed tiles (alphabet-index letter +// plus a blank flag). It satisfies the read view the validator and generator need (GenBoard, +// which extends validate.ts's Board) and adds set() so the engine can apply a play. The board is +// alphabet-agnostic — a cell's letter is a variant index, meaningful with the variant's ruleset. + +import { BOARD_SIZE } from '../premiums'; +import type { Cell } from '../dict/validate'; +import type { GenBoard } from '../dict/generate'; + +/** LocalBoard is the engine's in-memory board; it reads as a GenBoard and applies plays via set. */ +export class LocalBoard implements GenBoard { + readonly rows = BOARD_SIZE; + readonly cols = BOARD_SIZE; + private readonly grid: (Cell | null)[]; + private count = 0; + + constructor() { + this.grid = new Array(BOARD_SIZE * BOARD_SIZE).fill(null); + } + + inBounds(row: number, col: number): boolean { + return row >= 0 && row < this.rows && col >= 0 && col < this.cols; + } + + filled(row: number, col: number): boolean { + return this.inBounds(row, col) && this.grid[row * this.cols + col] !== null; + } + + cellAt(row: number, col: number): Cell { + return this.grid[row * this.cols + col]!; + } + + isEmpty(): boolean { + return this.count === 0; + } + + /** set places a tile at (row, col). It assumes the square was empty (a play only lays tiles + * on empty squares), keeping the filled count exact for isEmpty. */ + set(row: number, col: number, letter: number, blank: boolean): void { + const i = row * this.cols + col; + if (this.grid[i] === null) this.count++; + this.grid[i] = { letter, blank }; + } +} diff --git a/ui/src/lib/localgame/engine.parity.test.ts b/ui/src/lib/localgame/engine.parity.test.ts new file mode 100644 index 0000000..830d679 --- /dev/null +++ b/ui/src/lib/localgame/engine.parity.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { applyEndAdjustment, winner, type EndReason } from './engine'; +import { RULESETS } from './ruleset'; +import type { Variant } from '../model'; + +// The offline engine must settle unplayed racks and decide the winner exactly as the Go engine +// does, so a local game finishes with the same scores. Golden from the in-package emitter +// (backend/internal/engine/endfixture_test.go). +interface EndCase { + name: string; + variant: Variant; + reason: EndReason; + hands: number[][]; + scores: number[]; + resigned: boolean[]; + toMove: number; + scoresAfter: number[]; + winner: number; +} + +const fx = JSON.parse( + readFileSync(new URL('./testdata/endgame.json', import.meta.url), 'utf8'), +) as { cases: EndCase[] }; + +describe('offline engine end-game parity vs Go', () => { + for (const c of fx.cases) { + it(c.name, () => { + const values = RULESETS[c.variant].values; + const after = applyEndAdjustment(c.reason, c.hands, c.scores, c.toMove, values); + expect(after).toEqual(c.scoresAfter); + expect(winner(true, c.reason, after, c.resigned)).toBe(c.winner); + }); + } +}); diff --git a/ui/src/lib/localgame/engine.test.ts b/ui/src/lib/localgame/engine.test.ts new file mode 100644 index 0000000..4da8800 --- /dev/null +++ b/ui/src/lib/localgame/engine.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { Dawg } from '../dict/dawg'; +import { RACK_SIZE } from '../premiums'; +import { LocalGame } from './engine'; +import { decide } from '../robot/strategy'; +import type { Move } from '../dict/validate'; + +// A full-loop smoke: two robots play a whole local vs_ai game to completion over the committed +// sample dictionary, exercising deal / play / refill / exchange / pass / end detection / winner. +// (The rich full-dictionary robot behaviour is pinned elsewhere by the generator + strategy parity +// suites; this test proves the engine drives to a valid finish.) +const dawg = new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url)))); + +function bestOpponentScore(game: LocalGame, seat: number): number { + let best = 0; + for (let i = 0; i < game.playerCount; i++) if (i !== seat) best = Math.max(best, game.scoreOf(i)); + return best; +} + +// robotTurn plays the seat to move: it picks the robot's action from the ranked legal plays and +// dispatches it. The sample dict has a one-letter word ("a") the generator emits but the validator +// rejects (len < 2) — real dictionaries have none, so filtering to main length >= 2 is a no-op in +// production; an exchange the bag is too small to satisfy falls back to a pass. +function robotTurn(game: LocalGame, seed: bigint): void { + const seat = game.currentPlayer; + const cands: Move[] = game.generateMoves().filter((m) => m.main.letters.length >= 2); + const dec = decide(seed, game.moveCount, cands, game.scoreOf(seat), bestOpponentScore(game, seat), game.handOf(seat), game.bagLength); + if (dec.kind === 'play') { + game.play(dec.move.dir, dec.move.tiles); + } else if (dec.kind === 'exchange' && game.bagLength >= RACK_SIZE) { + game.exchange(dec.exchange); + } else { + game.pass(); + } +} + +describe('offline engine full-game smoke', () => { + it('plays a whole 2-player vs_ai game to completion', () => { + const game = new LocalGame({ + variant: 'scrabble_en', + version: 'sample', + seed: 123456789n, + players: 2, + dawg, + multipleWords: true, + }); + + let turns = 0; + while (!game.isOver && turns < 2000) { + robotTurn(game, game.seed); + turns++; + } + + expect(game.isOver).toBe(true); + expect(turns).toBeLessThan(2000); + expect(['out_of_tiles', 'scoreless', 'resign']).toContain(game.endReason); + + const w = game.winnerIndex; + expect(w === -1 || (w >= 0 && w < game.playerCount)).toBe(true); + for (let i = 0; i < game.playerCount; i++) expect(Number.isInteger(game.scoreOf(i))).toBe(true); + // The move log recorded every turn. + expect(game.history.length).toBe(turns); + }); + + it('is reproducible from the seed', () => { + const play = (): { reason: string; scores: number[]; turns: number } => { + const g = new LocalGame({ variant: 'scrabble_en', version: 'sample', seed: 42n, players: 2, dawg, multipleWords: true }); + let t = 0; + while (!g.isOver && t < 2000) { + robotTurn(g, g.seed); + t++; + } + return { reason: g.endReason, scores: [g.scoreOf(0), g.scoreOf(1)], turns: t }; + }; + expect(play()).toEqual(play()); + }); +}); diff --git a/ui/src/lib/localgame/engine.ts b/ui/src/lib/localgame/engine.ts new file mode 100644 index 0000000..10921cf --- /dev/null +++ b/ui/src/lib/localgame/engine.ts @@ -0,0 +1,395 @@ +// 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 { 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[]; + count?: 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; + 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 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.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]]; + } + /** 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); + } + /** 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. */ + generateMoves(): Move[] { + return generateMoves(this.dawg, this.board, this.rackOf(this.toMove), this.vrs, Both); + } + + // --- turns ----------------------------------------------------------------- + + /** 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; + + for (const t of tiles) this.board.set(t.row, t.col, t.letter, t.blank); + this.removeFromHand(player, used); + this.scores[player] += move.score; + this.refill(player); + this.scorelessRun = 0; + + const rec: LocalMove = { + player, + action: 'play', + dir, + tiles: tiles.map((t) => ({ ...t })), + score: move.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, 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(); + for (const t of this.hands[player]) avail.set(t, (avail.get(t) ?? 0) + 1); + const need = new Map(); + 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); + } +} diff --git a/ui/src/lib/localgame/ruleset.parity.test.ts b/ui/src/lib/localgame/ruleset.parity.test.ts new file mode 100644 index 0000000..cb603ac --- /dev/null +++ b/ui/src/lib/localgame/ruleset.parity.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { RULESETS } from './ruleset'; +import type { Variant } from '../model'; + +// The offline engine is self-contained: it carries each variant's tile values, bag counts and +// blank count in ruleset.ts (these are not otherwise on the client — online scoring uses the +// server-sent alphabet). This pins that hand-copied table to the Go rulesets +// (scrabble-solver/rules), whose values the movegen tool dumps to testdata/rulesets.json. +interface RulesetFix { + size: number; + rackSize: number; + bingo: number; + blanks: number; + values: number[]; + counts: number[]; +} + +const fx = JSON.parse( + readFileSync(new URL('./testdata/rulesets.json', import.meta.url), 'utf8'), +) as Record; + +describe('offline ruleset table parity vs Go rules', () => { + for (const variant of Object.keys(fx) as Variant[]) { + it(variant, () => { + const rs = RULESETS[variant]; + expect({ + size: rs.size, + rackSize: rs.rackSize, + bingo: rs.bingo, + blanks: rs.blanks, + values: [...rs.values], + counts: [...rs.counts], + }).toEqual(fx[variant]); + }); + } +}); diff --git a/ui/src/lib/localgame/ruleset.ts b/ui/src/lib/localgame/ruleset.ts new file mode 100644 index 0000000..840ddd5 --- /dev/null +++ b/ui/src/lib/localgame/ruleset.ts @@ -0,0 +1,56 @@ +// Static per-variant ruleset data for the offline engine — tile point values, bag tile counts +// and blank count, indexed by alphabet letter index. Mirrored from scrabble-solver/rules/rules.go +// (English / RussianScrabble / Erudit) so a local game is fully self-contained: online scoring +// reads the server-sent alphabet (lib/alphabet.ts), but offline there is no server, so the values +// live here. Board geometry, the centre and RACK_SIZE/BINGO already live in lib/premiums.ts; this +// table adds the values (offline copy), the bag distribution and the blank count. Pinned to the Go +// rulesets by ruleset.parity.test.ts. + +import type { Variant } from '../model'; + +/** VariantRuleset is the offline scoring + bag data for one variant. */ +export interface VariantRuleset { + /** Number of letters in the alphabet (bag distribution and values are indexed 0..size-1). */ + size: number; + /** Tiles drawn to a full rack (7 for every variant). */ + rackSize: number; + /** All-tiles (bingo) bonus. */ + bingo: number; + /** Number of blank tiles in the bag. */ + blanks: number; + /** Tile point value per letter index; a blank scores 0 (handled by the caller). */ + values: readonly number[]; + /** Bag tile count per letter index (how many of each letter the bag holds). */ + counts: readonly number[]; +} + +/** RULESETS is the static ruleset table, one entry per variant, mirrored from rules.go. */ +export const RULESETS: Record = { + scrabble_en: { + size: 26, + rackSize: 7, + bingo: 50, + blanks: 2, + // a b c d e f g h i j k l m n o p q r s t u v w x y z + values: [1, 3, 3, 2, 1, 4, 2, 4, 1, 8, 5, 1, 3, 1, 1, 3, 10, 1, 1, 1, 1, 4, 4, 8, 4, 10], + counts: [9, 2, 2, 4, 12, 2, 3, 2, 9, 1, 1, 4, 2, 6, 8, 2, 1, 6, 4, 6, 4, 2, 2, 1, 2, 1], + }, + scrabble_ru: { + size: 33, + rackSize: 7, + bingo: 50, + blanks: 2, + // а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я + values: [1, 3, 1, 3, 2, 1, 3, 5, 5, 1, 4, 2, 2, 2, 1, 1, 2, 1, 1, 1, 2, 10, 5, 5, 5, 8, 10, 10, 4, 3, 8, 8, 3], + counts: [8, 2, 4, 2, 4, 8, 1, 1, 2, 5, 1, 4, 4, 3, 5, 10, 4, 5, 5, 5, 4, 1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 1, 2], + }, + erudit_ru: { + size: 33, + rackSize: 7, + bingo: 15, + blanks: 3, + // а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я + values: [1, 3, 2, 3, 2, 1, 0, 5, 5, 1, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 3, 10, 5, 10, 5, 10, 10, 10, 5, 5, 10, 10, 3], + counts: [10, 3, 5, 3, 5, 9, 0, 2, 2, 8, 4, 6, 4, 5, 8, 10, 6, 6, 6, 5, 3, 1, 2, 1, 2, 1, 1, 1, 2, 2, 1, 1, 3], + }, +}; diff --git a/ui/src/lib/localgame/testdata/endgame.json b/ui/src/lib/localgame/testdata/endgame.json new file mode 100644 index 0000000..7e9b8d1 --- /dev/null +++ b/ui/src/lib/localgame/testdata/endgame.json @@ -0,0 +1,246 @@ +{ + "cases": [ + { + "name": "out-basic", + "variant": "scrabble_en", + "reason": "out_of_tiles", + "hands": [ + [], + [ + 0, + 1, + 2 + ] + ], + "scores": [ + 50, + 40 + ], + "resigned": [ + false, + false + ], + "toMove": 0, + "scoresAfter": [ + 57, + 33 + ], + "winner": 0 + }, + { + "name": "out-blank", + "variant": "scrabble_en", + "reason": "out_of_tiles", + "hands": [ + [], + [ + 255, + 0 + ] + ], + "scores": [ + 30, + 30 + ], + "resigned": [ + false, + false + ], + "toMove": 0, + "scoresAfter": [ + 31, + 29 + ], + "winner": 0 + }, + { + "name": "out-erudit-yo", + "variant": "erudit_ru", + "reason": "out_of_tiles", + "hands": [ + [], + [ + 6, + 32 + ] + ], + "scores": [ + 10, + 10 + ], + "resigned": [ + false, + false + ], + "toMove": 0, + "scoresAfter": [ + 13, + 7 + ], + "winner": 0 + }, + { + "name": "out-tie", + "variant": "scrabble_en", + "reason": "out_of_tiles", + "hands": [ + [], + [] + ], + "scores": [ + 30, + 30 + ], + "resigned": [ + false, + false + ], + "toMove": 0, + "scoresAfter": [ + 30, + 30 + ], + "winner": -1 + }, + { + "name": "out-3p", + "variant": "scrabble_ru", + "reason": "out_of_tiles", + "hands": [ + [], + [ + 0, + 1 + ], + [ + 2 + ] + ], + "scores": [ + 10, + 10, + 10 + ], + "resigned": [ + false, + false, + false + ], + "toMove": 0, + "scoresAfter": [ + 15, + 6, + 9 + ], + "winner": 0 + }, + { + "name": "scoreless", + "variant": "scrabble_en", + "reason": "scoreless", + "hands": [ + [ + 0, + 1 + ], + [ + 2, + 3 + ] + ], + "scores": [ + 20, + 20 + ], + "resigned": [ + false, + false + ], + "toMove": 0, + "scoresAfter": [ + 16, + 15 + ], + "winner": 0 + }, + { + "name": "resign-2p", + "variant": "scrabble_en", + "reason": "resign", + "hands": [ + [], + [ + 0 + ] + ], + "scores": [ + 100, + 10 + ], + "resigned": [ + true, + false + ], + "toMove": 1, + "scoresAfter": [ + 100, + 10 + ], + "winner": 1 + }, + { + "name": "resign-3p", + "variant": "scrabble_en", + "reason": "resign", + "hands": [ + [], + [], + [] + ], + "scores": [ + 50, + 60, + 40 + ], + "resigned": [ + false, + true, + false + ], + "toMove": 0, + "scoresAfter": [ + 50, + 60, + 40 + ], + "winner": 0 + }, + { + "name": "aborted", + "variant": "scrabble_en", + "reason": "aborted", + "hands": [ + [ + 0 + ], + [ + 1 + ] + ], + "scores": [ + 40, + 30 + ], + "resigned": [ + false, + false + ], + "toMove": 0, + "scoresAfter": [ + 40, + 30 + ], + "winner": -1 + } + ] +} diff --git a/ui/src/lib/localgame/testdata/rulesets.json b/ui/src/lib/localgame/testdata/rulesets.json new file mode 100644 index 0000000..a693d39 --- /dev/null +++ b/ui/src/lib/localgame/testdata/rulesets.json @@ -0,0 +1,216 @@ +{ + "erudit_ru": { + "size": 33, + "rackSize": 7, + "bingo": 15, + "blanks": 3, + "values": [ + 1, + 3, + 2, + 3, + 2, + 1, + 0, + 5, + 5, + 1, + 2, + 2, + 2, + 2, + 1, + 1, + 2, + 2, + 2, + 2, + 3, + 10, + 5, + 10, + 5, + 10, + 10, + 10, + 5, + 5, + 10, + 10, + 3 + ], + "counts": [ + 10, + 3, + 5, + 3, + 5, + 9, + 0, + 2, + 2, + 8, + 4, + 6, + 4, + 5, + 8, + 10, + 6, + 6, + 6, + 5, + 3, + 1, + 2, + 1, + 2, + 1, + 1, + 1, + 2, + 2, + 1, + 1, + 3 + ] + }, + "scrabble_en": { + "size": 26, + "rackSize": 7, + "bingo": 50, + "blanks": 2, + "values": [ + 1, + 3, + 3, + 2, + 1, + 4, + 2, + 4, + 1, + 8, + 5, + 1, + 3, + 1, + 1, + 3, + 10, + 1, + 1, + 1, + 1, + 4, + 4, + 8, + 4, + 10 + ], + "counts": [ + 9, + 2, + 2, + 4, + 12, + 2, + 3, + 2, + 9, + 1, + 1, + 4, + 2, + 6, + 8, + 2, + 1, + 6, + 4, + 6, + 4, + 2, + 2, + 1, + 2, + 1 + ] + }, + "scrabble_ru": { + "size": 33, + "rackSize": 7, + "bingo": 50, + "blanks": 2, + "values": [ + 1, + 3, + 1, + 3, + 2, + 1, + 3, + 5, + 5, + 1, + 4, + 2, + 2, + 2, + 1, + 1, + 2, + 1, + 1, + 1, + 2, + 10, + 5, + 5, + 5, + 8, + 10, + 10, + 4, + 3, + 8, + 8, + 3 + ], + "counts": [ + 8, + 2, + 4, + 2, + 4, + 8, + 1, + 1, + 2, + 5, + 1, + 4, + 4, + 3, + 5, + 10, + 4, + 5, + 5, + 5, + 4, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 2, + 1, + 1, + 2 + ] + } +}