UNPKG

hathora-et-labora-game

Version:

Game logic reducer for Uwe Rosenberg's Ora et Labora

325 lines 13 kB
import { BuildingEnum, GameCommandEnum, GameStatusEnum, LandEnum, NextUseClergy, SettlementEnum, SettlementRound, } from './types'; import { clergyForColor, isPrior } from './board/player'; import { take } from './board/rondel'; // =========================================================================== // Feature encoder — turns a GameState into a flat Float32Array for a tensor. // // Design notes (see docs/mcts-self-play-plan.md): // - Egocentric: players are emitted current-player-first, then the next // player to act, etc. (slot 0 is always "me"). Pass `perspective` to encode // from a specific player's view; defaults to the current player. // - Compact: a tile's erection is a single CATEGORICAL channel holding an // integer id (0 = empty, 1..N = a color-agnostic building/settlement type), // NOT a one-hot. The model embeds it. This keeps the tensor shape constant // as new buildings ship — only the embedding table indexes more rows. // - Forward-compatible: everywhere the building/settlement vocabulary appears // (the erection id and the three building masks) is sized to VOCAB_CAPACITY, // so adding variant/expansion buildings never changes FEATURE_LEN. // // Stability rule: enum orderings below are APPEND-ONLY. Never reorder — saved // model weights are keyed by these slot/id positions and will silently // misalign otherwise. New buildings appended to BuildingEnum get new ids at // the end; existing ids (and existing weights) stay valid. // =========================================================================== const BUILDINGS = Object.values(BuildingEnum); const SETTLEMENTS = Object.values(SettlementEnum); const LANDS = Object.values(LandEnum); const COMMANDS = Object.values(GameCommandEnum); const SETTLEMENT_ROUNDS = Object.values(SettlementRound); const NEXT_USES = Object.values(NextUseClergy); const LENGTHS = ['short', 'long']; const COUNTRIES = ['ireland', 'france']; const RONDEL_KEYS = [ 'wood', 'clay', 'coin', 'joker', 'grain', 'peat', 'sheep', 'grape', 'stone', ]; const RESOURCES = [ 'peat', 'penny', 'clay', 'wood', 'grain', 'sheep', 'stone', 'flour', 'grape', 'nickel', 'malt', 'coal', 'book', 'ceramic', 'whiskey', 'straw', 'meat', 'ornament', 'bread', 'wine', 'beer', 'reliquary', ]; // --- Color-agnostic erection vocabulary ----------------------------------- // A colored tile (ClayMoundR/G/B/W, FarmYard*, CloisterOffice*, settlements // SR1..SW8) only ever sits on its owner's board, and slot 0 is always "me", // so color is fully redundant with which player's grid the tile is in. We // collapse it. Country variants (F03 vs I03) stay DISTINCT — they are // different buildings, not colors. const SETTLEMENT_SET = new Set(SETTLEMENTS); const genericBuildingKey = (b) => { const m = /^L([RGBW])([123])$/.exec(b); if (m) return ['', 'ClayMound', 'FarmYard', 'CloisterOffice'][Number(m[2])]; return b; }; const settlementTypeKey = (s) => { const m = /^S[RGBW]([1-8])$/.exec(s); return `Settlement${m ? m[1] : s}`; }; // Keep first occurrence only. Runs once at module load over tiny vocab arrays, // so the O(n^2) indexOf is irrelevant — this is not the hot path. const dedupeInOrder = (keys) => keys.filter((k, i) => keys.indexOf(k) === i); const GENERIC_BUILDINGS = dedupeInOrder(BUILDINGS.map(genericBuildingKey)); // 72 const SETTLEMENT_TYPES = dedupeInOrder(SETTLEMENTS.map(settlementTypeKey)); // 8 // Unified erection vocab: buildings first, then settlement types. Index here // is the embedding id minus one (id 0 is reserved for "empty tile"). const ERECTION_VOCAB = [...GENERIC_BUILDINGS, ...SETTLEMENT_TYPES]; const buildingMaskIndex = new Map(); BUILDINGS.forEach((b) => buildingMaskIndex.set(b, GENERIC_BUILDINGS.indexOf(genericBuildingKey(b)))); const settlementHandIndex = new Map(); SETTLEMENTS.forEach((s) => settlementHandIndex.set(s, SETTLEMENT_TYPES.indexOf(settlementTypeKey(s)))); // erection id: 1..ERECTION_VOCAB.length (0 reserved for empty) const erectionId = (e) => { if (SETTLEMENT_SET.has(e)) return GENERIC_BUILDINGS.length + SETTLEMENT_TYPES.indexOf(settlementTypeKey(e)) + 1; return GENERIC_BUILDINGS.indexOf(genericBuildingKey(e)) + 1; }; // --- Dimensions ----------------------------------------------------------- // Grid is anchored: a player's logical row 0 always lands at output row // ANCHOR. H/W cover the worst-case board; cells outside it are clipped (rare). const H = 38; const W = 9; const ANCHOR = 18; const MAX_PLAYERS = 4; const RONDEL_PERIOD = 13; const MAX_PRICE_SLOTS = 9; const MAX_RONDEL_YIELD = 10; // armValues cap; normalizes yields into [0, 1] // Reserved capacity for every building/settlement-vocab feature, so new // expansion buildings never change FEATURE_LEN. const VOCAB_CAPACITY = 256; const LAND_LEN = LANDS.length; // 6 const ERECT_ID_LEN = 1; // categorical const CLERGY_LEN = 3; // [laybrother-present, prior-present, opponent-owned] const TILE_CH = LAND_LEN + ERECT_ID_LEN + CLERGY_LEN; // 10 const ERECT_ID_CHANNEL = LAND_LEN; // tile-local index of the categorical channel const SETTLEMENT_HAND_LEN = SETTLEMENT_TYPES.length; // 8 const PLAYER_SCALAR_LEN = RESOURCES.length + // resource counts 1 + // wonders 4 + // [lb_unplaced, lb_placed, prior_unplaced, prior_placed] SETTLEMENT_HAND_LEN; // in-hand settlement types const PLAYER_GRID_LEN = H * W * TILE_CH; const PLAYER_BLOCK = PLAYER_SCALAR_LEN + PLAYER_GRID_LEN; const FRAME_LEN = 1 + // round SETTLEMENT_ROUNDS.length + // one-hot MAX_PLAYERS + // currentPlayerIndex one-hot (rotated slot) MAX_PLAYERS + // activePlayerIndex one-hot 4 + // mainActionUsed, neutralBuildingPhase, bonusRoundPlacement, canBuyLandscape COMMANDS.length + // bonusActions mask NEXT_USES.length + // one-hot VOCAB_CAPACITY * 2; // usableBuildings + unusableBuildings masks const SHARED_LEN = RONDEL_KEYS.length + // normalized rondel deltas RONDEL_KEYS.length + // normalized rondel yields VOCAB_CAPACITY + // still-available buildings mask MAX_PRICE_SLOTS * 2 + // plot + district prices 1 + // wonders remaining MAX_PLAYERS + // config.players one-hot LENGTHS.length + // config.length one-hot COUNTRIES.length; // config.country one-hot export const FEATURE_LEN = MAX_PLAYERS * PLAYER_BLOCK + FRAME_LEN + SHARED_LEN; export const featureSpec = { featureLen: FEATURE_LEN, height: H, width: W, gridAnchor: ANCHOR, maxPlayers: MAX_PLAYERS, tileChannels: TILE_CH, vocabCapacity: VOCAB_CAPACITY, tile: { landOffset: 0, landLen: LAND_LEN, erectionIdChannel: ERECT_ID_CHANNEL, clergyOffset: LAND_LEN + ERECT_ID_LEN, clergyLen: CLERGY_LEN, }, categorical: [{ name: 'erection', tileChannel: ERECT_ID_CHANNEL, capacity: VOCAB_CAPACITY, vocab: ERECTION_VOCAB }], offsets: { players: [0, PLAYER_BLOCK, PLAYER_BLOCK * 2, PLAYER_BLOCK * 3], frame: MAX_PLAYERS * PLAYER_BLOCK, shared: MAX_PLAYERS * PLAYER_BLOCK + FRAME_LEN, playerResources: 0, playerWonders: RESOURCES.length, playerClergy: RESOURCES.length + 1, playerSettlements: RESOURCES.length + 1 + 4, playerGrid: PLAYER_SCALAR_LEN, }, vocab: { erections: ERECTION_VOCAB, buildings: GENERIC_BUILDINGS, settlements: SETTLEMENT_TYPES, lands: LANDS, commands: COMMANDS, resources: RESOURCES, rondelKeys: RONDEL_KEYS, settlementRounds: SETTLEMENT_ROUNDS, nextUses: NEXT_USES, lengths: LENGTHS, countries: COUNTRIES, }, }; // --- Imperative writer ---------------------------------------------------- // Writes straight into one preallocated buffer; no intermediate arrays. // Float32Array is zero-initialized, so one-hots/masks only set the hot slots. class Writer { buf; pos = 0; constructor(buf) { this.buf = buf; } put(v) { this.buf[this.pos++] = v; } hot(n, idx) { if (idx >= 0 && idx < n) this.buf[this.pos + idx] = 1; this.pos += n; } bits(n, idxs) { for (const i of idxs) if (i >= 0 && i < n) this.buf[this.pos + i] = 1; this.pos += n; } skip(n) { this.pos += n; } } // Rotate so `perspective` lands in slot 0; relative turn order is preserved. // Slots past players.length stay undefined (zero-padded block). const rotateOrder = (numPlayers, perspective) => Array.from({ length: MAX_PLAYERS }, (_, slot) => (slot < numPlayers ? (perspective + slot) % numPlayers : undefined)); const writeTile = (w, tile, isSelf) => { if (tile === undefined) { w.skip(TILE_CH); return; } const [land, erection, clergy] = tile; w.hot(LAND_LEN, land !== undefined ? LANDS.indexOf(land) : -1); w.put(erection !== undefined ? erectionId(erection) : 0); if (clergy !== undefined) { w.buf[w.pos + (isPrior(clergy) ? 1 : 0)] = 1; // [laybrother, prior] presence if (!isSelf) w.buf[w.pos + 2] = 1; // opponent-owned } w.skip(CLERGY_LEN); }; const clergyBuckets = (t, config) => { const unplaced = new Set(t.clergy); let lbUnplaced = 0; let lbPlaced = 0; let priorUnplaced = 0; let priorPlaced = 0; for (const c of clergyForColor(config)(t.color)) { const placed = !unplaced.has(c); if (isPrior(c)) { if (placed) priorPlaced++; else priorUnplaced++; } else if (placed) { lbPlaced++; } else { lbUnplaced++; } } return [lbUnplaced, lbPlaced, priorUnplaced, priorPlaced]; }; const writeTableau = (w, t, isSelf, config) => { for (const r of RESOURCES) w.put(t[r] ?? 0); w.put(t.wonders); for (const v of clergyBuckets(t, config)) w.put(v); w.bits(SETTLEMENT_HAND_LEN, t.settlements.map((s) => settlementHandIndex.get(s) ?? -1)); for (let outputRow = 0; outputRow < H; outputRow++) { const row = t.landscape[outputRow + t.landscapeOffset - ANCHOR]; for (let c = 0; c < W; c++) writeTile(w, row?.[c], isSelf); } }; const writeFrame = (w, frame, order) => { w.put(frame.round); w.hot(SETTLEMENT_ROUNDS.length, SETTLEMENT_ROUNDS.indexOf(frame.settlementRound)); w.hot(MAX_PLAYERS, order.indexOf(frame.currentPlayerIndex)); w.hot(MAX_PLAYERS, order.indexOf(frame.activePlayerIndex)); w.put(frame.mainActionUsed ? 1 : 0); w.put(frame.neutralBuildingPhase ? 1 : 0); w.put(frame.bonusRoundPlacement ? 1 : 0); w.put(frame.canBuyLandscape ? 1 : 0); w.bits(COMMANDS.length, frame.bonusActions.map((c) => COMMANDS.indexOf(c))); w.hot(NEXT_USES.length, NEXT_USES.indexOf(frame.nextUse)); w.bits(VOCAB_CAPACITY, frame.usableBuildings.map((b) => buildingMaskIndex.get(b) ?? -1)); w.bits(VOCAB_CAPACITY, frame.unusableBuildings.map((b) => buildingMaskIndex.get(b) ?? -1)); }; const rondelDelta = (rondel, key) => { const slot = rondel[key]; if (slot === undefined) return 0; return ((((slot - rondel.pointingBefore) % RONDEL_PERIOD) + RONDEL_PERIOD) % RONDEL_PERIOD) / RONDEL_PERIOD; }; const rondelYield = (rondel, key, config) => { const slot = rondel[key]; if (slot === undefined) return 0; return take(rondel.pointingBefore, slot, config) / MAX_RONDEL_YIELD; }; const writeShared = (w, state) => { const rondel = state.rondel; const config = state.config; for (const key of RONDEL_KEYS) w.put(rondelDelta(rondel, key)); for (const key of RONDEL_KEYS) w.put(rondelYield(rondel, key, config)); w.bits(VOCAB_CAPACITY, state.buildings.map((b) => buildingMaskIndex.get(b) ?? -1)); for (let i = 0; i < MAX_PRICE_SLOTS; i++) w.put(state.plotPurchasePrices[i] ?? 0); for (let i = 0; i < MAX_PRICE_SLOTS; i++) w.put(state.districtPurchasePrices[i] ?? 0); w.put(state.wonders); w.hot(MAX_PLAYERS, config.players - 1); w.hot(LENGTHS.length, LENGTHS.indexOf(config.length)); w.hot(COUNTRIES.length, COUNTRIES.indexOf(config.country)); }; export const encode = (state, perspective) => { const buf = new Float32Array(FEATURE_LEN); if (state.status === GameStatusEnum.SETUP) return buf; const p = perspective ?? state.frame.currentPlayerIndex; const order = rotateOrder(state.players.length, p); const w = new Writer(buf); order.forEach((idx, slot) => { if (idx === undefined) w.skip(PLAYER_BLOCK); else writeTableau(w, state.players[idx], slot === 0, state.config); }); writeFrame(w, state.frame, order); writeShared(w, state); return buf; }; //# sourceMappingURL=encode.js.map