hathora-et-labora-game
Version:
Game logic reducer for Uwe Rosenberg's Ora et Labora
407 lines (380 loc) • 14.4 kB
text/typescript
import {
BuildingEnum,
Clergy,
Cost,
ErectionEnum,
Frame,
GameCommandConfigParams,
GameCommandEnum,
GameConfigCountry,
GameConfigLength,
GameState,
GameStatusEnum,
LandEnum,
NextUseClergy,
Rondel,
SettlementEnum,
SettlementRound,
Tableau,
Tile,
} 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) as BuildingEnum[]
const SETTLEMENTS = Object.values(SettlementEnum) as SettlementEnum[]
const LANDS = Object.values(LandEnum) as LandEnum[]
const COMMANDS = Object.values(GameCommandEnum) as GameCommandEnum[]
const SETTLEMENT_ROUNDS = Object.values(SettlementRound) as SettlementRound[]
const NEXT_USES = Object.values(NextUseClergy) as NextUseClergy[]
const LENGTHS: GameConfigLength[] = ['short', 'long']
const COUNTRIES: GameConfigCountry[] = ['ireland', 'france']
const RONDEL_KEYS: (keyof Omit<Rondel, 'pointingBefore'>)[] = [
'wood',
'clay',
'coin',
'joker',
'grain',
'peat',
'sheep',
'grape',
'stone',
]
const RESOURCES: (keyof Required<Cost>)[] = [
'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<string>(SETTLEMENTS)
const genericBuildingKey = (b: BuildingEnum): string => {
const m = /^L([RGBW])([123])$/.exec(b)
if (m) return ['', 'ClayMound', 'FarmYard', 'CloisterOffice'][Number(m[2])]
return b
}
const settlementTypeKey = (s: SettlementEnum): string => {
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: string[]): string[] => 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<BuildingEnum, number>()
BUILDINGS.forEach((b) => buildingMaskIndex.set(b, GENERIC_BUILDINGS.indexOf(genericBuildingKey(b))))
const settlementHandIndex = new Map<SettlementEnum, number>()
SETTLEMENTS.forEach((s) => settlementHandIndex.set(s, SETTLEMENT_TYPES.indexOf(settlementTypeKey(s))))
// erection id: 1..ERECTION_VOCAB.length (0 reserved for empty)
const erectionId = (e: ErectionEnum): number => {
if (SETTLEMENT_SET.has(e))
return GENERIC_BUILDINGS.length + SETTLEMENT_TYPES.indexOf(settlementTypeKey(e as SettlementEnum)) + 1
return GENERIC_BUILDINGS.indexOf(genericBuildingKey(e as BuildingEnum)) + 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 type FeatureSpec = {
featureLen: number
height: number
width: number
gridAnchor: number
maxPlayers: number
tileChannels: number
vocabCapacity: number
tile: {
landOffset: number
landLen: number
erectionIdChannel: number
clergyOffset: number
clergyLen: number
}
// Channels the model must embed rather than treat as continuous floats.
categorical: { name: string; tileChannel: number; capacity: number; vocab: string[] }[]
offsets: {
players: number[]
frame: number
shared: number
// offsets within one player block
playerResources: number
playerWonders: number
playerClergy: number
playerSettlements: number
playerGrid: number
}
vocab: {
erections: string[]
buildings: string[]
settlements: string[]
lands: LandEnum[]
commands: GameCommandEnum[]
resources: (keyof Required<Cost>)[]
rondelKeys: (keyof Omit<Rondel, 'pointingBefore'>)[]
settlementRounds: SettlementRound[]
nextUses: NextUseClergy[]
lengths: GameConfigLength[]
countries: GameConfigCountry[]
}
}
export const featureSpec: 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 {
pos = 0
constructor(readonly buf: Float32Array) {}
put(v: number): void {
this.buf[this.pos++] = v
}
hot(n: number, idx: number): void {
if (idx >= 0 && idx < n) this.buf[this.pos + idx] = 1
this.pos += n
}
bits(n: number, idxs: number[]): void {
for (const i of idxs) if (i >= 0 && i < n) this.buf[this.pos + i] = 1
this.pos += n
}
skip(n: number): void {
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: number, perspective: number): (number | undefined)[] =>
Array.from({ length: MAX_PLAYERS }, (_, slot) => (slot < numPlayers ? (perspective + slot) % numPlayers : undefined))
const writeTile = (w: Writer, tile: Tile | undefined, isSelf: boolean): void => {
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: Tableau, config: GameCommandConfigParams): [number, number, number, number] => {
const unplaced = new Set<Clergy>(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: Writer, t: Tableau, isSelf: boolean, config: GameCommandConfigParams): void => {
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: Writer, frame: Frame, order: (number | undefined)[]): void => {
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: Rondel, key: (typeof RONDEL_KEYS)[number]): number => {
const slot = rondel[key]
if (slot === undefined) return 0
return ((((slot - rondel.pointingBefore) % RONDEL_PERIOD) + RONDEL_PERIOD) % RONDEL_PERIOD) / RONDEL_PERIOD
}
const rondelYield = (rondel: Rondel, key: (typeof RONDEL_KEYS)[number], config: GameCommandConfigParams): number => {
const slot = rondel[key]
if (slot === undefined) return 0
return take(rondel.pointingBefore, slot, config) / MAX_RONDEL_YIELD
}
const writeShared = (w: Writer, state: GameState): void => {
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: GameState, perspective?: number): Float32Array => {
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
}