Files
scrabble-game/ui/src/lib/localgame/bag.ts
T
Ilia Denisov 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
feat(offline): local game engine (Phase B1)
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).
2026-07-06 08:02:05 +02:00

77 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (FisherYates).
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;
}
}
}