1/**2 * Game-domain contract v2 — shared by engine, script-host, worker, and web.3 *4 * V2 DESIGN SPINE: geography → extraction → refinement → finished goods →5 * (people | soldiers | trade). Tribes are dense populations (1,000+ citizens)6 * simulated as SCRIPTABLE GROUPS: scripts set persistent per-mille policies7 * (allocations, craft splits, decrees, standing orders) and spend their gas8 * budget ADJUSTING policy, not issuing per-unit orders. Soldiers are equipped9 * from the smithy chain — military power is downstream of the whole economy.10 *11 * DETERMINISM RULES (binding for every implementer):12 * - All sim quantities are integers; rates/shares are per-mille (0..1000).13 * - Fractional flows use fixed-point accumulators in hashed state:14 * out = (base*rate + acc) div 1000; acc' = (base*rate + acc) mod 1000.15 * - Allocation across a set uses largest-remainder apportionment, ties broken16 * by ascending id/enum order.17 * - The tick is a pure function (state, actionsByTribe) -> state+1. Replay is18 * seed + scripts. PRNG use is confined to mapgen, threat placement, and a19 * bounded battle-variance term, consumed in fixed phase order.20 * - Slow systems run on a SLOW_TICK_INTERVAL cadence in fixed phase order.21 * - Scripts are metered by deterministic gas (interrupt-poll counting).22 */2324// ---------------------------------------------------------------------------25// Limits & constants26// ---------------------------------------------------------------------------2728export const LIMITS = {29 /** Largest world (6-8 tribes); see mapSizeForTribes for the ladder. */30 MAP_SIZE: 80,31 MAX_TICKS: 10_000,32 MIN_TRIBES: 2,33 MAX_TRIBES: 8,34 /** Starting population: a 10-soul founding camp. Tribes GROW into their35 * thousands — establishing safety and an economy is the early game. */36 START_POP: 10,37 /** Cadence (ticks) for slow systems and metrics frames. */38 SLOW_TICK_INTERVAL: 10,39 /** Max UTF-8 bytes of a submitted script. */40 SCRIPT_MAX_BYTES: 64 * 1024,41 /** Max bytes of the JSON-serialized per-tribe `memory` blob. */42 MEMORY_MAX_BYTES: 64 * 1024,43 /** Deterministic gas budget per tribe per tick, in QuickJS interrupt polls. */44 GAS_PER_TICK: 512,45 /** Consecutive over-budget ticks before a tribe forfeits. */46 MAX_CONSECUTIVE_OVERRUNS: 50,47 /** Hard memory cap for each tribe's QuickJS VM, bytes. */48 VM_MEMORY_LIMIT: 32 * 1024 * 1024,49 /** Max stars per user across scripts and sims combined. */50 MAX_STARS: 100,51 /** TTL for unstarred scripts and sims, in days. */52 TTL_DAYS: 14,53 /** Max characters of an api.say() message after sanitation. */54 SAY_MAX_CHARS: 120,55 /** Max api.say() messages kept per tribe per tick (extras dropped). */56 SAYS_PER_TICK: 1,57 /** Max events delivered in one TribeView (newest kept). */58 EVENTS_PER_VIEW: 60,59 /** Max standing trade offers per tribe. */60 MAX_OFFERS: 16,61 /** Max standing routes per tribe. */62 MAX_ROUTES: 24,63 /** Max actions applied per tribe per tick (rest dropped, tallied). */64 ACTIONS_PER_TICK: 64,65} as const;6667// ---------------------------------------------------------------------------68// Noun categories (the ontology)69// ---------------------------------------------------------------------------7071/**72 * World size scales with the roster: 2-3 tribes share 48×48, 4-5 share 64×64,73 * 6-8 share 80×80. Sized so contested ground exists without the map filling74 * up — claim budgets cap each tribe's reach, and most games should end with75 * real wilderness left between the borders.76 */77export function mapSizeForTribes(tribeCount: number): number {78 if (tribeCount <= 3) return 48;79 if (tribeCount <= 5) return 64;80 return 80;81}8283/** Resources in dependency chains: raw → intermediate → finished. */84export const RESOURCES = [85 // raw86 'grain', 'fish', 'meat', 'hides', 'wool', 'timber', 'stone', 'ore', 'coin',87 // intermediate88 'flour', 'planks', 'metal', 'leather', 'cloth',89 // finished90 'bread', 'ale', 'tools', 'weapons', 'armor', 'clothes',91] as const;92export type Resource = (typeof RESOURCES)[number];9394/**95 * Professions (cohort labor). Extraction works at reduced yield without96 * `tools` (which wear out) — the internal demand engine. Soldiers are NOT a97 * profession: they are population removed into warbands via `muster`.98 * `idle` is the unallocated remainder (contributes to growth only).99 */100export const PROFESSIONS = [101 'farmer', 'fisher', 'herder', 'woodcutter', 'miner',102 'miller', 'baker', 'brewer', 'smelter', 'smith', 'carpenter', 'tanner', 'weaver', 'tailor',103 'builder', 'hauler', 'scholar', 'priest',104] as const;105export type Profession = (typeof PROFESSIONS)[number];106107export const BUILDINGS = [108 'hall', 'house', 'granary', 'warehouse',109 'farm', 'dock', 'pasture', 'lumber_camp', 'mine',110 'mill', 'bakery', 'brewery', 'smelter', 'smithy', 'tannery', 'weavers_hall', 'tailor_shop',111 'market', 'shrine', 'church', 'library', 'academy', 'barracks',112 'walls', 'tower',113] as const;114export type Building = (typeof BUILDINGS)[number];115116/** Tech DAG (prereqs documented in the engine README / llms.txt). */117export const TECHS = [118 'milling', 'brewing', 'smelting', 'weaponsmithing', 'plate_armor',119 'masonry', 'castellany', 'coinage', 'crop_rotation', 'horsemanship',120 'scholarship', 'liturgy', 'siegecraft',121] as const;122export type Tech = (typeof TECHS)[number];123124/**125 * Terrain chars for TribeView.terrain (row-major mapSize² string):126 * '?' unexplored - '.' plains - 'f' forest - 'h' hills - 'm' mountain -127 * 'w' water - 'r' river - 's' swamp - 't' steppe - 'a' arctic - 'v' volcano -128 * 'O' ore vein - 'G' gold vein. Coast = land tile adjacent to water.129 */130export type TerrainChar =131 | '?' | '.' | 'f' | 'h' | 'm' | 'w' | 'r' | 's' | 't' | 'a' | 'v' | 'O' | 'G';132133/**134 * Distant-view category chars for TribeView.distant: beyond explored ground,135 * a glimpse band reveals terrain CATEGORY silhouettes, never specifics:136 * '?' unknown - '.' open ground (plains/steppe/arctic) - 'f' vegetation137 * (forest/swamp) - '^' highland (hills/mountain/volcano — veins NOT shown) -138 * 'w' water (ocean/river).139 */140export type DistantChar = '?' | '.' | 'f' | '^' | 'w';141142export type WarbandKind = 'infantry' | 'archers' | 'cavalry';143144export type WarbandOrderType =145 | 'garrison' | 'patrol' | 'march' | 'raid' | 'besiege' | 'escort' | 'disband';146147export interface WarbandOrder {148 type: WarbandOrderType;149 /** Meaning depends on type: settlement id (garrison/besiege/raid of a150 * settlement), {x,y} point (march/patrol), caravan id (escort/raid), or151 * null (disband). */152 target: number | { x: number; y: number } | null;153}154155export type Posture = 'ally' | 'neutral' | 'hostile';156157export type DecreeKey = 'rations' | 'slaughter' | 'fodder' | 'ale_ration' | 'expansion';158159export type RiteKind = 'blessing' | 'harvest' | 'warding' | 'war';160161/** Workshop output splits set via the `craft` verb. */162export type CraftTarget =163 | 'tools' | 'weapons' | 'armor' // smithy split164 | 'mill' | 'brew' | 'fodder'; // grain split165166export type ThreatKind = 'goblin_camp' | 'skeleton_barrow' | 'ogre_den' | 'demon_gate';167168/**169 * Ages — the prestige ladder. A tribe BURNS a hoard of resources (via the170 * `advance` verb) to enter the next age. Each age grants a permanent171 * extraction/refinement rate bonus, a warband power bonus, a score term, and172 * a score MULTIPLIER — advanced civilizations out-farm, out-mine and out-war173 * the wandering ones, and their score accumulates faster. Costs escalate per174 * age; see the engine README for exact hoards and bonuses.175 */176export const AGES = ['wandering', 'settled', 'crafting', 'high'] as const;177export type AgeName = (typeof AGES)[number];178179// ---------------------------------------------------------------------------180// Actions — what a script emits each tick (all persistent policy unless noted)181// ---------------------------------------------------------------------------182183export type Order =184 | { kind: 'allocate'; profession: Profession; permille: number; settlementId?: number }185 | { kind: 'craft'; target: CraftTarget; permille: number }186 | { kind: 'build'; building: Building; settlementId: number; count?: number }187 | { kind: 'demolish'; building: Building; settlementId: number }188 | { kind: 'found'; x: number; y: number; settlers: number; supplies: Partial<Record<Resource, number>> }189 | { kind: 'route'; fromId: number; toId: number; resource: Resource; permilleOfSurplus: number }190 | { kind: 'muster'; settlementId: number; size: number; unitKind: WarbandKind }191 | { kind: 'command'; warbandId: number; order: WarbandOrder }192 | {193 kind: 'trade_offer';194 give: Resource;195 giveAmount: number;196 want: Resource;197 wantAmount: number;198 /** Counterparty tribe id, or null = anyone non-hostile. */199 tribe: number | null;200 repeat: boolean;201 }202 | { kind: 'trade_cancel'; offerId: number }203 | { kind: 'stance'; tribeId: number; posture: Posture }204 | { kind: 'research'; tech: Tech }205 | { kind: 'decree'; key: DecreeKey; value: number }206 /** One-shot faith spend (the exception to persistence). */207 | { kind: 'rite'; rite: RiteKind; target: number | null }208 /** One-shot prestige: burn the age hoard (drawn across your settlements)209 * to advance to the next age. Dropped if unaffordable or already at max. */210 | { kind: 'advance' }211 /** Broadcast a short sanitized message to every tribe's world feed. */212 | { kind: 'say'; text: string };213214// ---------------------------------------------------------------------------215// Views — fog-filtered snapshot handed to a script each tick216// ---------------------------------------------------------------------------217218export interface SettlementView {219 id: number;220 x: number;221 y: number;222 /** 0 camp - 1 village - 2 town - 3 city (auto-upgrades at pop 100/400/1000). */223 tier: number;224 pop: number;225 /** 0..1000 */226 morale: number;227 stores: Record<Resource, number>;228 storeCap: number;229 buildings: Partial<Record<Building, number>>;230 buildQueue: Array<{ building: Building; progress: number }>;231 /** Tiles this settlement currently claims (organic, terrain-shaped). */232 territoryTiles: number;233 /** Current workers apportioned per profession (derived from allocation). */234 workers: Partial<Record<Profession, number>>;235 /** Capacity per profession (building slots × biome census). */236 capacity: Partial<Record<Profession, number>>;237 /** Livestock. */238 herds: number;239 horses: number;240}241242export interface WarbandView {243 id: number;244 home: number;245 size: number;246 kind: WarbandKind;247 weapons: number;248 armor: number;249 /** 0..1000 */250 veterancy: number;251 morale: number;252 x: number;253 y: number;254 order: WarbandOrder;255 /** Food carried; attrition outside own territory when empty. */256 supply: number;257 /**258 * Ticks of training remaining. Freshly mustered/reinforced warbands DRILL259 * before they are campaign-ready: while drilling > 0 they fight at reduced260 * power and their raid/besiege orders are refused (defense works normally).261 * Aggression requires build-up — no raiding farmers with day-one levies.262 */263 drilling: number;264}265266export interface CaravanView {267 id: number;268 tribe: number;269 x: number;270 y: number;271 resource: Resource;272 amount: number;273}274275export interface ThreatView {276 id: number;277 kind: ThreatKind;278 x: number;279 y: number;280 strength: number;281}282283export interface TradeOfferView {284 id: number;285 tribe: number;286 give: Resource;287 giveAmount: number;288 want: Resource;289 wantAmount: number;290 counterparty: number | null;291 repeat: boolean;292}293294export interface EnemySettlementSighting {295 tribe: number;296 id: number;297 x: number;298 y: number;299 tier: number;300 wallLevel: number;301}302303export interface EnemyWarbandSighting {304 tribe: number;305 id: number;306 x: number;307 y: number;308 size: number;309 kind: WarbandKind;310}311312/** The `state` argument to tick(state, memory, api). Plain JSON, read-only. */313export interface TribeView {314 tick: number;315 tribeId: number;316 mapSize: number;317 /** Explored terrain (see TerrainChar legend). */318 terrain: string;319 /**320 * Distant glimpses (see DistantChar legend): category-only silhouettes in a321 * band beyond explored ground. Persistent, like `terrain`. Cells that are322 * explored (exact in `terrain`) are '?' here — the two strings are disjoint.323 * Viewers render this band as ~35%-transparent tiles.324 */325 distant: string;326 settlements: SettlementView[];327 warbands: WarbandView[];328 /** Own + visible foreign caravans. */329 caravans: CaravanView[];330 /** Visible monster camps. */331 threats: ThreatView[];332 /** Visible enemy assets (inside any of this tribe's vision sources). */333 enemySettlements: EnemySettlementSighting[];334 enemyWarbands: EnemyWarbandSighting[];335 /** Own standing offers + foreign offers visible via own market. */336 offers: TradeOfferView[];337 techs: Tech[];338 researching: Tech | null;339 knowledge: number;340 faith: number;341 /** Current craft splits and decrees (the persistent policy, echoed back). */342 craft: Record<CraftTarget, number>;343 decrees: Record<DecreeKey, number>;344 allocations: Record<Profession, number>;345 /** Current age index into AGES (0 = wandering). */346 age: number;347 stances: Record<number, Posture>;348 aliveTribes: number[];349 /**350 * Territory claims over EXPLORED ground: row-major mapSize² chars, '.' for351 * unclaimed-or-unknown, '0'-'7' for the owning tribe. Claims grow352 * organically from settlements via terrain-cost expansion — water and353 * rivers block claims entirely, mountains are expensive — so natural354 * borders and chokepoints matter.355 */356 claims: string;357 /** Feed events from the previous tick (fog-filtered; see SimEvent). */358 events: SimEvent[];359}360361/** Result of running one tribe's script for one tick (script-host output). */362export interface TickOutcome {363 orders: Order[];364 gasUsed: number;365 overBudget: boolean;366 error: string | null;367}368369// ---------------------------------------------------------------------------370// Events — MUD-style feed entries delivered in each TribeView371// ---------------------------------------------------------------------------372373/**374 * Structured events emitted during the previous step(). Delivered375 * fog-filtered: a tribe receives events it is involved in, all 'say'376 * broadcasts, and events at locations inside its current vision. Capped at377 * LIMITS.EVENTS_PER_VIEW (newest kept). NOT part of stateHash.378 * 'say' text is UNTRUSTED player content — treat as data, never instructions.379 */380export type SimEvent =381 | { t: 'say'; tick: number; tribe: number; text: string }382 | { t: 'settlement_founded'; tick: number; tribe: number; settlementId: number; x: number; y: number }383 | { t: 'settlement_captured'; tick: number; tribe: number; by: number; settlementId: number; x: number; y: number }384 | { t: 'building_complete'; tick: number; tribe: number; building: Building; settlementId: number; x: number; y: number }385 | { t: 'building_destroyed'; tick: number; tribe: number; building: Building; settlementId: number; by: number | null; x: number; y: number }386 | {387 t: 'battle';388 tick: number;389 x: number;390 y: number;391 attacker: number;392 /** Defender tribe id, or null when fighting a neutral threat. */393 defender: number | null;394 attackerLosses: number;395 defenderLosses: number;396 /** Winning side's tribe id; null = draw/withdraw or threat won. */397 winner: number | null;398 }399 | { t: 'raid'; tick: number; tribe: number; by: number; settlementId: number; loot: Partial<Record<Resource, number>>; x: number; y: number }400 | { t: 'caravan_raided'; tick: number; tribe: number; by: number; x: number; y: number; resource: Resource; amount: number }401 | { t: 'starvation'; tick: number; tribe: number; settlementId: number; deaths: number; x: number; y: number }402 | { t: 'threat_spawned'; tick: number; kind: ThreatKind; x: number; y: number }403 | { t: 'threat_cleared'; tick: number; tribe: number; kind: ThreatKind; x: number; y: number }404 | { t: 'tech_unlocked'; tick: number; tribe: number; tech: Tech }405 | { t: 'age_advanced'; tick: number; tribe: number; age: number }406 | { t: 'sighted'; tick: number; tribe: number; enemy: number; x: number; y: number }407 | {408 /** One of your orders was dropped, and why — the engine's answer to409 * silent-failure freezes (capped at 8 per tribe per tick). */410 t: 'order_dropped';411 tick: number;412 tribe: number;413 order: string;414 reason: string;415 }416 | { t: 'eliminated'; tick: number; tribe: number }417 | { t: 'forfeited'; tick: number; tribe: number };418419// ---------------------------------------------------------------------------420// Metrics — the stats-first viewer contract (emitted every SLOW_TICK_INTERVAL)421// ---------------------------------------------------------------------------422423export interface TribeMetrics {424 tribeId: number;425 alive: boolean;426 // population lens427 pop: number;428 popByProfession: Record<Profession, number>;429 idle: number;430 soldiers: number;431 moraleAvg: number;432 births: number;433 deaths: number;434 starvationDeaths: number;435 // economy lens436 stores: Record<Resource, number>;437 /** Produced this interval, per resource. */438 production: Record<Resource, number>;439 /** Consumed this interval, per resource. */440 consumption: Record<Resource, number>;441 /** Per-mille of extractive workers holding tools. */442 toolCoverage: number;443 // military lens444 warbands: number;445 warbandPower: number;446 battlesWon: number;447 battlesLost: number;448 casualtiesDealt: number;449 casualtiesTaken: number;450 campsCleared: number;451 // territory lens452 territoryTiles: number;453 settlementsByTier: [number, number, number, number];454 buildingsCompleted: number;455 // tech / faith / trade lens456 techsUnlocked: number;457 /** Age index (prestige tier). */458 age: number;459 knowledge: number;460 faith: number;461 coin: number;462 tradeVolume: number;463 caravansLost: number;464 // headline465 score: number;466 scoreBreakdown: {467 pop: number;468 settlements: number;469 techs: number;470 military: number;471 coin: number;472 buildings: number;473 /** Prestige term (before the age multiplier is applied to the total). */474 age: number;475 };476}477478export interface MetricsFrame {479 tick: number;480 tribes: TribeMetrics[];481}482483// ---------------------------------------------------------------------------484// Sim config & results485// ---------------------------------------------------------------------------486487export interface SimConfig {488 /** Decimal-string seed for the engine PRNG (fits in 2^53). */489 seed: string;490 tribeCount: number;491 mapSize: number;492 maxTicks: number;493}494495export interface TribeResult {496 tribeId: number;497 alive: boolean;498 score: number;499 scoreBreakdown: TribeMetrics['scoreBreakdown'];500 pop: number;501 settlements: number;502 casualtiesDealt: number;503 diedAt: number | null;504}505506export interface SimResult {507 endedAt: number;508 reason: 'last_standing' | 'tick_cap';509 winner: number | null;510 tribes: TribeResult[];511}512513// ---------------------------------------------------------------------------514// Engine interface (implemented by @tribes/engine)515// ---------------------------------------------------------------------------516517export interface EngineWorld {518 readonly tick: number;519 readonly config: SimConfig;520 viewFor(tribeId: number): TribeView;521 /** Apply per-tribe actions (invalid dropped silently, tallied), advance one tick. */522 step(ordersByTribe: ReadonlyMap<number, Order[]>): void;523 forfeit(tribeId: number): void;524 /** Deterministic hash of full sim state (accumulators & policies included). */525 stateHash(): string;526 isOver(): boolean;527 result(): SimResult;528 /**529 * The metrics frame computed on the most recent SLOW_TICK_INTERVAL boundary,530 * or null before the first one. Also recomputed on the final tick.531 */532 latestMetrics(): MetricsFrame | null;533}534535export type CreateWorld = (config: SimConfig) => EngineWorld;536537// ---------------------------------------------------------------------------538// Script host interface (implemented by @tribes/script-host)539// ---------------------------------------------------------------------------540541export interface CompileResult {542 ok: boolean;543 error: string | null;544}545546export interface TribeVmOptions {547 gasPerTick: number;548 memoryLimitBytes: number;549 rngSeed: string;550}551552export interface TribeVm {553 runTick(view: TribeView): TickOutcome;554 dispose(): void;555}556557export interface ScriptHost {558 compile(source: string): CompileResult;559 createTribeVm(source: string, opts: TribeVmOptions): TribeVm;560 dispose(): void;561}