hathora-et-labora-game
Version:
Game logic reducer for Uwe Rosenberg's Ora et Labora
325 lines • 13 kB
JavaScript
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