e4cf143e9f
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).
77 lines
2.8 KiB
TypeScript
77 lines
2.8 KiB
TypeScript
// 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;
|
||
}
|
||
}
|
||
}
|