1import { describe, expect, it } from 'vitest';2import type { EngineWorld, MetricsFrame, Order, SimEvent, TribeView } from '@tribes/protocol';3import { LIMITS, PROFESSIONS, RESOURCES } from '@tribes/protocol';4import { apportion, createWorld } from '../src/index.js';5import {6 MUSTER_DRILL_TICKS,7 MUSTER_MIN_SIZE,8 SETTLEMENT_POP_FLOOR,9 START_HERDS,10 START_STORES,11 THREAT_GRACE_TICKS,12} from '../src/constants.js';1314type Bot = (view: TribeView) => Order[];1516const passiveBot: Bot = () => [];1718const A = (profession: string, permille: number): Order =>19 ({ kind: 'allocate', profession, permille }) as Order;2021/** Self-sufficient economy policy (~40% food labor). */22const basePolicy = (): Order[] => [23 A('farmer', 400),24 A('woodcutter', 100),25 A('miner', 60),26 A('herder', 30),27 A('smith', 20),28 A('builder', 60),29 A('hauler', 20),30 A('scholar', 20),31 A('priest', 10),32];3334/** Policy-at-tick-0 economy bot. */35const economyBot: Bot = (v) => (v.tick === 0 ? basePolicy() : []);3637function stepWithBots(world: EngineWorld, bots: ReadonlyMap<number, Bot>): void {38 const orders = new Map<number, Order[]>();39 for (const [id, bot] of bots) orders.set(id, bot(world.viewFor(id)));40 world.step(orders);41}4243function runTicks(world: EngineWorld, bots: ReadonlyMap<number, Bot>, ticks: number): void {44 for (let i = 0; i < ticks && !world.isOver(); i++) stepWithBots(world, bots);45}4647const botsFor = (n: number, bot: Bot): Map<number, Bot> => {48 const m = new Map<number, Bot>();49 for (let i = 0; i < n; i++) m.set(i, bot);50 return m;51};5253const metricsOf = (world: EngineWorld, tribe: number): MetricsFrame['tribes'][number] => {54 const m = world.latestMetrics();55 if (m === null) throw new Error('no metrics yet');56 return m.tribes.find((t) => t.tribeId === tribe)!;57};5859// ---------------------------------------------------------------------------60// grow(): deterministic humble-start development policy61// ---------------------------------------------------------------------------6263interface GrowOpts {64 /** Climb the mine -> stone -> library -> smelting -> smelter -> smithy ->65 * barracks chain (weaponsmithing research follows smelting). */66 military?: boolean;67 /** Once weaponsmithing lands, put the whole smithy split on weapons.68 * (Needed because tiny per-tick outputs are apportioned without a split69 * accumulator, so the heavier/earlier target wins every single unit.) */70 arm?: boolean;71 /** Build a market (implies the mine, for stone). */72 market?: boolean;73 /** Rush a pasture before the first slow tick culls the starting herds to74 * zero pasture capacity, then breed toward 4 pastures. */75 herds?: boolean;76 /** Stockade against monster raids. */77 walls?: boolean;78}7980/**81 * A growth bot for the humble start (10 souls, one hall, wagon-load stores):82 * bootstraps food and timber off the hall's kitchen garden and salvage yard,83 * then scales farms/lumber camps/houses with population; optional chains84 * reach a militarized economy. Stateless policies + view-conditioned build85 * orders keep it fully deterministic.86 */87const growBot = (opts: GrowOpts = {}): Bot => {88 let start = -1;89 let armed = false;90 return (v) => {91 if (start === -1) start = v.tick;92 const rel = v.tick - start;93 const o: Order[] = [];94 const s = v.settlements[0];95 if (s === undefined) return o;96 if (rel === 0) {97 // Full territorial appetite: the harness models a deliberate bot, and98 // the grown-economy expectations assume the full tier/age/pop reach.99 o.push({ kind: 'decree', key: 'expansion', value: 1000 } as Order);100 if (opts.herds === true) {101 // 6 builders finish the pasture (50 pts) before the herd cull at t9.102 o.push({ kind: 'build', building: 'pasture', settlementId: s.id } as Order);103 o.push(A('builder', 600), A('farmer', 400));104 o.push({ kind: 'decree', key: 'fodder', value: 1000 } as Order);105 o.push({ kind: 'craft', target: 'fodder', permille: 200 } as Order);106 } else {107 o.push(A('farmer', 400), A('builder', 200), A('woodcutter', 200));108 }109 }110 if (rel % 25 !== 0 || rel === 0) return o;111 if (rel === 25) {112 // Standing allocation once the camp is on its feet.113 o.push(114 A('farmer', 330),115 A('woodcutter', 170),116 A('miner', 90),117 A('builder', 110),118 A('smelter', 30),119 A('smith', opts.military === true ? 40 : 5),120 A('scholar', 60),121 A('herder', opts.herds === true ? 80 : 20),122 A('hauler', 10),123 );124 }125 const b = (name: string): number => (s.buildings as Record<string, number>)[name] ?? 0;126 const have = (name: string): number =>127 b(name) + s.buildQueue.filter((q) => (q.building as string) === name).length;128 const want = (building: string, target: number): void => {129 if (have(building) < target) {130 o.push({ kind: 'build', building, settlementId: s.id } as Order);131 }132 };133 // Military/market chain first: the (timber-only) mine is the door to134 // stone — every stone-costed building (library, smithy, barracks,135 // market) waits on mined stone.136 if (opts.military === true || opts.market === true) {137 want('mine', 1);138 if (b('mine') >= 1) {139 if (opts.military === true) {140 want('library', 2);141 want('smelter', 1);142 want('smithy', 1);143 want('barracks', 1);144 }145 if (opts.walls === true) want('walls', 1);146 if (opts.market === true) want('market', 1);147 }148 if (opts.military === true && v.researching === null) {149 if (!v.techs.includes('smelting')) {150 o.push({ kind: 'research', tech: 'smelting' } as Order);151 } else if (!v.techs.includes('weaponsmithing')) {152 o.push({ kind: 'research', tech: 'weaponsmithing' } as Order);153 }154 }155 if (opts.arm === true && !armed && v.techs.includes('weaponsmithing')) {156 armed = true;157 o.push({ kind: 'craft', target: 'weapons', permille: 1000 } as Order);158 o.push({ kind: 'craft', target: 'tools', permille: 0 } as Order);159 }160 }161 if (opts.herds === true) want('pasture', 4);162 want('farm', Math.min(8, 1 + Math.trunc(s.pop / 60)));163 want('lumber_camp', Math.min(3, 1 + Math.trunc(s.pop / 150)));164 const hallHousing = 300 + 300 * s.tier;165 want('house', Math.min(18, Math.max(0, Math.ceil((s.pop * 2 - hallHousing) / 60))));166 if (s.pop > 250) want('granary', 1);167 return o;168 };169};170171/** Step up to `ticks` with growBots on `tribes` (others passive), stopping172 * early when `until` (checked after every step) reports true. */173function grow(174 world: EngineWorld,175 ticks: number,176 opts: GrowOpts & { tribes?: number[]; until?: () => boolean } = {},177): void {178 const bots = new Map<number, Bot>();179 for (const id of opts.tribes ?? [0]) bots.set(id, growBot(opts));180 for (let t = 0; t < ticks && !world.isOver(); t++) {181 stepWithBots(world, bots);182 if (opts.until !== undefined && opts.until()) return;183 }184}185186// ---------------------------------------------------------------------------187// Golden replay188// ---------------------------------------------------------------------------189190/** Scripted humble-start scenario touching allocations, craft, decree,191 * research, build, trade, muster (dropped: no barracks yet), rite and say. */192function goldenBot(id: number): Bot {193 return (v) => {194 const o: Order[] = [];195 const s = v.settlements[0];196 if (v.tick === 0) {197 o.push(...basePolicy());198 o.push({ kind: 'craft', target: 'tools', permille: 800 });199 o.push({ kind: 'decree', key: 'rations', value: 1000 });200 o.push({ kind: 'research', tech: 'milling' });201 }202 if (v.tick === 5 && s !== undefined) {203 // 50 of the 80 starting timber: one farm, one house.204 o.push({ kind: 'build', building: 'farm', settlementId: s.id });205 o.push({ kind: 'build', building: 'house', settlementId: s.id });206 }207 if (v.tick === 30) {208 o.push({209 kind: 'trade_offer',210 give: 'timber',211 giveAmount: 20,212 want: 'stone',213 wantAmount: 10,214 tribe: null,215 repeat: false,216 });217 }218 if (v.tick === 31 && id === 1) {219 o.push({220 kind: 'trade_offer',221 give: 'stone',222 giveAmount: 10,223 want: 'timber',224 wantAmount: 15,225 tribe: null,226 repeat: false,227 });228 }229 if (v.tick === 50 && s !== undefined) {230 // Impossible at the humble start (no barracks, 4 weapons, 10 souls):231 // exercises the silently-dropped muster path.232 o.push({ kind: 'muster', settlementId: s.id, size: 15, unitKind: 'infantry' });233 }234 if (v.tick === 200 && s !== undefined) {235 o.push({ kind: 'build', building: 'lumber_camp', settlementId: s.id });236 }237 if (v.tick === 400 && s !== undefined) {238 o.push({ kind: 'build', building: 'house', settlementId: s.id, count: 2 });239 }240 if (v.tick === 600 && s !== undefined) {241 o.push({ kind: 'build', building: 'farm', settlementId: s.id });242 }243 if (v.tick === 900 && s !== undefined) {244 // No priests/faith this early — exercises the dropped-rite path.245 o.push({ kind: 'rite', rite: 'blessing', target: s.id });246 }247 if (v.tick > 0 && v.tick % 250 === 0) {248 o.push({ kind: 'say', text: `t${v.tick} from ${id}` });249 }250 return o;251 };252}253254const goldenBots = (): Map<number, Bot> => {255 const m = new Map<number, Bot>();256 for (let i = 0; i < 4; i++) m.set(i, goldenBot(i));257 return m;258};259260const GOLDEN_CONFIG = { seed: '123456789', tribeCount: 4, mapSize: 128, maxTicks: 10_000 };261// Generated once from this exact scenario; any engine change that alters262// simulation behavior must regenerate these (run this test — the failure263// output prints the actual hashes to pin).264const GOLDEN_HASHES: Record<number, string> = {265 0: '7ae249463646e801',266 100: '34099d7740b8fcd8',267 1000: '9688dcd89b05bb83',268};269270describe('golden replay', () => {271 it('matches the recorded state hashes at ticks 0, 100, 1000', () => {272 const world = createWorld(GOLDEN_CONFIG);273 const bots = goldenBots();274 const actual: Record<number, string> = { 0: world.stateHash() };275 while (world.tick < 1000 && !world.isOver()) {276 stepWithBots(world, bots);277 if (world.tick === 100 || world.tick === 1000) actual[world.tick] = world.stateHash();278 }279 expect(actual).toEqual(GOLDEN_HASHES);280 });281});282283// ---------------------------------------------------------------------------284// Determinism (hashes, events, metrics; accumulators are hashed state)285// ---------------------------------------------------------------------------286287describe('determinism', () => {288 it('dual runs agree on hashes, event streams and metrics frames', () => {289 const config = { seed: '41', tribeCount: 3, mapSize: 64, maxTicks: 10_000 };290 const run = (): { hashes: string[]; events: string[]; metrics: string[] } => {291 const world = createWorld(config);292 const bots = new Map<number, Bot>();293 for (let i = 0; i < 3; i++) bots.set(i, goldenBot(i));294 const hashes: string[] = [world.stateHash()];295 const events: string[] = [];296 const metrics: string[] = [];297 for (let t = 0; t < 1200 && !world.isOver(); t++) {298 stepWithBots(world, bots);299 hashes.push(world.stateHash());300 events.push(301 JSON.stringify([world.viewFor(0).events, world.viewFor(1).events, world.viewFor(2).events]),302 );303 if (world.tick % LIMITS.SLOW_TICK_INTERVAL === 0) {304 metrics.push(JSON.stringify(world.latestMetrics()));305 }306 }307 return { hashes, events, metrics };308 };309 const a = run();310 const b = run();311 expect(b.hashes).toEqual(a.hashes);312 expect(b.events).toEqual(a.events);313 expect(b.metrics).toEqual(a.metrics);314 expect(a.metrics.length).toBeGreaterThan(10);315 expect(a.events.some((e) => e !== '[[],[],[]]')).toBe(true);316 });317318 it('different seeds diverge', () => {319 const a = createWorld({ seed: '1', tribeCount: 2, mapSize: 64, maxTicks: 100 });320 const b = createWorld({ seed: '2', tribeCount: 2, mapSize: 64, maxTicks: 100 });321 expect(a.stateHash()).not.toEqual(b.stateHash());322 });323324 it('reading views and metrics never touches the state hash', () => {325 const world = createWorld({ seed: '7', tribeCount: 2, mapSize: 64, maxTicks: 1000 });326 runTicks(world, botsFor(2, economyBot), 25);327 const before = world.stateHash();328 world.viewFor(0);329 world.viewFor(1);330 world.latestMetrics();331 world.result();332 expect(world.stateHash()).toBe(before);333 });334});335336// ---------------------------------------------------------------------------337// Apportionment (largest remainder, ties ascending index)338// ---------------------------------------------------------------------------339340describe('apportion', () => {341 it('sums exactly and floors quotas', () => {342 expect(apportion(7, [500, 300, 200])).toEqual([4, 2, 1]);343 expect(apportion(100, [1, 1, 1]).reduce((a, b) => a + b, 0)).toBe(100);344 expect(apportion(1200, [400, 100, 60, 30, 20, 60, 20, 20, 10]).reduce((a, b) => a + b)).toBe(345 1200,346 );347 });348 it('breaks remainder ties by ascending index', () => {349 expect(apportion(10, [1, 1, 1])).toEqual([4, 3, 3]);350 expect(apportion(2, [1, 1, 1, 1])).toEqual([1, 1, 0, 0]);351 });352 it('handles zero weights and zero totals', () => {353 expect(apportion(5, [0, 0])).toEqual([0, 0]);354 expect(apportion(0, [3, 4])).toEqual([0, 0]);355 });356 it('normalizes weight sums above 1000', () => {357 const out = apportion(1000, [1500, 1500]);358 expect(out).toEqual([500, 500]);359 });360});361362// ---------------------------------------------------------------------------363// Economy integration364// ---------------------------------------------------------------------------365366describe('economy', () => {367 it('the humble start: ten souls, one hall, wagon-load stores', () => {368 const world = createWorld({ seed: '5', tribeCount: 2, mapSize: 64, maxTicks: 100 });369 const v = world.viewFor(0);370 const s = v.settlements[0]!;371 expect(s.pop).toBe(LIMITS.START_POP);372 expect(s.tier).toBe(0);373 expect(s.buildings.hall).toBe(1);374 // Nothing but the hall.375 expect(Object.values(s.buildings).reduce((a, b) => a + (b ?? 0), 0)).toBe(1);376 expect(s.herds).toBe(START_HERDS);377 expect(s.horses).toBe(0);378 for (const r of RESOURCES) {379 expect(s.stores[r], r).toBe((START_STORES as Partial<Record<string, number>>)[r] ?? 0);380 }381 expect(v.warbands).toEqual([]);382 // The hall bootstraps labor: kitchen-garden farmer and salvage-yard383 // woodcutter slots exist before any building is raised (capacity is384 // computed during the first step's labor phase).385 world.step(new Map());386 const s1 = world.viewFor(0).settlements[0]!;387 expect(s1.capacity.farmer).toBeGreaterThan(0);388 expect(s1.capacity.woodcutter).toBeGreaterThan(0);389 });390391 it('allocated farmers grow food; workers respect capacity', () => {392 const world = createWorld({ seed: '11', tribeCount: 2, mapSize: 64, maxTicks: 5000 });393 const bots = new Map<number, Bot>([394 [0, economyBot],395 [1, passiveBot],396 ]);397 runTicks(world, bots, 100);398 const m = metricsOf(world, 0);399 expect(m.production.grain).toBeGreaterThan(0);400 expect(m.production.timber).toBeGreaterThan(0);401 expect(m.starvationDeaths).toBe(0);402 const v = world.viewFor(0);403 const s = v.settlements[0]!;404 expect(s.workers.farmer).toBeGreaterThan(0);405 expect(s.workers.farmer!).toBeLessThanOrEqual(s.capacity.farmer!);406 // Passive tribe extracts nothing.407 expect(metricsOf(world, 1).production.grain).toBe(0);408 });409410 it('tool shortage drops per-worker yield to ~600‰; the smithy chain restores coverage', () => {411 // Phase A: the 12 starting tools cover the first few extractive workers,412 // then wear out with nothing to replace them (no smithy, no metal).413 const world = createWorld({ seed: '31', tribeCount: 2, mapSize: 64, maxTicks: 10_000 });414 const perWorkerAvg = (frames: number, bots: ReadonlyMap<number, Bot>): number => {415 let sum = 0;416 for (let f = 0; f < frames; f++) {417 runTicks(world, bots, LIMITS.SLOW_TICK_INTERVAL);418 const m = metricsOf(world, 0);419 sum += m.production.grain / Math.max(1, m.popByProfession.farmer);420 }421 return sum / frames;422 };423 const econ = new Map<number, Bot>([[0, growBot()]]);424 runTicks(world, econ, 50);425 const early = metricsOf(world, 0);426 expect(early.toolCoverage).toBe(1000);427 const earlyPerWorker = perWorkerAvg(5, econ);428 // Wear out the tools (extractive workforce grows well past 12 tools).429 runTicks(world, econ, 700);430 const late = metricsOf(world, 0);431 expect(late.toolCoverage).toBeLessThan(50);432 const latePerWorker = perWorkerAvg(5, econ);433 const ratio = latePerWorker / earlyPerWorker;434 expect(ratio).toBeGreaterThan(0.55);435 expect(ratio).toBeLessThan(0.66);436 // Phase B: climb the mine -> smelter -> smithy chain; fresh tools flow437 // and coverage recovers while they last.438 const military = new Map<number, Bot>([[0, growBot({ military: true })]]);439 let maxCoverage = 0;440 let toolsMade = 0;441 for (let t = 0; t < 3500 && !world.isOver(); t++) {442 stepWithBots(world, military);443 if (world.tick % LIMITS.SLOW_TICK_INTERVAL === 0) {444 const m = metricsOf(world, 0);445 maxCoverage = Math.max(maxCoverage, m.toolCoverage);446 toolsMade += m.production.tools;447 if (toolsMade > 0 && maxCoverage > late.toolCoverage + 50) break;448 }449 }450 expect(toolsMade).toBeGreaterThan(0);451 expect(maxCoverage).toBeGreaterThan(late.toolCoverage + 50);452 });453454 it('starvation: an unworked camp starves, but the pop floor spares everyone below it', () => {455 const world = createWorld({ seed: '13', tribeCount: 2, mapSize: 64, maxTicks: 5000 });456 const bots = botsFor(2, passiveBot);457 let starved: SimEvent | undefined;458 let totalStarved = 0;459 let maxPop = 0;460 // Stay inside the threat grace window so only hunger acts on the camp.461 for (let t = 0; t < 1100 && !world.isOver(); t++) {462 stepWithBots(world, bots);463 const v = world.viewFor(0);464 maxPop = Math.max(maxPop, v.settlements[0]!.pop);465 for (const e of v.events) {466 if (e.t === 'starvation' && e.tribe === 0) {467 starved ??= e;468 // Below SETTLEMENT_POP_FLOOR, starvation kills nobody — the event469 // still fires (deaths 0) and morale still takes the hit.470 expect(e.deaths).toBe(0);471 }472 }473 if (world.tick % LIMITS.SLOW_TICK_INTERVAL === 0) {474 totalStarved += metricsOf(world, 0).starvationDeaths;475 }476 }477 expect(starved).toMatchObject({ t: 'starvation', tribe: 0 });478 // The birth boom lifts 10 souls a little, but the wagon stores run dry479 // before the camp ever clears the floor — so no one starves to death.480 expect(maxPop).toBeLessThan(SETTLEMENT_POP_FLOOR);481 expect(totalStarved).toBe(0);482 const m = metricsOf(world, 0);483 expect(m.pop).toBeGreaterThanOrEqual(LIMITS.START_POP);484 expect(m.pop).toBeLessThan(SETTLEMENT_POP_FLOOR);485 // Hunger shows up as crushed morale instead of extinction.486 expect(world.viewFor(0).settlements[0]!.morale).toBeLessThan(300);487 });488489 it('population booms under a fed, housed frontier economy', () => {490 const world = createWorld({ seed: '14', tribeCount: 2, mapSize: 64, maxTicks: 5000 });491 const bots = new Map<number, Bot>([492 [0, economyBot],493 [1, passiveBot],494 ]);495 let sawBirths = false;496 for (let t = 0; t < 600 && !world.isOver(); t++) {497 stepWithBots(world, bots);498 if (world.tick % LIMITS.SLOW_TICK_INTERVAL === 0 && metricsOf(world, 0).births > 0) {499 sawBirths = true;500 }501 }502 expect(sawBirths).toBe(true);503 expect(metricsOf(world, 0).pop).toBeGreaterThan(LIMITS.START_POP);504 });505});506507// ---------------------------------------------------------------------------508// Military509// ---------------------------------------------------------------------------510511describe('military', () => {512 it('nothing can be mustered at the humble start', () => {513 const world = createWorld({ seed: '21', tribeCount: 2, mapSize: 64, maxTicks: 100 });514 const sid = world.viewFor(0).settlements[0]!.id;515 // 4 weapons, 10 souls, no barracks: every variant is silently dropped.516 world.step(517 new Map([[0, [518 { kind: 'muster', settlementId: sid, size: MUSTER_MIN_SIZE, unitKind: 'infantry' } as Order,519 { kind: 'muster', settlementId: sid, size: 4, unitKind: 'infantry' } as Order,520 ]]]),521 );522 expect(world.viewFor(0).warbands).toEqual([]);523 expect(world.viewFor(0).settlements[0]!.stores.weapons).toBe(START_STORES.weapons);524 });525526 it('muster requires a grown arsenal (weapons hard, cavalry gated); fresh bands drill', () => {527 const world = createWorld({ seed: '21', tribeCount: 2, mapSize: 64, maxTicks: 10_000 });528 // Grow tribe 0 to a militarized economy: mine -> smelter -> smithy ->529 // barracks, weaponsmithing, then the smithy hammers out weapons.530 grow(world, 4000, {531 military: true,532 arm: true,533 walls: true,534 until: () => {535 const s = world.viewFor(0).settlements[0]!;536 return (537 (s.buildings as Record<string, number>).barracks !== undefined &&538 s.stores.weapons >= 45 &&539 s.pop >= 200540 );541 },542 });543 const before = world.viewFor(0).settlements[0]!;544 const W = before.stores.weapons;545 const P = before.pop;546 expect(W).toBeGreaterThanOrEqual(45);547 const sid = before.id;548 const orders: Order[] = [549 { kind: 'muster', settlementId: sid, size: W + 10, unitKind: 'infantry' }, // > arsenal550 { kind: 'muster', settlementId: sid, size: MUSTER_MIN_SIZE - 1, unitKind: 'infantry' }, // too small551 { kind: 'muster', settlementId: sid, size: 20, unitKind: 'infantry' }, // ok552 { kind: 'muster', settlementId: sid, size: 10, unitKind: 'cavalry' }, // no horsemanship553 ];554 world.step(new Map([[0, orders]]));555 const v = world.viewFor(0);556 expect(v.warbands.length).toBe(1);557 expect(v.warbands[0]!).toMatchObject({ size: 20, weapons: 20, kind: 'infantry' });558 // Fresh musters drill before they can campaign.559 expect(v.warbands[0]!.drilling).toBeGreaterThan(0);560 expect(v.warbands[0]!.drilling).toBeLessThanOrEqual(MUSTER_DRILL_TICKS);561 // The muster debits exactly 20; the smiths may FORGE during the same562 // step now that the craft split actually yields weapons.563 expect(v.settlements[0]!.stores.weapons).toBeGreaterThanOrEqual(W - 20);564 expect(v.settlements[0]!.stores.weapons).toBeLessThanOrEqual(W - 18);565 expect(v.settlements[0]!.pop).toBe(P - 20);566 });567568 it('battles resolve deterministically; superior force wins', () => {569 const config = { seed: '41', tribeCount: 2, mapSize: 64, maxTicks: 10_000 };570 const run = (): { events: string[]; firstBattle: SimEvent | undefined } => {571 const world = createWorld(config);572 // Both sides grow militarized economies (seed 40 gives both a claimable573 // ore vein); the attacker out-produces the defender.574 grow(world, 4500, {575 tribes: [0, 1],576 military: true,577 arm: true,578 walls: true,579 until: () => {580 const s0 = world.viewFor(0).settlements[0]!;581 const s1 = world.viewFor(1).settlements[0]!;582 return (583 (s0.buildings as Record<string, number>).barracks !== undefined &&584 (s1.buildings as Record<string, number>).barracks !== undefined &&585 s0.stores.weapons >= 45 &&586 s1.stores.weapons >= 16 &&587 s0.pop >= 110 &&588 s1.pop >= 80589 );590 },591 });592 const sid0 = world.viewFor(0).settlements[0]!.id;593 const s1 = world.viewFor(1).settlements[0]!;594 world.step(595 new Map<number, Order[]>([596 [0, [597 { kind: 'stance', tribeId: 1, posture: 'hostile' } as Order,598 { kind: 'muster', settlementId: sid0, size: 40, unitKind: 'infantry' } as Order,599 ]],600 [1, [{ kind: 'muster', settlementId: s1.id, size: 15, unitKind: 'infantry' } as Order]],601 ]),602 );603 const events: string[] = [];604 let firstBattle: SimEvent | undefined;605 let marched = false;606 for (let t = 0; t < 800 && !world.isOver(); t++) {607 const w = world.viewFor(0).warbands[0];608 const orders = new Map<number, Order[]>();609 // March only once the band has finished drilling (anti-rush).610 if (w !== undefined && w.drilling === 0 && !marched) {611 marched = true;612 orders.set(0, [613 {614 kind: 'command',615 warbandId: w.id,616 order: { type: 'march', target: { x: s1.x, y: s1.y } },617 } as Order,618 ]);619 }620 world.step(orders);621 const evs = world.viewFor(1).events;622 events.push(JSON.stringify(evs));623 firstBattle ??= evs.find(624 (e) => e.t === 'battle' && (e as { defender: number | null }).defender === 1,625 );626 if (firstBattle !== undefined) break;627 }628 return { events, firstBattle };629 };630 const a = run();631 const b = run();632 expect(a.firstBattle).toBeDefined();633 expect(a.firstBattle).toMatchObject({ t: 'battle', attacker: 0, defender: 1, winner: 0 });634 const fb = a.firstBattle as Extract<SimEvent, { t: 'battle' }>;635 expect(fb.defenderLosses).toBeGreaterThan(0);636 expect(b.events).toEqual(a.events);637 });638639 it('a grown army besieges and captures an undefended camp, eliminating the tribe', () => {640 const world = createWorld({ seed: '23', tribeCount: 2, mapSize: 64, maxTicks: 10_000 });641 const enemySid = 2; // settlements are created in tribe order: tribe1 owns id 2642 // Tribe 0 militarizes; tribe 1 stays passive (its hall gardens keep a643 // sub-floor remnant alive, but it never musters — "undefended" means no644 // warbands, not zero people).645 grow(world, 4000, {646 military: true,647 arm: true,648 walls: true,649 until: () => {650 const s = world.viewFor(0).settlements[0]!;651 return (652 (s.buildings as Record<string, number>).barracks !== undefined &&653 s.stores.weapons >= 35 &&654 s.pop >= 110655 );656 },657 });658 const sid = world.viewFor(0).settlements[0]!.id;659 world.step(660 new Map([[0, [{ kind: 'muster', settlementId: sid, size: 30, unitKind: 'infantry' } as Order]]]),661 );662 expect(world.viewFor(0).warbands.length).toBe(1);663 let captured: SimEvent | undefined;664 let eliminated: SimEvent | undefined;665 let besieging = false;666 for (let t = 0; t < 2000 && !world.isOver(); t++) {667 const band = world.viewFor(0).warbands[0];668 const orders = new Map<number, Order[]>();669 // Green troops can't besiege — wait for the drill to finish.670 if (band !== undefined && band.drilling === 0 && !besieging) {671 besieging = true;672 orders.set(0, [673 { kind: 'command', warbandId: band.id, order: { type: 'besiege', target: enemySid } } as Order,674 ]);675 }676 world.step(orders);677 captured ??= world.viewFor(0).events.find((e) => e.t === 'settlement_captured');678 // The elimination event is delivered to the eliminated tribe itself.679 eliminated ??= world.viewFor(1).events.find((e) => e.t === 'eliminated');680 }681 expect(captured).toMatchObject({682 t: 'settlement_captured',683 tribe: 1,684 by: 0,685 settlementId: enemySid,686 });687 expect(eliminated).toMatchObject({ t: 'eliminated', tribe: 1 });688 expect(world.isOver()).toBe(true);689 const result = world.result();690 expect(result.reason).toBe('last_standing');691 expect(result.winner).toBe(0);692 expect(result.tribes.find((t) => t.tribeId === 0)!.settlements).toBe(2);693 expect(result.tribes.find((t) => t.tribeId === 1)!.diedAt).toBeGreaterThan(0);694 // The captured settlement now appears in the captor's view.695 expect(world.viewFor(0).settlements.map((s) => s.id).sort()).toEqual([1, 2]);696 });697});698699// ---------------------------------------------------------------------------700// Threats701// ---------------------------------------------------------------------------702703describe('threats', () => {704 it('camps spawn only after the grace window, then raid (raid.by === -1 convention)', () => {705 const world = createWorld({ seed: '32', tribeCount: 2, mapSize: 64, maxTicks: 10_000 });706 const bots = new Map<number, Bot>([707 [0, growBot()],708 [1, growBot()],709 ]);710 let raid: SimEvent | undefined;711 let threatSeen = false;712 for (let t = 0; t < 5000 && !world.isOver(); t++) {713 stepWithBots(world, bots);714 for (const id of [0, 1]) {715 const v = world.viewFor(id);716 if (v.threats.length > 0) threatSeen = true;717 raid ??= v.events.find((e) => e.t === 'raid' && e.by === -1);718 }719 // The wilderness is quiet during the grace window: no camps sighted,720 // no monster raids while young camps establish themselves.721 if (world.tick <= THREAT_GRACE_TICKS) {722 expect(threatSeen).toBe(false);723 expect(raid).toBeUndefined();724 }725 if (raid !== undefined && threatSeen) break;726 }727 // Camps spawn 10..25 tiles out; a grown settlement's vision (radius+2)728 // eventually covers the nearest ones. Raids reach the victim as raid729 // events with the documented by = -1 threat convention.730 expect(raid).toBeDefined();731 expect(raid).toMatchObject({ t: 'raid', by: -1 });732 expect((raid as { tick: number }).tick).toBeGreaterThan(THREAT_GRACE_TICKS);733 expect(threatSeen).toBe(true);734 });735});736737// ---------------------------------------------------------------------------738// Trade739// ---------------------------------------------------------------------------740741/** Grow both tribes to markets, then freeze extraction so store levels are742 * stable for exact trade arithmetic. Returns nothing; asserts markets. */743function growToMarkets(world: EngineWorld, tribes: number[]): void {744 grow(world, 3000, {745 tribes,746 market: true,747 walls: true,748 until: () =>749 tribes.every(750 (id) =>751 ((world.viewFor(id).settlements[0]!.buildings as Record<string, number>).market ?? 0) >= 1,752 ),753 });754 for (const id of tribes) {755 expect((world.viewFor(id).settlements[0]!.buildings as Record<string, number>).market).toBe(1);756 }757 // Bank the goods the trade scenarios need (growth runs hand-to-mouth on758 // timber), then freeze extraction so store levels are stable.759 const bank: Order[] = [760 'farmer', 'woodcutter', 'miner', 'builder', 'smelter', 'smith', 'scholar', 'herder', 'hauler',761 ].map((p) => A(p, p === 'farmer' ? 300 : p === 'woodcutter' ? 250 : p === 'miner' ? 150 : 0));762 const bankOrders = new Map<number, Order[]>();763 for (const id of tribes) bankOrders.set(id, bank);764 world.step(bankOrders);765 for (let t = 0; t < 2000; t++) {766 const ok = tribes.every((id) => {767 const st = world.viewFor(id).settlements[0]!;768 return st.stores.timber >= 260 && st.stores.stone >= 150;769 });770 if (ok) break;771 world.step(new Map());772 }773 const freeze: Order[] = [774 'farmer', 'woodcutter', 'miner', 'builder', 'smelter', 'smith', 'scholar', 'herder', 'hauler',775 ].map((p) => A(p, p === 'farmer' ? 300 : 0));776 const orders = new Map<number, Order[]>();777 for (const id of tribes) orders.set(id, freeze);778 world.step(orders);779 for (let t = 0; t < 30; t++) world.step(new Map());780}781782describe('trade', () => {783 it('matches complementary offers at the midpoint and delivers by caravan', () => {784 const world = createWorld({ seed: '108', tribeCount: 2, mapSize: 64, maxTicks: 10_000 });785 // The humble start has no markets and near-empty stores: trade only786 // works between grown economies.787 growToMarkets(world, [0, 1]);788 const s0 = world.viewFor(0).settlements[0]!;789 const s1 = world.viewFor(1).settlements[0]!;790 expect(s0.stores.timber).toBeGreaterThanOrEqual(200);791 expect(s1.stores.stone).toBeGreaterThanOrEqual(100);792 const t0: Order[] = [793 { kind: 'trade_offer', give: 'timber', giveAmount: 200, want: 'stone', wantAmount: 100, tribe: null, repeat: false },794 ];795 const t1: Order[] = [796 { kind: 'trade_offer', give: 'stone', giveAmount: 100, want: 'timber', wantAmount: 150, tribe: null, repeat: false },797 ];798 world.step(799 new Map<number, Order[]>([800 [0, t0],801 [1, t1],802 ]),803 );804 // Offers visible to both sides (both have markets, neutral stances).805 expect(world.viewFor(0).offers.length).toBe(2);806 expect(world.viewFor(1).offers.length).toBe(2);807 const empty = new Map<number, Order[]>();808 let sawCaravan = false;809 for (let t = 0; t < 150 && !world.isOver(); t++) {810 world.step(empty);811 if (world.viewFor(0).caravans.length > 0) sawCaravan = true;812 }813 expect(sawCaravan).toBe(true);814 // q = min(100, 100) = 100 stone; pay = 100*(200*100+100*150)/(2*100*100) = 175 timber.815 const e0 = world.viewFor(0).settlements[0]!;816 const e1 = world.viewFor(1).settlements[0]!;817 expect(e0.stores.stone - s0.stores.stone).toBe(100);818 expect(e0.stores.timber - s0.stores.timber).toBe(-175);819 expect(e1.stores.timber - s1.stores.timber).toBe(175);820 expect(e1.stores.stone - s1.stores.stone).toBe(-100);821 expect(metricsOf(world, 0).tradeVolume).toBeGreaterThan(0);822 // Matched non-repeat offers are gone.823 expect(world.viewFor(0).offers.length).toBe(0);824 });825826 it('peddler: sell-to-coin offers clear instantly at a poor rate', () => {827 const world = createWorld({ seed: '41', tribeCount: 2, mapSize: 64, maxTicks: 10_000 });828 growToMarkets(world, [0]);829 const before = world.viewFor(0).settlements[0]!;830 expect(before.stores.timber).toBeGreaterThanOrEqual(100);831 const orders: Order[] = [832 { kind: 'trade_offer', give: 'timber', giveAmount: 100, want: 'coin', wantAmount: 1, tribe: null, repeat: false },833 ];834 world.step(new Map([[0, orders]]));835 const empty = new Map<number, Order[]>();836 for (let t = 0; t < 12; t++) world.step(empty);837 // 100 timber * value 1 * 250‰ = 25 coin.838 const after = world.viewFor(0).settlements[0]!;839 expect(after.stores.coin - before.stores.coin).toBe(40);840 expect(after.stores.timber - before.stores.timber).toBe(-100);841 });842});843844// ---------------------------------------------------------------------------845// Order validation846// ---------------------------------------------------------------------------847848describe('order validation', () => {849 it('invalid orders are dropped without state divergence', () => {850 const config = { seed: '41', tribeCount: 2, mapSize: 64, maxTicks: 10_000 };851 const junk: Order[] = [852 { kind: 'allocate', profession: 'wizard' as never, permille: 100 },853 { kind: 'allocate', profession: 'farmer', permille: 1500 },854 { kind: 'allocate', profession: 'farmer', permille: -5 },855 { kind: 'allocate', profession: 'farmer', permille: 100, settlementId: 2 }, // enemy settlement856 { kind: 'craft', target: 'bogus' as never, permille: 100 },857 { kind: 'decree', key: 'nope' as never, value: 100 },858 { kind: 'decree', key: 'rations', value: 9999 },859 { kind: 'build', building: 'hall', settlementId: 1 },860 { kind: 'build', building: 'castle' as never, settlementId: 1 },861 { kind: 'build', building: 'farm', settlementId: 2 },862 { kind: 'build', building: 'mill', settlementId: 1 }, // milling not researched863 { kind: 'build', building: 'tower', settlementId: 1 }, // no walls864 { kind: 'demolish', building: 'granary', settlementId: 1 }, // none built865 { kind: 'found', x: -3, y: 5, settlers: 100, supplies: {} },866 { kind: 'found', x: 30, y: 30, settlers: 5, supplies: {} }, // too few867 { kind: 'route', fromId: 1, toId: 2, resource: 'grain', permilleOfSurplus: 100 }, // enemy dest868 { kind: 'muster', settlementId: 2, size: 20, unitKind: 'infantry' },869 { kind: 'muster', settlementId: 1, size: 20, unitKind: 'dragon' as never },870 { kind: 'muster', settlementId: 1, size: 20, unitKind: 'infantry' }, // no barracks, 4 weapons871 { kind: 'muster', settlementId: 1, size: 20, unitKind: 'cavalry' }, // no horsemanship872 { kind: 'command', warbandId: 99, order: { type: 'march', target: { x: 1, y: 1 } } },873 { kind: 'trade_offer', give: 'grain', giveAmount: 0, want: 'coin', wantAmount: 1, tribe: null, repeat: false },874 { kind: 'trade_offer', give: 'grain', giveAmount: 10, want: 'grain', wantAmount: 10, tribe: null, repeat: false },875 { kind: 'trade_cancel', offerId: 12345 },876 { kind: 'stance', tribeId: 0, posture: 'hostile' }, // self877 { kind: 'stance', tribeId: 7, posture: 'ally' }, // no such tribe878 { kind: 'research', tech: 'alchemy' as never },879 { kind: 'research', tech: 'plate_armor' }, // missing prereqs880 { kind: 'advance' }, // wagon-load stores can't cover the age hoard881 { kind: 'rite', rite: 'blessing', target: 1 }, // no faith yet882 { kind: 'rite', rite: 'war', target: null },883 { bogus: true } as never,884 ];885 const runHashes = (withJunk: boolean): string[] => {886 const world = createWorld(config);887 const bots = botsFor(2, economyBot);888 const hashes: string[] = [];889 for (let t = 0; t < 120; t++) {890 const orders = new Map<number, Order[]>();891 for (const [id, bot] of bots) {892 const base = bot(world.viewFor(id));893 orders.set(id, withJunk && id === 0 ? [...base, ...junk] : base);894 }895 world.step(orders);896 hashes.push(world.stateHash());897 }898 return hashes;899 };900 expect(runHashes(true)).toEqual(runHashes(false));901 });902});903904// ---------------------------------------------------------------------------905// Views, fog, events906// ---------------------------------------------------------------------------907908describe('viewFor', () => {909 it('survives a JSON round-trip and structuredClone unchanged', () => {910 const world = createWorld({ seed: '55', tribeCount: 2, mapSize: 64, maxTicks: 1000 });911 runTicks(world, botsFor(2, (v) => goldenBot(v.tribeId)(v)), 120);912 const v = world.viewFor(0);913 expect(JSON.parse(JSON.stringify(v))).toEqual(v);914 expect(structuredClone(v)).toEqual(v);915 expect(v.settlements.length).toBeGreaterThan(0);916 expect(v.terrain.length).toBe(64 * 64);917 // All record fields fully populated.918 for (const r of RESOURCES) expect(v.settlements[0]!.stores[r]).toBeTypeOf('number');919 for (const p of PROFESSIONS) expect(v.allocations[p]).toBeTypeOf('number');920 });921922 it('fog: unexplored terrain is ?, enemies hidden until seen', () => {923 const world = createWorld({ seed: '56', tribeCount: 2, mapSize: 128, maxTicks: 1000 });924 const v = world.viewFor(0);925 expect(v.terrain).toContain('?');926 const s = v.settlements[0]!;927 expect(v.terrain[s.y * 128 + s.x]).not.toBe('?');928 expect(v.enemySettlements).toEqual([]);929 expect(v.enemyWarbands).toEqual([]);930 expect(v.aliveTribes).toEqual([0, 1]);931 });932933 it('spawn fairness: quotas hold within radius 8 of every founding site', () => {934 const world = createWorld({ seed: '57', tribeCount: 8, mapSize: 128, maxTicks: 10_000 });935 // A tier-0 camp only sees radius 6; grow every tribe to tier 1 (vision936 // radius 8) so the whole fairness neighborhood is explored.937 grow(world, 2000, {938 tribes: [0, 1, 2, 3, 4, 5, 6, 7],939 until: () => {940 for (let i = 0; i < 8; i++) {941 if (world.viewFor(i).settlements[0]!.tier < 1) return false;942 }943 return true;944 },945 });946 for (let id = 0; id < 8; id++) {947 const v = world.viewFor(id);948 const s = v.settlements[0]!;949 expect(s.tier).toBeGreaterThanOrEqual(1);950 let plains = 0;951 let forest = 0;952 let hills = 0;953 let ore = 0;954 let waterAdj = 0;955 const at = (x: number, y: number): string =>956 x >= 0 && y >= 0 && x < 128 && y < 128 ? v.terrain[y * 128 + x]! : 'w';957 for (let dy = -8; dy <= 8; dy++) {958 for (let dx = -8; dx <= 8; dx++) {959 const x = s.x + dx;960 const y = s.y + dy;961 const t = at(x, y);962 expect(t, `tribe ${id} explored (${dx},${dy})`).not.toBe('?');963 if (t === '.') plains++;964 else if (t === 'f') forest++;965 if ('hmOGv'.includes(t)) hills++;966 if (t === 'O' || t === 'G') ore++;967 if (t !== 'w' && t !== 'r' && t !== '?') {968 let adjWater = false;969 for (let ay = -1; ay <= 1 && !adjWater; ay++) {970 for (let ax = -1; ax <= 1; ax++) {971 const n = at(x + ax, y + ay);972 if (n === 'w' || n === 'r') {973 adjWater = true;974 break;975 }976 }977 }978 if (adjWater) waterAdj++;979 }980 }981 }982 expect(plains, `tribe ${id} plains`).toBeGreaterThanOrEqual(20);983 expect(forest, `tribe ${id} forest`).toBeGreaterThanOrEqual(10);984 expect(hills, `tribe ${id} hills`).toBeGreaterThanOrEqual(6);985 expect(ore, `tribe ${id} ore`).toBeGreaterThanOrEqual(1);986 expect(waterAdj, `tribe ${id} waterAdj`).toBeGreaterThanOrEqual(3);987 }988 });989990 it('say broadcasts to everyone; events capped per view', () => {991 const world = createWorld({ seed: '58', tribeCount: 3, mapSize: 64, maxTicks: 100 });992 const says: Order[] = [993 { kind: 'say', text: 'hello' },994 { kind: 'say', text: 'dropped by SAYS_PER_TICK' },995 ];996 const long: Order[] = [{ kind: 'say', text: 'x'.repeat(300) }];997 world.step(998 new Map<number, Order[]>([999 [0, says],1000 [1, long],1001 ]),1002 );1003 for (const t of [0, 1, 2]) {1004 const evs = world.viewFor(t).events.filter((e) => e.t === 'say');1005 expect(evs).toEqual([1006 { t: 'say', tick: 0, tribe: 0, text: 'hello' },1007 { t: 'say', tick: 0, tribe: 1, text: 'x'.repeat(LIMITS.SAY_MAX_CHARS) },1008 ]);1009 expect(world.viewFor(t).events.length).toBeLessThanOrEqual(LIMITS.EVENTS_PER_VIEW);1010 }1011 });10121013 it('say orders never affect stateHash', () => {1014 const config = { seed: '41', tribeCount: 2, mapSize: 64, maxTicks: 1000 };1015 const run = (withSay: boolean): string[] => {1016 const world = createWorld(config);1017 const hashes: string[] = [];1018 for (let t = 0; t < 60; t++) {1019 const orders = new Map<number, Order[]>();1020 orders.set(0, withSay ? [{ kind: 'say', text: `tick ${t}` }] : []);1021 world.step(orders);1022 hashes.push(world.stateHash());1023 }1024 return hashes;1025 };1026 expect(run(true)).toEqual(run(false));1027 });1028});10291030// ---------------------------------------------------------------------------1031// Metrics1032// ---------------------------------------------------------------------------10331034describe('metrics', () => {1035 it('frames are complete and interval flows reset each interval', () => {1036 const world = createWorld({ seed: '61', tribeCount: 2, mapSize: 64, maxTicks: 5000 });1037 const bots = new Map<number, Bot>([1038 [0, economyBot],1039 [1, passiveBot],1040 ]);1041 expect(world.latestMetrics()).toBeNull();1042 runTicks(world, bots, 200);1043 const frameA = JSON.parse(JSON.stringify(world.latestMetrics())) as MetricsFrame;1044 runTicks(world, bots, 10);1045 const frameB = world.latestMetrics()!;1046 expect(frameB.tick).toBe(frameA.tick + 10);1047 const a = frameA.tribes[0]!;1048 const b = frameB.tribes[0]!;1049 // Interval flows, not cumulative: steady economy → similar magnitudes.1050 expect(a.production.grain).toBeGreaterThan(0);1051 expect(b.production.grain).toBeGreaterThan(0);1052 expect(b.production.grain).toBeLessThan(a.production.grain * 2);1053 expect(b.consumption.grain).toBeGreaterThan(0);1054 // Every field populated.1055 for (const r of RESOURCES) {1056 expect(a.stores[r]).toBeGreaterThanOrEqual(0);1057 expect(a.production[r]).toBeGreaterThanOrEqual(0);1058 expect(a.consumption[r]).toBeGreaterThanOrEqual(0);1059 }1060 for (const p of PROFESSIONS) expect(a.popByProfession[p]).toBeGreaterThanOrEqual(0);1061 expect(a.pop).toBeGreaterThan(0);1062 expect(a.moraleAvg).toBeGreaterThan(0);1063 expect(a.territoryTiles).toBeGreaterThan(0);1064 // The humble start is a single tier-0 camp.1065 expect(a.settlementsByTier).toEqual([1, 0, 0, 0]);1066 expect(a.score).toBe(1067 a.scoreBreakdown.pop +1068 a.scoreBreakdown.settlements +1069 a.scoreBreakdown.techs +1070 a.scoreBreakdown.military +1071 a.scoreBreakdown.coin +1072 a.scoreBreakdown.buildings,1073 );1074 expect(a.toolCoverage).toBeGreaterThanOrEqual(0);1075 expect(a.toolCoverage).toBeLessThanOrEqual(1000);1076 });1077});10781079// ---------------------------------------------------------------------------1080// Forfeit1081// ---------------------------------------------------------------------------10821083describe('forfeit', () => {1084 it('removes the tribe and can end the sim', () => {1085 const world = createWorld({ seed: '71', tribeCount: 2, mapSize: 64, maxTicks: 100 });1086 world.forfeit(1);1087 expect(world.isOver()).toBe(true);1088 expect(world.viewFor(1).events).toContainEqual({ t: 'forfeited', tick: 0, tribe: 1 });1089 const result = world.result();1090 expect(result.reason).toBe('last_standing');1091 expect(result.winner).toBe(0);1092 const dead = result.tribes.find((t) => t.tribeId === 1)!;1093 expect(dead.alive).toBe(false);1094 expect(dead.settlements).toBe(0);1095 expect(world.latestMetrics()).not.toBeNull();1096 });1097});10981099// ---------------------------------------------------------------------------1100// Performance1101// ---------------------------------------------------------------------------11021103describe('performance', () => {1104 it(1105 '8 tribes x 10k ticks with active policies stays under budget',1106 () => {1107 const world = createWorld({ seed: '8888', tribeCount: 8, mapSize: 128, maxTicks: 10_000 });1108 const activeBot = (id: number): Bot => (v) => {1109 if (v.tick % 10 !== 0) return [];1110 const o: Order[] = [];1111 if (v.tick === 0) o.push(...basePolicy());1112 const s = v.settlements[0];1113 if (s !== undefined) {1114 // Periodic policy churn + entity orders (musters are dropped at1115 // the humble start — the order path itself is what's exercised).1116 o.push(A('farmer', 380 + ((v.tick / 10 + id) % 4) * 10));1117 if (v.tick % 500 === 0 && v.tick > 0) {1118 o.push({ kind: 'build', building: 'house', settlementId: s.id });1119 }1120 if (v.tick === 100) {1121 o.push({ kind: 'muster', settlementId: s.id, size: 30, unitKind: 'infantry' });1122 }1123 if (v.tick === 120 && v.warbands[0] !== undefined) {1124 o.push({1125 kind: 'command',1126 warbandId: v.warbands[0].id,1127 order: { type: 'patrol', target: { x: s.x + 3, y: s.y } },1128 });1129 }1130 if (v.tick % 1000 === 0 && v.tick > 0) {1131 o.push({1132 kind: 'trade_offer',1133 give: 'timber',1134 giveAmount: 50,1135 want: 'stone',1136 wantAmount: 25,1137 tribe: null,1138 repeat: false,1139 });1140 }1141 }1142 return o;1143 };1144 const bots = new Map<number, Bot>();1145 for (let i = 0; i < 8; i++) bots.set(i, activeBot(i));1146 const start = performance.now();1147 runTicks(world, bots, 10_000);1148 const elapsed = performance.now() - start;1149 // eslint-disable-next-line no-console1150 console.log(`perf: ${world.tick} ticks in ${elapsed.toFixed(0)}ms`);1151 expect(world.tick).toBeGreaterThan(1000);1152 expect(elapsed).toBeLessThan(20_000); // target < 10s1153 },1154 60_000,1155 );1156});11571158describe('distant view (glimpse band)', () => {1159 it('reveals category silhouettes beyond explored ground, disjoint from terrain', () => {1160 const world = createWorld({ seed: '55', tribeCount: 2, mapSize: 64, maxTicks: 50 });1161 for (let i = 0; i < 5; i++) world.step(new Map());1162 const v = world.viewFor(0);1163 expect(v.distant.length).toBe(64 * 64);1164 let distantCells = 0;1165 for (let i = 0; i < v.distant.length; i++) {1166 const d = v.distant[i]!;1167 if (d !== '?') {1168 distantCells++;1169 // category chars only1170 expect('.f^w').toContain(d);1171 // disjoint: anywhere distant is known, exact terrain must be unknown1172 expect(v.terrain[i]).toBe('?');1173 }1174 }1175 expect(distantCells).toBeGreaterThan(50); // a real band exists1176 });11771178 it('never leaks vein specifics — ore/gold glimpse as generic highland', () => {1179 const world = createWorld({ seed: '55', tribeCount: 2, mapSize: 64, maxTicks: 50 });1180 const v = world.viewFor(0);1181 expect(v.distant).not.toContain('O');1182 expect(v.distant).not.toContain('G');1183 });1184});11851186describe('organic territory (claims)', () => {1187 it('claims are exclusive, terrain-shaped, and water-blocked', () => {1188 const world = createWorld({ seed: '77', tribeCount: 2, mapSize: 32, maxTicks: 100 });1189 for (let i = 0; i < 11; i++) world.step(new Map());1190 const v = world.viewFor(0);1191 expect(v.claims.length).toBe(32 * 32);1192 let mine = 0;1193 for (let i = 0; i < v.claims.length; i++) {1194 const c = v.claims[i]!;1195 if (c === '.') continue;1196 expect('01').toContain(c); // owner chars only1197 if (c === '0') mine++;1198 // water and rivers can never be claimed1199 expect('wr').not.toContain(v.terrain[i] ?? '?');1200 }1201 expect(mine).toBeGreaterThan(30); // a real organic territory exists1202 expect(v.settlements[0]!.territoryTiles).toBeGreaterThan(30);1203 });12041205 it('claim maps are deterministic across identical runs', () => {1206 const run = () => {1207 const world = createWorld({ seed: '78', tribeCount: 3, mapSize: 32, maxTicks: 60 });1208 for (let i = 0; i < 31; i++) world.step(new Map());1209 return world.viewFor(1).claims;1210 };1211 expect(run()).toBe(run());1212 });1213});12141215describe('ages (prestige)', () => {1216 it('advance burns the hoard, raises the age, boosts rates and score', () => {1217 const world = createWorld({ seed: '79', tribeCount: 2, mapSize: 32, maxTicks: 200 });1218 const before = world.viewFor(0);1219 expect(before.age).toBe(0);1220 // Wagon-load stores (grain 150, timber 80, hides 8) can't cover the age-11221 // hoard (grain 800, timber 400, hides 100): the order is dropped.1222 world.step(new Map([[0, [{ kind: 'advance' } as Order]]]));1223 expect(world.viewFor(0).age).toBe(0); // unaffordable -> dropped12241225 // The affordable path needs a grown economy: rush a pasture to save the1226 // starting herds, breed them fodder-fed to a real flock, then slaughter1227 // for the 100 hides while farms and lumber camps pile up the rest.1228 const world2 = createWorld({ seed: '83', tribeCount: 2, mapSize: 64, maxTicks: 10_000 });1229 const bot = growBot({ herds: true });1230 let slaughterOn = false;1231 let advancedAt = -1;1232 for (let t = 0; t < 4000 && !world2.isOver(); t++) {1233 const v = world2.viewFor(0);1234 const s = v.settlements[0]!;1235 const extra: Order[] = [];1236 if (!slaughterOn && s.herds >= 60) {1237 slaughterOn = true;1238 extra.push({ kind: 'decree', key: 'slaughter', value: 80 } as Order);1239 }1240 if (1241 slaughterOn &&1242 world2.tick % 20 === 0 &&1243 s.stores.hides >= 100 &&1244 s.stores.grain >= 800 &&1245 s.stores.timber >= 4001246 ) {1247 extra.push({ kind: 'advance' } as Order);1248 }1249 world2.step(new Map([[0, [...bot(v), ...extra]]]));1250 if (world2.viewFor(0).age === 1) {1251 advancedAt = world2.tick;1252 break;1253 }1254 }1255 expect(advancedAt).toBeGreaterThan(0);1256 const v = world2.viewFor(0);1257 expect(v.age).toBe(1);1258 // score breakdown carries the prestige term (metrics recompute on the1259 // next slow-tick boundary — step past it before asserting)1260 for (let i = 0; i < 10; i++) world2.step(new Map());1261 const m = world2.latestMetrics();1262 if (m) {1263 const t0 = m.tribes.find((t) => t.tribeId === 0)!;1264 expect(t0.age).toBe(1);1265 }1266 // age_advanced event was emitted (delivered to the tribe itself)1267 // (event already consumed from the view ring in earlier steps — the age1268 // field itself is the durable assertion here)1269 });1270});