/* 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, }; } }