growBot harness bootstraps a ten-soul camp through the real build/tech chains; goldens repinned; new invariant tests for the humble start, threat grace, muster gating, sieges, ages, and sub-floor starvation. Root typecheck now runs per-package tsc (tsc -b had no root project); web vitest passes with no test files.
- commit
- 7e8e1f813443346dd0091d102622be90fb2416d7
- parent
- ebc7efc2126ea17197be150964bb7db50b408b07
- author
- Dami <dami.etoile@gmail.com>
| M | package.json | 2 | +- |
| M | packages/ | 618 | +++++++++++++++++++++++------- |
| M | web/ | 2 | +- |
@@ -12,7 +12,7 @@
1212 "dev": "npm run build -w web && wrangler dev",1313 "deploy": "npm run build && wrangler deploy",1414 "test": "npm test --workspaces --if-present",15- "typecheck": "tsc -b",15+ "typecheck": "tsc -p packages/protocol --noEmit && tsc -p packages/engine --noEmit && tsc -p packages/script-host --noEmit && tsc -p web --noEmit && tsc -p worker --noEmit",1616 "deploy:dev": "npm run build && wrangler d1 migrations apply tribes-dev --env dev --remote && wrangler deploy --env dev",1717 "release": "scripts/release.sh"1818 },@@ -2,6 +2,14 @@ import { describe, expect, it } from 'vitest';
22 import type { EngineWorld, MetricsFrame, Order, SimEvent, TribeView } from '@tribes/protocol';33 import { LIMITS, PROFESSIONS, RESOURCES } from '@tribes/protocol';44 import { apportion, createWorld } from '../src/index.js';5+import {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';513 614 type Bot = (view: TribeView) => Order[];715 @@ -48,12 +56,136 @@ const metricsOf = (world: EngineWorld, tribe: number): MetricsFrame['tribes'][nu
4856 return m.tribes.find((t) => t.tribeId === tribe)!;4957 };5058 59+// ---------------------------------------------------------------------------60+// grow(): deterministic humble-start development policy61+// ---------------------------------------------------------------------------62+63+interface 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+}79+80+/**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+ */87+const 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+ if (opts.herds === true) {98+ // 6 builders finish the pasture (50 pts) before the herd cull at t9.99+ o.push({ kind: 'build', building: 'pasture', settlementId: s.id } as Order);100+ o.push(A('builder', 600), A('farmer', 400));101+ o.push({ kind: 'decree', key: 'fodder', value: 1000 } as Order);102+ o.push({ kind: 'craft', target: 'fodder', permille: 200 } as Order);103+ } else {104+ o.push(A('farmer', 400), A('builder', 200), A('woodcutter', 200));105+ }106+ }107+ if (rel % 25 !== 0 || rel === 0) return o;108+ if (rel === 25) {109+ // Standing allocation once the camp is on its feet.110+ o.push(111+ A('farmer', 330),112+ A('woodcutter', 170),113+ A('miner', 90),114+ A('builder', 110),115+ A('smelter', 30),116+ A('smith', 40),117+ A('scholar', 60),118+ A('herder', opts.herds === true ? 80 : 20),119+ A('hauler', 10),120+ );121+ }122+ const b = (name: string): number => (s.buildings as Record<string, number>)[name] ?? 0;123+ const have = (name: string): number =>124+ b(name) + s.buildQueue.filter((q) => (q.building as string) === name).length;125+ const want = (building: string, target: number): void => {126+ if (have(building) < target) {127+ o.push({ kind: 'build', building, settlementId: s.id } as Order);128+ }129+ };130+ // Military/market chain first: the (timber-only) mine is the door to131+ // stone — every stone-costed building (library, smithy, barracks,132+ // market) waits on mined stone.133+ if (opts.military === true || opts.market === true) {134+ want('mine', 1);135+ if (b('mine') >= 1) {136+ if (opts.military === true) {137+ want('library', 2);138+ want('smelter', 1);139+ want('smithy', 1);140+ want('barracks', 1);141+ }142+ if (opts.walls === true) want('walls', 1);143+ if (opts.market === true) want('market', 1);144+ }145+ if (opts.military === true && v.researching === null) {146+ if (!v.techs.includes('smelting')) {147+ o.push({ kind: 'research', tech: 'smelting' } as Order);148+ } else if (!v.techs.includes('weaponsmithing')) {149+ o.push({ kind: 'research', tech: 'weaponsmithing' } as Order);150+ }151+ }152+ if (opts.arm === true && !armed && v.techs.includes('weaponsmithing')) {153+ armed = true;154+ o.push({ kind: 'craft', target: 'weapons', permille: 1000 } as Order);155+ o.push({ kind: 'craft', target: 'tools', permille: 0 } as Order);156+ }157+ }158+ if (opts.herds === true) want('pasture', 4);159+ want('farm', Math.min(8, 1 + Math.trunc(s.pop / 60)));160+ want('lumber_camp', Math.min(3, 1 + Math.trunc(s.pop / 150)));161+ const hallHousing = 300 + 300 * s.tier;162+ want('house', Math.min(18, Math.max(0, Math.ceil((s.pop * 2 - hallHousing) / 60))));163+ if (s.pop > 250) want('granary', 1);164+ return o;165+ };166+};167+168+/** Step up to `ticks` with growBots on `tribes` (others passive), stopping169+ * early when `until` (checked after every step) reports true. */170+function grow(171+ world: EngineWorld,172+ ticks: number,173+ opts: GrowOpts & { tribes?: number[]; until?: () => boolean } = {},174+): void {175+ const bots = new Map<number, Bot>();176+ for (const id of opts.tribes ?? [0]) bots.set(id, growBot(opts));177+ for (let t = 0; t < ticks && !world.isOver(); t++) {178+ stepWithBots(world, bots);179+ if (opts.until !== undefined && opts.until()) return;180+ }181+}182+51183 // ---------------------------------------------------------------------------52184 // Golden replay53185 // ---------------------------------------------------------------------------54186 55-/** Scripted scenario touching allocations, build, muster, command, trade,56- * research, decree, craft, rite and say. */187+/** Scripted humble-start scenario touching allocations, craft, decree,188+ * research, build, trade, muster (dropped: no barracks yet), rite and say. */57189 function goldenBot(id: number): Bot {58190 return (v) => {59191 const o: Order[] = [];@@ -61,20 +193,21 @@ function goldenBot(id: number): Bot {
61193 if (v.tick === 0) {62194 o.push(...basePolicy());63195 o.push({ kind: 'craft', target: 'tools', permille: 800 });64- o.push({ kind: 'decree', key: 'slaughter', value: 50 });196+ o.push({ kind: 'decree', key: 'rations', value: 1000 });65197 o.push({ kind: 'research', tech: 'milling' });66198 }67199 if (v.tick === 5 && s !== undefined) {68- o.push({ kind: 'build', building: 'house', settlementId: s.id, count: 2 });200+ // 50 of the 80 starting timber: one farm, one house.69201 o.push({ kind: 'build', building: 'farm', settlementId: s.id });202+ o.push({ kind: 'build', building: 'house', settlementId: s.id });70203 }71204 if (v.tick === 30) {72205 o.push({73206 kind: 'trade_offer',74207 give: 'timber',75- giveAmount: 100,208+ giveAmount: 20,76209 want: 'stone',77- wantAmount: 50,210+ wantAmount: 10,78211 tribe: null,79212 repeat: false,80213 });@@ -83,27 +216,29 @@ function goldenBot(id: number): Bot {
83216 o.push({84217 kind: 'trade_offer',85218 give: 'stone',86- giveAmount: 60,219+ giveAmount: 10,87220 want: 'timber',88- wantAmount: 80,221+ wantAmount: 15,89222 tribe: null,90223 repeat: false,91224 });92225 }93226 if (v.tick === 50 && s !== undefined) {94- o.push({ kind: 'muster', settlementId: s.id, size: 40, unitKind: 'infantry' });227+ // Impossible at the humble start (no barracks, 4 weapons, 10 souls):228+ // exercises the silently-dropped muster path.229+ o.push({ kind: 'muster', settlementId: s.id, size: 15, unitKind: 'infantry' });95230 }96- if (v.tick === 60 && v.warbands[0] !== undefined && s !== undefined) {97- o.push({98- kind: 'command',99- warbandId: v.warbands[0].id,100- order: { type: 'patrol', target: { x: s.x + 4, y: s.y + 4 } },101- });231+ if (v.tick === 200 && s !== undefined) {232+ o.push({ kind: 'build', building: 'lumber_camp', settlementId: s.id });233+ }234+ if (v.tick === 400 && s !== undefined) {235+ o.push({ kind: 'build', building: 'house', settlementId: s.id, count: 2 });102236 }103- if (v.tick === 500 && s !== undefined) {104- o.push({ kind: 'build', building: 'mill', settlementId: s.id });237+ if (v.tick === 600 && s !== undefined) {238+ o.push({ kind: 'build', building: 'farm', settlementId: s.id });105239 }106240 if (v.tick === 900 && s !== undefined) {241+ // No priests/faith this early — exercises the dropped-rite path.107242 o.push({ kind: 'rite', rite: 'blessing', target: s.id });108243 }109244 if (v.tick > 0 && v.tick % 250 === 0) {@@ -121,12 +256,12 @@ const goldenBots = (): Map<number, Bot> => {
121256 122257 const GOLDEN_CONFIG = { seed: '123456789', tribeCount: 4, mapSize: 128, maxTicks: 10_000 };123258 // Generated once from this exact scenario; any engine change that alters124-// simulation behavior must regenerate these (see scripts in repo history:125-// run this test with GOLDEN printed on failure).259+// simulation behavior must regenerate these (run this test — the failure260+// output prints the actual hashes to pin).126261 const GOLDEN_HASHES: Record<number, string> = {127- 0: '0e3ae27cf3378a4f',128- 100: '0d6a052aceb277e9',129- 1000: '1d7a492119b01976',262+ 0: '3599c044db82a9af',263+ 100: 'fc7c86ada89db43e',264+ 1000: '5bcbdb90ba576a07',130265 };131266 132267 describe('golden replay', () => {@@ -226,6 +361,30 @@ describe('apportion', () => {
226361 // ---------------------------------------------------------------------------227362 228363 describe('economy', () => {364+ it('the humble start: ten souls, one hall, wagon-load stores', () => {365+ const world = createWorld({ seed: '5', tribeCount: 2, mapSize: 64, maxTicks: 100 });366+ const v = world.viewFor(0);367+ const s = v.settlements[0]!;368+ expect(s.pop).toBe(LIMITS.START_POP);369+ expect(s.tier).toBe(0);370+ expect(s.buildings.hall).toBe(1);371+ // Nothing but the hall.372+ expect(Object.values(s.buildings).reduce((a, b) => a + (b ?? 0), 0)).toBe(1);373+ expect(s.herds).toBe(START_HERDS);374+ expect(s.horses).toBe(0);375+ for (const r of RESOURCES) {376+ expect(s.stores[r], r).toBe((START_STORES as Partial<Record<string, number>>)[r] ?? 0);377+ }378+ expect(v.warbands).toEqual([]);379+ // The hall bootstraps labor: kitchen-garden farmer and salvage-yard380+ // woodcutter slots exist before any building is raised (capacity is381+ // computed during the first step's labor phase).382+ world.step(new Map());383+ const s1 = world.viewFor(0).settlements[0]!;384+ expect(s1.capacity.farmer).toBeGreaterThan(0);385+ expect(s1.capacity.woodcutter).toBeGreaterThan(0);386+ });387+229388 it('allocated farmers grow food; workers respect capacity', () => {230389 const world = createWorld({ seed: '11', tribeCount: 2, mapSize: 64, maxTicks: 5000 });231390 const bots = new Map<number, Bot>([@@ -245,70 +404,86 @@ describe('economy', () => {
245404 expect(metricsOf(world, 1).production.grain).toBe(0);246405 });247406 248- it('tool shortage drops per-worker yield to ~600‰; smiths restore coverage', () => {249- const world = createWorld({ seed: '12', tribeCount: 2, mapSize: 64, maxTicks: 10_000 });250- let phase2 = false;251- const bot: Bot = (v) => {252- const o: Order[] = [];253- if (v.tick === 0) {254- // 150‰ farmers (~180 of 1200) < 250 starting tools: full coverage.255- o.push(A('farmer', 150), { kind: 'decree', key: 'rations', value: 500 } as Order);256- o.push({ kind: 'craft', target: 'tools', permille: 1000 });407+ it('tool shortage drops per-worker yield to ~600‰; the smithy chain restores coverage', () => {408+ // Phase A: the 12 starting tools cover the first few extractive workers,409+ // then wear out with nothing to replace them (no smithy, no metal).410+ const world = createWorld({ seed: '31', tribeCount: 2, mapSize: 64, maxTicks: 10_000 });411+ const perWorkerAvg = (frames: number, bots: ReadonlyMap<number, Bot>): number => {412+ let sum = 0;413+ for (let f = 0; f < frames; f++) {414+ runTicks(world, bots, LIMITS.SLOW_TICK_INTERVAL);415+ const m = metricsOf(world, 0);416+ sum += m.production.grain / Math.max(1, m.popByProfession.farmer);257417 }258- if (phase2 && v.tick % 50 === 0) o.push(A('smith', 30));259- return o;418+ return sum / frames;260419 };261- const bots = new Map<number, Bot>([262- [0, bot],263- [1, passiveBot],264- ]);265- runTicks(world, bots, 50);420+ const econ = new Map<number, Bot>([[0, growBot()]]);421+ runTicks(world, econ, 50);266422 const early = metricsOf(world, 0);267423 expect(early.toolCoverage).toBe(1000);268- const earlyPerWorker = early.production.grain / early.popByProfession.farmer;269- // Deplete the tools (wear, no smiths).270- runTicks(world, bots, 800);424+ const earlyPerWorker = perWorkerAvg(5, econ);425+ // Wear out the tools (extractive workforce grows well past 12 tools).426+ runTicks(world, econ, 700);271427 const late = metricsOf(world, 0);272428 expect(late.toolCoverage).toBeLessThan(50);273- const latePerWorker = late.production.grain / late.popByProfession.farmer;429+ const latePerWorker = perWorkerAvg(5, econ);274430 const ratio = latePerWorker / earlyPerWorker;275431 expect(ratio).toBeGreaterThan(0.55);276432 expect(ratio).toBeLessThan(0.66);277- // Phase 2: smiths turn the stored metal into tools; coverage recovers278- // while the fresh tools last.279- phase2 = true;433+ // Phase B: climb the mine -> smelter -> smithy chain; fresh tools flow434+ // and coverage recovers while they last.435+ const military = new Map<number, Bot>([[0, growBot({ military: true })]]);280436 let maxCoverage = 0;281437 let toolsMade = 0;282- for (let t = 0; t < 300 && !world.isOver(); t++) {283- stepWithBots(world, bots);438+ for (let t = 0; t < 3500 && !world.isOver(); t++) {439+ stepWithBots(world, military);284440 if (world.tick % LIMITS.SLOW_TICK_INTERVAL === 0) {285441 const m = metricsOf(world, 0);286442 maxCoverage = Math.max(maxCoverage, m.toolCoverage);287443 toolsMade += m.production.tools;444+ if (toolsMade > 0 && maxCoverage > late.toolCoverage + 50) break;288445 }289446 }290447 expect(toolsMade).toBeGreaterThan(0);291448 expect(maxCoverage).toBeGreaterThan(late.toolCoverage + 50);292449 });293450 294- it('starvation: an unworked tribe runs out of food, loses pop, gets events', () => {451+ it('starvation: an unworked camp starves, but the pop floor spares everyone below it', () => {295452 const world = createWorld({ seed: '13', tribeCount: 2, mapSize: 64, maxTicks: 5000 });296453 const bots = botsFor(2, passiveBot);297454 let starved: SimEvent | undefined;298455 let totalStarved = 0;299- for (let t = 0; t < 600 && !world.isOver(); t++) {456+ let maxPop = 0;457+ // Stay inside the threat grace window so only hunger acts on the camp.458+ for (let t = 0; t < 1100 && !world.isOver(); t++) {300459 stepWithBots(world, bots);301- starved ??= world.viewFor(0).events.find((e) => e.t === 'starvation' && e.tribe === 0);460+ const v = world.viewFor(0);461+ maxPop = Math.max(maxPop, v.settlements[0]!.pop);462+ for (const e of v.events) {463+ if (e.t === 'starvation' && e.tribe === 0) {464+ starved ??= e;465+ // Below SETTLEMENT_POP_FLOOR, starvation kills nobody — the event466+ // still fires (deaths 0) and morale still takes the hit.467+ expect(e.deaths).toBe(0);468+ }469+ }302470 if (world.tick % LIMITS.SLOW_TICK_INTERVAL === 0) {303471 totalStarved += metricsOf(world, 0).starvationDeaths;304472 }305473 }306474 expect(starved).toMatchObject({ t: 'starvation', tribe: 0 });307- expect(totalStarved).toBeGreaterThan(0);308- expect(metricsOf(world, 0).pop).toBeLessThan(LIMITS.START_POP);475+ // The birth boom lifts 10 souls a little, but the wagon stores run dry476+ // before the camp ever clears the floor — so no one starves to death.477+ expect(maxPop).toBeLessThan(SETTLEMENT_POP_FLOOR);478+ expect(totalStarved).toBe(0);479+ const m = metricsOf(world, 0);480+ expect(m.pop).toBeGreaterThanOrEqual(LIMITS.START_POP);481+ expect(m.pop).toBeLessThan(SETTLEMENT_POP_FLOOR);482+ // Hunger shows up as crushed morale instead of extinction.483+ expect(world.viewFor(0).settlements[0]!.morale).toBeLessThan(300);309484 });310485 311- it('population grows under a fed, housed economy', () => {486+ it('population booms under a fed, housed frontier economy', () => {312487 const world = createWorld({ seed: '14', tribeCount: 2, mapSize: 64, maxTicks: 5000 });313488 const bots = new Map<number, Bot>([314489 [0, economyBot],@@ -331,14 +506,46 @@ describe('economy', () => {
331506 // ---------------------------------------------------------------------------332507 333508 describe('military', () => {334- it('muster requires weapons (and horses+tech for cavalry)', () => {335- const world = createWorld({ seed: '21', tribeCount: 2, mapSize: 64, maxTicks: 1000 });509+ it('nothing can be mustered at the humble start', () => {510+ const world = createWorld({ seed: '21', tribeCount: 2, mapSize: 64, maxTicks: 100 });336511 const sid = world.viewFor(0).settlements[0]!.id;512+ // 4 weapons, 10 souls, no barracks: every variant is silently dropped.513+ world.step(514+ new Map([[0, [515+ { kind: 'muster', settlementId: sid, size: MUSTER_MIN_SIZE, unitKind: 'infantry' } as Order,516+ { kind: 'muster', settlementId: sid, size: 4, unitKind: 'infantry' } as Order,517+ ]]]),518+ );519+ expect(world.viewFor(0).warbands).toEqual([]);520+ expect(world.viewFor(0).settlements[0]!.stores.weapons).toBe(START_STORES.weapons);521+ });522+523+ it('muster requires a grown arsenal (weapons hard, cavalry gated); fresh bands drill', () => {524+ const world = createWorld({ seed: '21', tribeCount: 2, mapSize: 64, maxTicks: 10_000 });525+ // Grow tribe 0 to a militarized economy: mine -> smelter -> smithy ->526+ // barracks, weaponsmithing, then the smithy hammers out weapons.527+ grow(world, 4000, {528+ military: true,529+ arm: true,530+ walls: true,531+ until: () => {532+ const s = world.viewFor(0).settlements[0]!;533+ return (534+ (s.buildings as Record<string, number>).barracks !== undefined &&535+ s.stores.weapons >= 45 &&536+ s.pop >= 200537+ );538+ },539+ });540+ const before = world.viewFor(0).settlements[0]!;541+ const W = before.stores.weapons;542+ const P = before.pop;543+ expect(W).toBeGreaterThanOrEqual(45);544+ const sid = before.id;337545 const orders: Order[] = [338- { kind: 'muster', settlementId: sid, size: 40, unitKind: 'infantry' }, // > 24 starter weapons339- { kind: 'muster', settlementId: sid, size: 5, unitKind: 'infantry' }, // < MUSTER_MIN546+ { kind: 'muster', settlementId: sid, size: W + 10, unitKind: 'infantry' }, // > arsenal547+ { kind: 'muster', settlementId: sid, size: MUSTER_MIN_SIZE - 1, unitKind: 'infantry' }, // too small340548 { kind: 'muster', settlementId: sid, size: 20, unitKind: 'infantry' }, // ok341- { kind: 'muster', settlementId: sid, size: 10, unitKind: 'infantry' }, // 4 weapons left342549 { kind: 'muster', settlementId: sid, size: 10, unitKind: 'cavalry' }, // no horsemanship343550 ];344551 world.step(new Map([[0, orders]]));@@ -347,49 +554,70 @@ describe('military', () => {
347554 expect(v.warbands[0]!).toMatchObject({ size: 20, weapons: 20, kind: 'infantry' });348555 // Fresh musters drill before they can campaign.349556 expect(v.warbands[0]!.drilling).toBeGreaterThan(0);350- expect(v.settlements[0]!.stores.weapons).toBe(4);351- expect(v.settlements[0]!.pop).toBe(LIMITS.START_POP - 20);557+ expect(v.warbands[0]!.drilling).toBeLessThanOrEqual(MUSTER_DRILL_TICKS);558+ expect(v.settlements[0]!.stores.weapons).toBe(W - 20);559+ expect(v.settlements[0]!.pop).toBe(P - 20);352560 });353561 354562 it('battles resolve deterministically; superior force wins', () => {355- const config = { seed: '22', tribeCount: 2, mapSize: 64, maxTicks: 5000 };356- const attacker: Bot = (v) => {357- const o: Order[] = [];358- const s = v.settlements[0];359- if (v.tick === 0 && s !== undefined) {360- o.push({ kind: 'stance', tribeId: 1, posture: 'hostile' });361- o.push({ kind: 'muster', settlementId: s.id, size: 20, unitKind: 'infantry' });362- }363- // March only once the band has finished drilling (anti-rush).364- if (v.warbands[0] !== undefined && v.warbands[0].drilling === 0 && v.tick < 300 && v.warbands[0].order.type === 'garrison') {365- o.push({366- kind: 'command',367- warbandId: v.warbands[0].id,368- order: { type: 'march', target: { x: v.mapSize - 1 - v.settlements[0]!.x, y: v.mapSize - 1 - v.settlements[0]!.y } },369- });370- }371- return o;372- };373- const defender: Bot = (v) => {374- const o: Order[] = [];375- if (v.tick === 0 && v.settlements[0] !== undefined) {376- o.push({ kind: 'muster', settlementId: v.settlements[0].id, size: 10, unitKind: 'infantry' });377- }378- return o;379- };563+ const config = { seed: '40', tribeCount: 2, mapSize: 64, maxTicks: 10_000 };380564 const run = (): { events: string[]; firstBattle: SimEvent | undefined } => {381565 const world = createWorld(config);382- const bots = new Map<number, Bot>([383- [0, attacker],384- [1, defender],385- ]);566+ // Both sides grow militarized economies (seed 40 gives both a claimable567+ // ore vein); the attacker out-produces the defender.568+ grow(world, 4500, {569+ tribes: [0, 1],570+ military: true,571+ arm: true,572+ walls: true,573+ until: () => {574+ const s0 = world.viewFor(0).settlements[0]!;575+ const s1 = world.viewFor(1).settlements[0]!;576+ return (577+ (s0.buildings as Record<string, number>).barracks !== undefined &&578+ (s1.buildings as Record<string, number>).barracks !== undefined &&579+ s0.stores.weapons >= 45 &&580+ s1.stores.weapons >= 16 &&581+ s0.pop >= 110 &&582+ s1.pop >= 80583+ );584+ },585+ });586+ const sid0 = world.viewFor(0).settlements[0]!.id;587+ const s1 = world.viewFor(1).settlements[0]!;588+ world.step(589+ new Map<number, Order[]>([590+ [0, [591+ { kind: 'stance', tribeId: 1, posture: 'hostile' } as Order,592+ { kind: 'muster', settlementId: sid0, size: 40, unitKind: 'infantry' } as Order,593+ ]],594+ [1, [{ kind: 'muster', settlementId: s1.id, size: 15, unitKind: 'infantry' } as Order]],595+ ]),596+ );386597 const events: string[] = [];387598 let firstBattle: SimEvent | undefined;388- for (let t = 0; t < 600 && !world.isOver(); t++) {389- stepWithBots(world, bots);599+ let marched = false;600+ for (let t = 0; t < 800 && !world.isOver(); t++) {601+ const w = world.viewFor(0).warbands[0];602+ const orders = new Map<number, Order[]>();603+ // March only once the band has finished drilling (anti-rush).604+ if (w !== undefined && w.drilling === 0 && !marched) {605+ marched = true;606+ orders.set(0, [607+ {608+ kind: 'command',609+ warbandId: w.id,610+ order: { type: 'march', target: { x: s1.x, y: s1.y } },611+ } as Order,612+ ]);613+ }614+ world.step(orders);390615 const evs = world.viewFor(1).events;391616 events.push(JSON.stringify(evs));392- firstBattle ??= evs.find((e) => e.t === 'battle');617+ firstBattle ??= evs.find(618+ (e) => e.t === 'battle' && (e as { defender: number | null }).defender === 1,619+ );620+ if (firstBattle !== undefined) break;393621 }394622 return { events, firstBattle };395623 };@@ -402,33 +630,44 @@ describe('military', () => {
402630 expect(b.events).toEqual(a.events);403631 });404632 405- it('besiege captures an undefended settlement and eliminates the tribe', () => {406- const world = createWorld({ seed: '23', tribeCount: 2, mapSize: 64, maxTicks: 5000 });633+ it('a grown army besieges and captures an undefended camp, eliminating the tribe', () => {634+ const world = createWorld({ seed: '23', tribeCount: 2, mapSize: 64, maxTicks: 10_000 });407635 const enemySid = 2; // settlements are created in tribe order: tribe1 owns id 2408- const attacker: Bot = (v) => {409- const o: Order[] = [];410- if (v.tick === 0 && v.settlements[0] !== undefined) {411- o.push({ kind: 'muster', settlementId: v.settlements[0].id, size: 24, unitKind: 'infantry' });412- }413- // Green troops can't besiege — wait for the drill to finish.414- const band = v.warbands[0];415- if (band !== undefined && band.drilling === 0 && band.order.type !== 'besiege') {416- o.push({417- kind: 'command',418- warbandId: band.id,419- order: { type: 'besiege', target: enemySid },420- });421- }422- return o;423- };424- const bots = new Map<number, Bot>([425- [0, attacker],426- [1, passiveBot],427- ]);636+ // Tribe 0 militarizes; tribe 1 stays passive (its hall gardens keep a637+ // sub-floor remnant alive, but it never musters — "undefended" means no638+ // warbands, not zero people).639+ grow(world, 4000, {640+ military: true,641+ arm: true,642+ walls: true,643+ until: () => {644+ const s = world.viewFor(0).settlements[0]!;645+ return (646+ (s.buildings as Record<string, number>).barracks !== undefined &&647+ s.stores.weapons >= 35 &&648+ s.pop >= 110649+ );650+ },651+ });652+ const sid = world.viewFor(0).settlements[0]!.id;653+ world.step(654+ new Map([[0, [{ kind: 'muster', settlementId: sid, size: 30, unitKind: 'infantry' } as Order]]]),655+ );656+ expect(world.viewFor(0).warbands.length).toBe(1);428657 let captured: SimEvent | undefined;429658 let eliminated: SimEvent | undefined;430- for (let t = 0; t < 1200 && !world.isOver(); t++) {431- stepWithBots(world, bots);659+ let besieging = false;660+ for (let t = 0; t < 2000 && !world.isOver(); t++) {661+ const band = world.viewFor(0).warbands[0];662+ const orders = new Map<number, Order[]>();663+ // Green troops can't besiege — wait for the drill to finish.664+ if (band !== undefined && band.drilling === 0 && !besieging) {665+ besieging = true;666+ orders.set(0, [667+ { kind: 'command', warbandId: band.id, order: { type: 'besiege', target: enemySid } } as Order,668+ ]);669+ }670+ world.step(orders);432671 captured ??= world.viewFor(0).events.find((e) => e.t === 'settlement_captured');433672 // The elimination event is delivered to the eliminated tribe itself.434673 eliminated ??= world.viewFor(1).events.find((e) => e.t === 'eliminated');@@ -456,25 +695,35 @@ describe('military', () => {
456695 // ---------------------------------------------------------------------------457696 458697 describe('threats', () => {459- it('camps spawn and raid settlements (raid.by === -1 convention)', () => {698+ it('camps spawn only after the grace window, then raid (raid.by === -1 convention)', () => {460699 const world = createWorld({ seed: '32', tribeCount: 2, mapSize: 64, maxTicks: 10_000 });461- const bots = botsFor(2, economyBot);700+ const bots = new Map<number, Bot>([701+ [0, growBot()],702+ [1, growBot()],703+ ]);462704 let raid: SimEvent | undefined;463705 let threatSeen = false;464- for (let t = 0; t < 4000 && !world.isOver(); t++) {706+ for (let t = 0; t < 5000 && !world.isOver(); t++) {465707 stepWithBots(world, bots);466708 for (const id of [0, 1]) {467709 const v = world.viewFor(id);468710 if (v.threats.length > 0) threatSeen = true;469- raid ??= v.events.find((e) => e.t === 'raid' && e.by === -1 && e.tribe === id);711+ raid ??= v.events.find((e) => e.t === 'raid' && e.by === -1);712+ }713+ // The wilderness is quiet during the grace window: no camps sighted,714+ // no monster raids while young camps establish themselves.715+ if (world.tick <= THREAT_GRACE_TICKS) {716+ expect(threatSeen).toBe(false);717+ expect(raid).toBeUndefined();470718 }471719 if (raid !== undefined && threatSeen) break;472720 }473- // Camps spawn 10..25 tiles out; settlement vision is radius+2 = 14, so474- // nearby camps show up in ThreatView. Raids reach the victim as raid721+ // Camps spawn 10..25 tiles out; a grown settlement's vision (radius+2)722+ // eventually covers the nearest ones. Raids reach the victim as raid475723 // events with the documented by = -1 threat convention.476724 expect(raid).toBeDefined();477725 expect(raid).toMatchObject({ t: 'raid', by: -1 });726+ expect((raid as { tick: number }).tick).toBeGreaterThan(THREAT_GRACE_TICKS);478727 expect(threatSeen).toBe(true);479728 });480729 });@@ -483,9 +732,41 @@ describe('threats', () => {
483732 // Trade484733 // ---------------------------------------------------------------------------485734 735+/** Grow both tribes to markets, then freeze extraction so store levels are736+ * stable for exact trade arithmetic. Returns nothing; asserts markets. */737+function growToMarkets(world: EngineWorld, tribes: number[]): void {738+ grow(world, 3000, {739+ tribes,740+ market: true,741+ walls: true,742+ until: () =>743+ tribes.every(744+ (id) =>745+ ((world.viewFor(id).settlements[0]!.buildings as Record<string, number>).market ?? 0) >= 1,746+ ),747+ });748+ for (const id of tribes) {749+ expect((world.viewFor(id).settlements[0]!.buildings as Record<string, number>).market).toBe(1);750+ }751+ const freeze: Order[] = [752+ 'farmer', 'woodcutter', 'miner', 'builder', 'smelter', 'smith', 'scholar', 'herder', 'hauler',753+ ].map((p) => A(p, p === 'farmer' ? 300 : 0));754+ const orders = new Map<number, Order[]>();755+ for (const id of tribes) orders.set(id, freeze);756+ world.step(orders);757+ for (let t = 0; t < 30; t++) world.step(new Map());758+}759+486760 describe('trade', () => {487761 it('matches complementary offers at the midpoint and delivers by caravan', () => {488- const world = createWorld({ seed: '41', tribeCount: 2, mapSize: 64, maxTicks: 5000 });762+ const world = createWorld({ seed: '41', tribeCount: 2, mapSize: 64, maxTicks: 10_000 });763+ // The humble start has no markets and near-empty stores: trade only764+ // works between grown economies.765+ growToMarkets(world, [0, 1]);766+ const s0 = world.viewFor(0).settlements[0]!;767+ const s1 = world.viewFor(1).settlements[0]!;768+ expect(s0.stores.timber).toBeGreaterThanOrEqual(200);769+ expect(s1.stores.stone).toBeGreaterThanOrEqual(100);489770 const t0: Order[] = [490771 { kind: 'trade_offer', give: 'timber', giveAmount: 200, want: 'stone', wantAmount: 100, tribe: null, repeat: false },491772 ];@@ -509,19 +790,22 @@ describe('trade', () => {
509790 }510791 expect(sawCaravan).toBe(true);511792 // q = min(100, 100) = 100 stone; pay = 100*(200*100+100*150)/(2*100*100) = 175 timber.512- const m0 = metricsOf(world, 0);513- const m1 = metricsOf(world, 1);514- expect(m0.stores.stone).toBe(250 + 100);515- expect(m0.stores.timber).toBe(400 - 175);516- expect(m1.stores.timber).toBe(400 + 175);517- expect(m1.stores.stone).toBe(250 - 100);518- expect(m0.tradeVolume).toBeGreaterThan(0);793+ const e0 = world.viewFor(0).settlements[0]!;794+ const e1 = world.viewFor(1).settlements[0]!;795+ expect(e0.stores.stone - s0.stores.stone).toBe(100);796+ expect(e0.stores.timber - s0.stores.timber).toBe(-175);797+ expect(e1.stores.timber - s1.stores.timber).toBe(175);798+ expect(e1.stores.stone - s1.stores.stone).toBe(-100);799+ expect(metricsOf(world, 0).tradeVolume).toBeGreaterThan(0);519800 // Matched non-repeat offers are gone.520801 expect(world.viewFor(0).offers.length).toBe(0);521802 });522803 523804 it('peddler: sell-to-coin offers clear instantly at a poor rate', () => {524- const world = createWorld({ seed: '42', tribeCount: 2, mapSize: 64, maxTicks: 5000 });805+ const world = createWorld({ seed: '41', tribeCount: 2, mapSize: 64, maxTicks: 10_000 });806+ growToMarkets(world, [0]);807+ const before = world.viewFor(0).settlements[0]!;808+ expect(before.stores.timber).toBeGreaterThanOrEqual(100);525809 const orders: Order[] = [526810 { kind: 'trade_offer', give: 'timber', giveAmount: 100, want: 'coin', wantAmount: 1, tribe: null, repeat: false },527811 ];@@ -529,8 +813,9 @@ describe('trade', () => {
529813 const empty = new Map<number, Order[]>();530814 for (let t = 0; t < 12; t++) world.step(empty);531815 // 100 timber * value 1 * 250‰ = 25 coin.532- expect(metricsOf(world, 0).stores.coin).toBe(100 + 25);533- expect(metricsOf(world, 0).stores.timber).toBe(400 - 100);816+ const after = world.viewFor(0).settlements[0]!;817+ expect(after.stores.coin - before.stores.coin).toBe(25);818+ expect(after.stores.timber - before.stores.timber).toBe(-100);534819 });535820 });536821 @@ -554,12 +839,13 @@ describe('order validation', () => {
554839 { kind: 'build', building: 'farm', settlementId: 2 },555840 { kind: 'build', building: 'mill', settlementId: 1 }, // milling not researched556841 { kind: 'build', building: 'tower', settlementId: 1 }, // no walls557- { kind: 'demolish', building: 'hall', settlementId: 1 },842+ { kind: 'demolish', building: 'granary', settlementId: 1 }, // none built558843 { kind: 'found', x: -3, y: 5, settlers: 100, supplies: {} },559844 { kind: 'found', x: 30, y: 30, settlers: 5, supplies: {} }, // too few560845 { kind: 'route', fromId: 1, toId: 2, resource: 'grain', permilleOfSurplus: 100 }, // enemy dest561846 { kind: 'muster', settlementId: 2, size: 20, unitKind: 'infantry' },562847 { kind: 'muster', settlementId: 1, size: 20, unitKind: 'dragon' as never },848+ { kind: 'muster', settlementId: 1, size: 20, unitKind: 'infantry' }, // no barracks, 4 weapons563849 { kind: 'muster', settlementId: 1, size: 20, unitKind: 'cavalry' }, // no horsemanship564850 { kind: 'command', warbandId: 99, order: { type: 'march', target: { x: 1, y: 1 } } },565851 { kind: 'trade_offer', give: 'grain', giveAmount: 0, want: 'coin', wantAmount: 1, tribe: null, repeat: false },@@ -569,6 +855,7 @@ describe('order validation', () => {
569855 { kind: 'stance', tribeId: 7, posture: 'ally' }, // no such tribe570856 { kind: 'research', tech: 'alchemy' as never },571857 { kind: 'research', tech: 'plate_armor' }, // missing prereqs858+ { kind: 'advance' }, // wagon-load stores can't cover the age hoard572859 { kind: 'rite', rite: 'blessing', target: 1 }, // no faith yet573860 { kind: 'rite', rite: 'war', target: null },574861 { bogus: true } as never,@@ -622,10 +909,22 @@ describe('viewFor', () => {
622909 });623910 624911 it('spawn fairness: quotas hold within radius 8 of every founding site', () => {625- const world = createWorld({ seed: '57', tribeCount: 8, mapSize: 128, maxTicks: 100 });912+ const world = createWorld({ seed: '57', tribeCount: 8, mapSize: 128, maxTicks: 10_000 });913+ // A tier-0 camp only sees radius 6; grow every tribe to tier 1 (vision914+ // radius 8) so the whole fairness neighborhood is explored.915+ grow(world, 2000, {916+ tribes: [0, 1, 2, 3, 4, 5, 6, 7],917+ until: () => {918+ for (let i = 0; i < 8; i++) {919+ if (world.viewFor(i).settlements[0]!.tier < 1) return false;920+ }921+ return true;922+ },923+ });626924 for (let id = 0; id < 8; id++) {627925 const v = world.viewFor(id);628926 const s = v.settlements[0]!;927+ expect(s.tier).toBeGreaterThanOrEqual(1);629928 let plains = 0;630929 let forest = 0;631930 let hills = 0;@@ -638,6 +937,7 @@ describe('viewFor', () => {
638937 const x = s.x + dx;639938 const y = s.y + dy;640939 const t = at(x, y);940+ expect(t, `tribe ${id} explored (${dx},${dy})`).not.toBe('?');641941 if (t === '.') plains++;642942 else if (t === 'f') forest++;643943 if ('hmOGv'.includes(t)) hills++;@@ -739,7 +1039,8 @@ describe('metrics', () => {
7391039 expect(a.pop).toBeGreaterThan(0);7401040 expect(a.moraleAvg).toBeGreaterThan(0);7411041 expect(a.territoryTiles).toBeGreaterThan(0);742- expect(a.settlementsByTier).toEqual([0, 0, 0, 1]);1042+ // The humble start is a single tier-0 camp.1043+ expect(a.settlementsByTier).toEqual([1, 0, 0, 0]);7431044 expect(a.score).toBe(7441045 a.scoreBreakdown.pop +7451046 a.scoreBreakdown.settlements +@@ -788,7 +1089,8 @@ describe('performance', () => {
7881089 if (v.tick === 0) o.push(...basePolicy());7891090 const s = v.settlements[0];7901091 if (s !== undefined) {791- // Periodic policy churn + entity orders.1092+ // Periodic policy churn + entity orders (musters are dropped at1093+ // the humble start — the order path itself is what's exercised).7921094 o.push(A('farmer', 380 + ((v.tick / 10 + id) % 4) * 10));7931095 if (v.tick % 500 === 0 && v.tick > 0) {7941096 o.push({ kind: 'build', building: 'house', settlementId: s.id });@@ -893,26 +1195,36 @@ describe('ages (prestige)', () => {
8931195 const world = createWorld({ seed: '79', tribeCount: 2, mapSize: 32, maxTicks: 200 });8941196 const before = world.viewFor(0);8951197 expect(before.age).toBe(0);896- // Starting stores: grain 2000, timber 400, hides 50 — not enough hides.1198+ // Wagon-load stores (grain 150, timber 80, hides 8) can't cover the age-11199+ // hoard (grain 800, timber 400, hides 100): the order is dropped.8971200 world.step(new Map([[0, [{ kind: 'advance' } as Order]]]));8981201 expect(world.viewFor(0).age).toBe(0); // unaffordable -> dropped8991202 900- // Top up hides via a fake windfall: use a herd slaughter decree over time901- // is slow — instead verify the cost gate itself by checking affordable902- // path on a fresh world with a boosted decree-free route: trade is heavy;903- // simplest deterministic route: slaughter herds hard for hides.904- const world2 = createWorld({ seed: '79', tribeCount: 2, mapSize: 32, maxTicks: 4000 });905- const orders: Order[] = [906- { kind: 'decree', key: 'slaughter', value: 60 },907- { kind: 'decree', key: 'fodder', value: 1 },908- { kind: 'allocate', profession: 'farmer', permille: 500 },909- { kind: 'allocate', profession: 'herder', permille: 120 },910- { kind: 'allocate', profession: 'woodcutter', permille: 250 },911- ];912- world2.step(new Map([[0, orders]]));1203+ // The affordable path needs a grown economy: rush a pasture to save the1204+ // starting herds, breed them fodder-fed to a real flock, then slaughter1205+ // for the 100 hides while farms and lumber camps pile up the rest.1206+ const world2 = createWorld({ seed: '79', tribeCount: 2, mapSize: 64, maxTicks: 10_000 });1207+ const bot = growBot({ herds: true });1208+ let slaughterOn = false;9131209 let advancedAt = -1;914- for (let t = 0; t < 3900; t++) {915- world2.step(new Map([[0, t % 20 === 0 ? [{ kind: 'advance' } as Order] : []]]));1210+ for (let t = 0; t < 4000 && !world2.isOver(); t++) {1211+ const v = world2.viewFor(0);1212+ const s = v.settlements[0]!;1213+ const extra: Order[] = [];1214+ if (!slaughterOn && s.herds >= 60) {1215+ slaughterOn = true;1216+ extra.push({ kind: 'decree', key: 'slaughter', value: 80 } as Order);1217+ }1218+ if (1219+ slaughterOn &&1220+ world2.tick % 20 === 0 &&1221+ s.stores.hides >= 100 &&1222+ s.stores.grain >= 800 &&1223+ s.stores.timber >= 4001224+ ) {1225+ extra.push({ kind: 'advance' } as Order);1226+ }1227+ world2.step(new Map([[0, [...bot(v), ...extra]]]));9161228 if (world2.viewFor(0).age === 1) {9171229 advancedAt = world2.tick;9181230 break;