faience / js /engine.js
RemiFabre's picture
Deploy browser player — run4/ckpt-037888 elo 2361.0
5d90ebb
Raw
History Blame Contribute Delete
26 kB
/* Azul rules engine — a line-by-line port of ludometer/azul/engine.py.
*
* Official 2-player rules (docs/DESIGN.md). Same action encoding
* (`action_id = source * 30 + color * 6 + dest`) and, crucially, the same
* 182-float observation layout, because the ONNX net in ../model/ was trained on
* exactly those numbers: a single misplaced offset would leave the search
* evaluating garbage while still looking like it works.
*
* The port is proven, not asserted: scripts/dump_fixtures.py plays seeded games
* with the Python engine and records every state, legal-action list, encoding and
* outcome; test/engine.test.mjs replays them here and demands an exact match.
*
* Chance. The Python engine keeps all randomness in one `random.Random` that is
* only consumed at a round refill. Here the RNG is an injected object with
* `shuffle` / `clone`, which is what lets three callers coexist: the page uses a
* seeded PRNG, MCTS reshuffles a clone's bag to determinize a refill, and the
* fixture test replays Python's own shuffles so the two engines see identical
* deals (JS cannot reproduce the Mersenne Twister, and it does not need to —
* only the rules are under test).
*
* Colors are 0..4 = blue, yellow, red, black, teal.
*/
export const NUM_COLORS = 5;
export const TILES_PER_COLOR = 20;
export const NUM_FACTORIES = 5; // 2-player count
export const FACTORY_SIZE = 4;
export const NUM_ROWS = 5;
export const CENTER = 5; // action `source` value for the center
export const FLOOR = 5; // action `dest` value for the floor line
export const ACTION_SPACE = 180; // source (6) * color (5) * dest (6)
export const ENCODED_SIZE = 182;
export const FLOOR_PENALTIES = [-1, -1, -2, -2, -2, -3, -3];
export const FLOOR_SLOTS = FLOOR_PENALTIES.length;
export const CUM_PENALTY = (() => {
const out = [0];
for (const p of FLOOR_PENALTIES) out.push(out[out.length - 1] + p);
return out;
})();
export const COLOR_NAMES = ["blue", "yellow", "red", "black", "teal"];
export const ROW_BONUS = 2;
export const COL_BONUS = 7;
export const COLOR_BONUS = 10;
/** Flat wall lookup: WALL_IDX[color * 5 + row] -> index into the 25-cell wall. */
export const WALL_IDX = (() => {
const out = new Int32Array(NUM_COLORS * NUM_ROWS);
for (let c = 0; c < NUM_COLORS; c++) {
for (let r = 0; r < NUM_ROWS; r++) out[c * 5 + r] = r * 5 + ((c + r) % 5);
}
return out;
})();
export function wallCol(color, row) {
return (color + row) % NUM_COLORS;
}
export function encodeAction(source, color, dest) {
return source * 30 + color * 6 + dest;
}
export function decodeAction(actionId) {
const source = Math.floor(actionId / 30);
const rest = actionId - source * 30;
const color = Math.floor(rest / 6);
return [source, color, rest - color * 6];
}
/* ------------------------------------------------------------ lookup tables */
/* _ACTION_TABLE[source][color][open_mask] -> the ready-made action ids (pattern
* rows in ascending order, floor last), mirroring the Python hot path so the
* legal-action *order* matches too, not just the set. */
const _ALL_ROWS = (1 << NUM_ROWS) - 1;
const _ACTION_TABLE = [];
for (let src = 0; src < 6; src++) {
const perSource = [];
for (let c = 0; c < NUM_COLORS; c++) {
const perColor = [];
for (let mask = 0; mask < 1 << NUM_ROWS; mask++) {
const ids = [];
for (let r = 0; r < NUM_ROWS; r++) if ((mask >> r) & 1) ids.push(src * 30 + c * 6 + r);
ids.push(src * 30 + c * 6 + FLOOR);
perColor.push(ids);
}
perSource.push(perColor);
}
_ACTION_TABLE.push(perSource);
}
/* _AND_KEEP[color][row]: AND-masks closing `row` for every color but `color`. */
const _AND_KEEP = [];
for (let c = 0; c < NUM_COLORS; c++) {
const perColor = [];
for (let r = 0; r < NUM_ROWS; r++) {
const masks = [];
for (let c2 = 0; c2 < NUM_COLORS; c2++) masks.push(c2 === c ? _ALL_ROWS : _ALL_ROWS ^ (1 << r));
perColor.push(masks);
}
_AND_KEEP.push(perColor);
}
/* _AND_CLOSE[row]: closes `row` for every color (the line is full / tiled). */
const _AND_CLOSE = [];
for (let r = 0; r < NUM_ROWS; r++) {
const masks = [];
for (let c = 0; c < NUM_COLORS; c++) masks.push(_ALL_ROWS ^ (1 << r));
_AND_CLOSE.push(masks);
}
/* --------------------------------------------------------------------- RNG */
/**
* Small seeded PRNG (mulberry32) with the bits the engine needs.
*
* It is *not* Python's Mersenne Twister — the two engines deal different tiles
* from the same seed, which is fine: reproducibility is per-engine, and the
* fixture test hands the JS side Python's recorded shuffles instead.
*/
export class Rng {
constructor(seed = 0) {
this.seed(seed);
}
seed(n) {
this.state = (Number(n) >>> 0) || 0x9e3779b9;
return this;
}
/** uint32 */
next() {
this.state = (this.state + 0x6d2b79f5) >>> 0;
let t = this.state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return (t ^ (t >>> 14)) >>> 0;
}
/** float in [0, 1) */
random() {
return this.next() / 4294967296;
}
/** int in [0, n) */
randrange(n) {
return this.next() % n;
}
/** Fisher-Yates, in place (same loop shape as random.shuffle). */
shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = this.randrange(i + 1);
const tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
return arr;
}
clone() {
const other = new Rng(0);
other.state = this.state;
return other;
}
}
/**
* An RNG that replays a recorded list of shuffle *results* (fixtures only).
*
* `shuffle(bag)` overwrites the bag with the next recorded ordering, so the JS
* engine deals exactly what Python dealt without owning Python's RNG.
*/
export class ScriptedRng {
constructor(shuffles) {
this.shuffles = shuffles;
this.index = 0;
}
seed() {
return this;
}
shuffle(arr) {
if (this.index >= this.shuffles.length) {
throw new Error(`ScriptedRng ran out of recorded shuffles (${this.index})`);
}
const next = this.shuffles[this.index++];
if (next.length !== arr.length) {
throw new Error(`recorded shuffle #${this.index - 1} has ${next.length} tiles, bag has ${arr.length}`);
}
for (let i = 0; i < next.length; i++) arr[i] = next[i];
return arr;
}
clone() {
const other = new ScriptedRng(this.shuffles);
other.index = this.index;
return other;
}
}
/* ------------------------------------------------------------------- state */
export class AzulState {
/** Deal a new 2-player game. `rng` defaults to a fresh seeded `Rng`. */
static newGame(seed = 0, rng = null) {
const self = new AzulState();
self.numPlayers = 2;
self.rng = rng || new Rng(seed);
self.bag = [];
for (let c = 0; c < NUM_COLORS; c++) {
for (let i = 0; i < TILES_PER_COLOR; i++) self.bag.push(c);
}
self.rng.shuffle(self.bag);
self.lid = [0, 0, 0, 0, 0];
self.factories = [];
for (let i = 0; i < NUM_FACTORIES; i++) self.factories.push([0, 0, 0, 0, 0]);
self.center = [0, 0, 0, 0, 0];
self.markerInCenter = true;
self.walls = [new Array(25).fill(0), new Array(25).fill(0)];
self.plColor = [new Array(NUM_ROWS).fill(-1), new Array(NUM_ROWS).fill(-1)];
self.plCount = [new Array(NUM_ROWS).fill(0), new Array(NUM_ROWS).fill(0)];
self.floor = [[0, 0, 0, 0, 0], [0, 0, 0, 0, 0]];
self.floorMarker = [false, false];
self.openMask = [new Array(NUM_COLORS).fill(_ALL_ROWS), new Array(NUM_COLORS).fill(_ALL_ROWS)];
self.scores = [0, 0];
self.currentPlayer = 0;
self.firstPlayer = 0;
self.roundIndex = 0;
self.isTerminal = false;
self.exhausted = false;
self.tilesLeft = 0;
self._refill();
return self;
}
/**
* Build a state from an explicit field dump (fixtures and tests only).
*
* The Python engine lets tests hand-edit `factories` / `center` / `pl_*` /
* `walls` and then call `recount()`; this is the same door, so a fixture can
* put the engine in a position random play would essentially never reach
* (an all-monochrome round end, a bag that runs dry, a wall one tile short).
*/
static fromSetup(setup, rng = null) {
const self = new AzulState();
self.numPlayers = 2;
self.rng = rng || new Rng(0);
self.bag = setup.bag.slice();
self.lid = setup.lid.slice();
self.factories = setup.factories.map((f) => f.slice());
self.center = setup.center.slice();
self.markerInCenter = setup.marker_in_center;
self.walls = setup.walls.map((w) => w.slice());
self.plColor = setup.pl_color.map((x) => x.slice());
self.plCount = setup.pl_count.map((x) => x.slice());
self.floor = setup.floor.map((f) => f.slice());
self.floorMarker = setup.floor_marker.slice();
self.openMask = [new Array(NUM_COLORS).fill(_ALL_ROWS), new Array(NUM_COLORS).fill(_ALL_ROWS)];
self.scores = setup.scores.slice();
self.currentPlayer = setup.current_player;
self.firstPlayer = setup.first_player;
self.roundIndex = setup.round_index;
self.isTerminal = Boolean(setup.is_terminal);
self.exhausted = Boolean(setup.exhausted);
self.tilesLeft = 0;
self.recount();
return self;
}
/** The inverse of `fromSetup`: a structured-clone-safe field dump. */
toSetup() {
return {
bag: this.bag.slice(),
lid: this.lid.slice(),
factories: this.factories.map((f) => f.slice()),
center: this.center.slice(),
marker_in_center: this.markerInCenter,
walls: this.walls.map((w) => w.slice()),
pl_color: this.plColor.map((x) => x.slice()),
pl_count: this.plCount.map((x) => x.slice()),
floor: this.floor.map((f) => f.slice()),
floor_marker: this.floorMarker.slice(),
scores: this.scores.slice(),
current_player: this.currentPlayer,
first_player: this.firstPlayer,
round_index: this.roundIndex,
is_terminal: this.isTerminal,
exhausted: this.exhausted,
};
}
/** Rebuild the derived caches (`tilesLeft` and the placement masks). */
recount() {
let total = this.center[0] + this.center[1] + this.center[2] + this.center[3] + this.center[4];
for (const f of this.factories) total += f[0] + f[1] + f[2] + f[3] + f[4];
this.tilesLeft = total;
for (let p = 0; p < this.numPlayers; p++) this._rebuildMask(p);
}
/** Deep-enough copy: every mutable container is duplicated. */
clone() {
const other = new AzulState();
other.numPlayers = this.numPlayers;
other.rng = this.rng.clone();
other.bag = this.bag.slice();
other.lid = this.lid.slice();
other.factories = this.factories.map((f) => f.slice());
other.center = this.center.slice();
other.markerInCenter = this.markerInCenter;
other.walls = this.walls.map((w) => w.slice());
other.plColor = this.plColor.map((x) => x.slice());
other.plCount = this.plCount.map((x) => x.slice());
other.floor = this.floor.map((f) => f.slice());
other.floorMarker = this.floorMarker.slice();
other.openMask = this.openMask.map((m) => m.slice());
other.scores = this.scores.slice();
other.currentPlayer = this.currentPlayer;
other.firstPlayer = this.firstPlayer;
other.roundIndex = this.roundIndex;
other.isTerminal = this.isTerminal;
other.exhausted = this.exhausted;
other.tilesLeft = this.tilesLeft;
return other;
}
_rebuildMask(player) {
const wall = this.walls[player];
const plc = this.plColor[player];
const pln = this.plCount[player];
const masks = this.openMask[player];
for (let c = 0; c < NUM_COLORS; c++) {
const base = c * 5;
let m = 0;
for (let r = 0; r < NUM_ROWS; r++) {
const n = pln[r];
if (n <= r && (n === 0 || plc[r] === c) && !wall[WALL_IDX[base + r]]) m |= 1 << r;
}
masks[c] = m;
}
}
/* ---------------------------------------------------------- legal moves */
legalActions() {
if (this.isTerminal) return [];
const masks = this.openMask[this.currentPlayer];
const out = [];
for (let src = 0; src < NUM_FACTORIES; src++) {
const pool = this.factories[src];
const table = _ACTION_TABLE[src];
for (let c = 0; c < NUM_COLORS; c++) {
if (pool[c]) {
const ids = table[c][masks[c]];
for (let i = 0; i < ids.length; i++) out.push(ids[i]);
}
}
}
const pool = this.center;
const table = _ACTION_TABLE[CENTER];
for (let c = 0; c < NUM_COLORS; c++) {
if (pool[c]) {
const ids = table[c][masks[c]];
for (let i = 0; i < ids.length; i++) out.push(ids[i]);
}
}
return out;
}
isLegal(actionId) {
if (this.isTerminal || !(actionId >= 0 && actionId < ACTION_SPACE)) return false;
const [src, color, dest] = decodeAction(actionId);
const pool = src === CENTER ? this.center : this.factories[src];
if (pool[color] === 0) return false;
if (dest === FLOOR) return true;
const p = this.currentPlayer;
const n = this.plCount[p][dest];
if (n > dest) return false;
if (n && this.plColor[p][dest] !== color) return false;
return !this.walls[p][WALL_IDX[color * 5 + dest]];
}
/* ---------------------------------------------------------------- moves */
/** Play `actionId`, then resolve round end / refill / game end as needed. */
apply(actionId) {
if (this.isTerminal) throw new Error("game is over");
if (!(actionId >= 0 && actionId < ACTION_SPACE)) throw new Error(`action ${actionId} out of range`);
const src = Math.floor(actionId / 30);
const rest = actionId - src * 30;
const color = Math.floor(rest / 6);
const dest = rest - color * 6;
const p = this.currentPlayer;
const pool = src === CENTER ? this.center : this.factories[src];
const count = pool[color];
if (count === 0) throw new Error(`no color ${color} at source ${src}`);
if (dest !== FLOOR) {
const held = this.plCount[p][dest];
if (held > dest) throw new Error(`pattern line ${dest} is full`);
if (held && this.plColor[p][dest] !== color) throw new Error(`pattern line ${dest} holds another color`);
if (this.walls[p][WALL_IDX[color * 5 + dest]]) throw new Error(`color ${color} already on wall row ${dest}`);
}
// --- take the tiles
pool[color] = 0;
if (src === CENTER) {
if (this.markerInCenter) {
this.markerInCenter = false;
this.floorMarker[p] = true;
}
} else {
const cen = this.center;
for (let c = 0; c < NUM_COLORS; c++) {
const n = pool[c];
if (n) {
cen[c] += n;
pool[c] = 0;
}
}
}
this.tilesLeft -= count;
// --- place them
let overflow;
if (dest !== FLOOR) {
const pln = this.plCount[p];
const room = dest + 1 - pln[dest];
this.plColor[p][dest] = color;
let keep;
if (count < room) {
pln[dest] += count;
overflow = 0;
keep = _AND_KEEP[color][dest];
} else {
pln[dest] = dest + 1;
overflow = count - room;
keep = _AND_CLOSE[dest];
}
const masks = this.openMask[p];
masks[0] &= keep[0];
masks[1] &= keep[1];
masks[2] &= keep[2];
masks[3] &= keep[3];
masks[4] &= keep[4];
} else {
overflow = count;
}
if (overflow) {
const fl = this.floor[p];
let occupied = fl[0] + fl[1] + fl[2] + fl[3] + fl[4];
if (this.floorMarker[p]) occupied += 1;
const room = FLOOR_SLOTS - occupied;
if (overflow <= room) {
fl[color] += overflow;
} else if (room > 0) {
fl[color] += room;
this.lid[color] += overflow - room;
} else {
this.lid[color] += overflow;
}
}
// --- round / game transitions
if (this.tilesLeft) this.currentPlayer = 1 - p;
else this._endRound(p);
}
/* --------------------------------------------------------- round logic */
_endRound(lastMover) {
const lid = this.lid;
for (let q = 0; q < this.numPlayers; q++) {
const wall = this.walls[q];
const plc = this.plColor[q];
const pln = this.plCount[q];
let gain = 0;
for (let r = 0; r < NUM_ROWS; r++) {
if (pln[r] !== r + 1) continue;
const c = plc[r];
const idx = WALL_IDX[c * 5 + r];
wall[idx] = 1;
const rowBase = r * 5;
const col = idx - rowBase;
let h = 1;
for (let i = col - 1; i >= 0 && wall[rowBase + i]; i--) h += 1;
for (let i = col + 1; i < 5 && wall[rowBase + i]; i++) h += 1;
let v = 1;
for (let i = r - 1; i >= 0 && wall[i * 5 + col]; i--) v += 1;
for (let i = r + 1; i < 5 && wall[i * 5 + col]; i++) v += 1;
if (h > 1 || v > 1) gain += (h > 1 ? h : 0) + (v > 1 ? v : 0);
else gain += 1;
lid[c] += r; // the r leftover tiles of the line
plc[r] = -1;
pln[r] = 0;
}
const fl = this.floor[q];
let occupied = fl[0] + fl[1] + fl[2] + fl[3] + fl[4];
if (this.floorMarker[q]) occupied += 1;
gain += CUM_PENALTY[Math.min(FLOOR_SLOTS, occupied)];
for (let c = 0; c < NUM_COLORS; c++) {
const n = fl[c];
if (n) {
lid[c] += n;
fl[c] = 0;
}
}
const total = this.scores[q] + gain;
this.scores[q] = Math.max(0, total);
this._rebuildMask(q);
}
// who starts next round: the marker holder (marker goes back to the center)
let holder = null;
for (let q = 0; q < this.numPlayers; q++) {
if (this.floorMarker[q]) {
this.floorMarker[q] = false;
holder = q;
}
}
if (holder === null) holder = 1 - lastMover;
this.firstPlayer = holder;
this.markerInCenter = true;
this.currentPlayer = holder;
if (this._anyRowComplete()) {
this._finish();
return;
}
this.roundIndex += 1;
this._refill();
if (this.tilesLeft === 0) {
// No tiles anywhere: cannot deal another round, stop the game.
this.exhausted = true;
this._finish();
}
}
_anyRowComplete() {
for (const wall of this.walls) {
for (const base of [0, 5, 10, 15, 20]) {
if (wall[base] && wall[base + 1] && wall[base + 2] && wall[base + 3] && wall[base + 4]) return true;
}
}
return false;
}
_finish() {
for (let q = 0; q < this.numPlayers; q++) {
this.scores[q] +=
ROW_BONUS * this.completedRows(q) + COL_BONUS * this.completedCols(q) + COLOR_BONUS * this.completedColors(q);
}
this.isTerminal = true;
}
_refill() {
const bag = this.bag;
const lid = this.lid;
let total = 0;
for (const f of this.factories) {
for (let k = 0; k < FACTORY_SIZE; k++) {
if (!bag.length) {
for (let c = 0; c < NUM_COLORS; c++) {
const n = lid[c];
if (n) {
for (let i = 0; i < n; i++) bag.push(c);
lid[c] = 0;
}
}
if (!bag.length) {
this.tilesLeft = total;
return;
}
this.rng.shuffle(bag);
}
f[bag.pop()] += 1;
total += 1;
}
}
this.tilesLeft = total;
}
/* ---------------------------------------------------------- inspection */
floorOccupied(player) {
const fl = this.floor[player];
return fl[0] + fl[1] + fl[2] + fl[3] + fl[4] + (this.floorMarker[player] ? 1 : 0);
}
floorPenalty(player) {
return CUM_PENALTY[Math.min(FLOOR_SLOTS, this.floorOccupied(player))];
}
completedRows(player) {
const wall = this.walls[player];
let n = 0;
for (const base of [0, 5, 10, 15, 20]) {
if (wall[base] && wall[base + 1] && wall[base + 2] && wall[base + 3] && wall[base + 4]) n += 1;
}
return n;
}
completedCols(player) {
const wall = this.walls[player];
let n = 0;
for (let col = 0; col < 5; col++) {
if (wall[col] && wall[col + 5] && wall[col + 10] && wall[col + 15] && wall[col + 20]) n += 1;
}
return n;
}
completedColors(player) {
const wall = this.walls[player];
let done = 0;
for (let c = 0; c < NUM_COLORS; c++) {
const base = c * 5;
let all = true;
for (let r = 0; r < NUM_ROWS; r++) {
if (!wall[WALL_IDX[base + r]]) {
all = false;
break;
}
}
if (all) done += 1;
}
return done;
}
/** +1 if player 0 wins, -1 if player 1 wins, 0 for a draw, null if unfinished. */
outcome() {
if (!this.isTerminal) return null;
const s0 = this.scores[0];
const s1 = this.scores[1];
if (s0 !== s1) return s0 > s1 ? 1.0 : -1.0;
const r0 = this.completedRows(0);
const r1 = this.completedRows(1);
if (r0 !== r1) return r0 > r1 ? 1.0 : -1.0;
return 0.0;
}
bagCounts() {
const counts = [0, 0, 0, 0, 0];
for (const c of this.bag) counts[c] += 1;
return counts;
}
/* ------------------------------------------------------------ encoding */
/**
* Fixed-size float32 observation from the current player's perspective.
*
* The offsets below are the OFF_* constants of ludometer/azul/engine.py and
* must never drift from them — the exported net reads this vector verbatim.
*
* [ 0: 25) my wall, row-major 5x5 [126: 151) factories /4
* [ 25: 50) their wall [151: 156) factory non-empty
* [ 50: 80) my pattern lines (one-hot, fill) [156: 161) center counts /10
* [ 80: 110) their pattern lines [161: 162) center total /20
* [110: 117) my floor (counts /7, slots, mark) [162: 163) marker in center
* [117: 124) their floor [163: 168) bag counts /20
* [124: 126) scores /100 [168: 173) lid counts /20
* [173: 174) tiles left /20
* [174: 175) I start next round
* [175: 176) round /10
* [176: 179) my rows/cols/colors /5
* [179: 182) theirs
*/
encode(out = null) {
const v = out || new Float32Array(ENCODED_SIZE);
if (out) v.fill(0);
const me = this.currentPlayer;
const op = 1 - me;
const myWall = this.walls[me];
const opWall = this.walls[op];
for (let i = 0; i < 25; i++) {
v[i] = myWall[i];
v[25 + i] = opWall[i];
}
for (const [off, p] of [[50, me], [80, op]]) {
const plc = this.plColor[p];
const pln = this.plCount[p];
for (let r = 0; r < NUM_ROWS; r++) {
const n = pln[r];
if (n) {
const base = off + r * 6;
v[base + plc[r]] = 1.0;
v[base + 5] = n / (r + 1);
}
}
}
for (const [off, p] of [[110, me], [117, op]]) {
const fl = this.floor[p];
for (let c = 0; c < NUM_COLORS; c++) {
if (fl[c]) v[off + c] = fl[c] / FLOOR_SLOTS;
}
v[off + 5] = Math.min(this.floorOccupied(p), FLOOR_SLOTS) / FLOOR_SLOTS;
v[off + 6] = this.floorMarker[p] ? 1.0 : 0.0;
}
v[124] = this.scores[me] / 100.0;
v[125] = this.scores[op] / 100.0;
for (let i = 0; i < NUM_FACTORIES; i++) {
const f = this.factories[i];
const base = 126 + i * 5;
let total = 0;
for (let c = 0; c < NUM_COLORS; c++) {
const n = f[c];
if (n) {
v[base + c] = n / FACTORY_SIZE;
total += n;
}
}
if (total) v[151 + i] = 1.0;
}
let cenTotal = 0;
for (let c = 0; c < NUM_COLORS; c++) {
const n = this.center[c];
if (n) {
v[156 + c] = n / 10.0;
cenTotal += n;
}
}
v[161] = cenTotal / 20.0;
v[162] = this.markerInCenter ? 1.0 : 0.0;
const bag = this.bagCounts();
for (let c = 0; c < NUM_COLORS; c++) {
v[163 + c] = bag[c] / TILES_PER_COLOR;
v[168 + c] = this.lid[c] / TILES_PER_COLOR;
}
v[173] = this.tilesLeft / 20.0;
v[174] = this.floorMarker[me] || this.firstPlayer === me ? 1.0 : 0.0;
v[175] = Math.min(this.roundIndex, 10) / 10.0;
for (const [off, p] of [[176, me], [179, op]]) {
v[off] = this.completedRows(p) / 5.0;
v[off + 1] = this.completedCols(p) / 5.0;
v[off + 2] = this.completedColors(p) / 5.0;
}
return v;
}
/* -------------------------------------------------------------- display */
/** Full state as plain data — the shape the old server's /api/state sent. */
toJSON() {
const players = [];
for (let p = 0; p < this.numPlayers; p++) {
const wall = [];
for (let r = 0; r < NUM_ROWS; r++) wall.push(this.walls[p].slice(r * 5, r * 5 + 5));
const patternLines = [];
for (let r = 0; r < NUM_ROWS; r++) {
patternLines.push({ capacity: r + 1, color: this.plColor[p][r], count: this.plCount[p][r] });
}
players.push({
score: this.scores[p],
wall,
pattern_lines: patternLines,
floor: this.floor[p].slice(),
floor_marker: this.floorMarker[p],
floor_penalty: this.floorPenalty(p),
completed_rows: this.completedRows(p),
completed_cols: this.completedCols(p),
completed_colors: this.completedColors(p),
});
}
return {
round: this.roundIndex,
current_player: this.currentPlayer,
first_player: this.firstPlayer,
factories: this.factories.map((f) => f.slice()),
center: this.center.slice(),
marker_in_center: this.markerInCenter,
bag: this.bagCounts(),
lid: this.lid.slice(),
tiles_left: this.tilesLeft,
scores: this.scores.slice(),
is_terminal: this.isTerminal,
exhausted: this.exhausted,
outcome: this.outcome(),
legal_actions: this.legalActions(),
color_names: COLOR_NAMES.slice(),
players,
};
}
}