feat(offline): local game engine (Phase B1)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
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).
This commit is contained in:
@@ -121,6 +121,8 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emitRulesets()
|
||||||
|
|
||||||
buildSample(*out, "en", rules.English(), sampleWordsEN, []genCase{
|
buildSample(*out, "en", rules.English(), sampleWordsEN, []genCase{
|
||||||
emptyCase("empty-cared", englishRack("caredts", 0), scrabble.Both, false),
|
emptyCase("empty-cared", englishRack("caredts", 0), scrabble.Both, false),
|
||||||
emptyCase("empty-dogs", englishRack("dogsent", 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}
|
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) {
|
func writeJSON(path string, v any) {
|
||||||
data, err := json.MarshalIndent(v, "", " ")
|
data, err := json.MarshalIndent(v, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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<number, number>();
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Cell | null>(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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Variant, RulesetFix>;
|
||||||
|
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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<Variant, VariantRuleset> = {
|
||||||
|
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],
|
||||||
|
},
|
||||||
|
};
|
||||||
+246
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+216
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user