1/**2 * Dev-mode mock of @tribes/engine createWorld and @tribes/script-host3 * createScriptHost, used behind the ?mock=1 flag (and until the real v24 * packages land). Implements the exact v2 EngineWorld / ScriptHost interfaces5 * from @tribes/protocol so the worker's real code paths exercise the same6 * shapes: settlements/warbands/caravans/threats in TribeViews, evolving7 * MetricsFrames every SLOW_TICK_INTERVAL, and occasional v2 events (battle,8 * raid, tech_unlocked, settlement_founded, threat_spawned/cleared, say, …).9 *10 * Deterministic: integer-ish state, one seeded PRNG, entity iteration by11 * ascending id. Not a real economy — each tribe follows a "personality"12 * (gatherers / raiders / turtles / nomads) so the dashboard charts diverge13 * plausibly over a run.14 */15import { LIMITS, PROFESSIONS, RESOURCES, TECHS } from '@tribes/protocol';16import type {17 CompileResult,18 CraftTarget,19 CreateWorld,20 DecreeKey,21 EngineWorld,22 MetricsFrame,23 Order,24 Profession,25 Resource,26 ScriptHost,27 SimConfig,28 SimEvent,29 SimResult,30 Tech,31 ThreatKind,32 TickOutcome,33 TribeMetrics,34 TribeView,35 TribeVm,36 TribeVmOptions,37 WarbandKind,38} from '@tribes/protocol';3940// --- deterministic PRNG (mulberry32) ---------------------------------------41function makeRng(seed: number): () => number {42 let a = seed >>> 0;43 return () => {44 a = (a + 0x6d2b79f5) >>> 0;45 let t = a;46 t = Math.imul(t ^ (t >>> 15), t | 1);47 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);48 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;49 };50}51const ri = (rng: () => number, n: number) => Math.floor(rng() * n);52const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));5354const zeroRecord = <K extends string>(keys: readonly K[]): Record<K, number> =>55 Object.fromEntries(keys.map((k) => [k, 0])) as Record<K, number>;5657/** Canned taunts for the synthetic demo 'say' events. */58const MOCK_SAYS = [59 'your walls are kindling',60 'we smell your granaries from here',61 'the steppe belongs to us now',62 'send tribute or send torches',63 'our smiths never sleep',64] as const;6566interface MockSettlement {67 id: number;68 x: number;69 y: number;70 pop: number;71 walls: number;72}7374interface MockWarband {75 id: number;76 home: number;77 kind: WarbandKind;78 size: number;79 x: number;80 y: number;81 veterancy: number;82}8384interface MockCaravan {85 id: number;86 tribe: number;87 x: number;88 y: number;89 resource: Resource;90 amount: number;91}9293interface MockThreat {94 id: number;95 kind: ThreatKind;96 x: number;97 y: number;98 strength: number;99}100101/** Personality multipliers — drive divergent, chart-friendly trajectories. */102interface Personality {103 growth: number; // population growth bias104 military: number; // muster appetite105 expand: number; // settlement founding appetite106 research: number; // tech pace107 trade: number; // caravan/coin bias108}109const PERSONALITIES: Personality[] = [110 { growth: 1.25, military: 0.5, expand: 0.9, research: 1.0, trade: 1.3 }, // gatherers111 { growth: 0.9, military: 1.6, expand: 0.7, research: 0.7, trade: 0.6 }, // raiders112 { growth: 1.0, military: 0.9, expand: 0.5, research: 1.3, trade: 0.9 }, // turtles113 { growth: 1.05, military: 0.8, expand: 1.6, research: 0.9, trade: 1.0 }, // nomads114];115116interface MockTribe {117 id: number;118 alive: boolean;119 diedAt: number | null;120 p: Personality;121 settlements: MockSettlement[];122 warbands: MockWarband[];123 stores: Record<Resource, number>;124 allocations: Record<Profession, number>;125 techs: Tech[];126 knowledge: number;127 faith: number;128 explored: Uint8Array;129 // fixed-point vital-rate accumulators (per-mille)130 birthAcc: number;131 deathAcc: number;132 // cumulative counters133 battlesWon: number;134 battlesLost: number;135 casualtiesDealt: number;136 casualtiesTaken: number;137 campsCleared: number;138 buildingsCompleted: number;139 tradeVolume: number;140 caravansLost: number;141 // per-interval counters (reset each SLOW_TICK_INTERVAL)142 births: number;143 deaths: number;144 starvationDeaths: number;145 production: Record<Resource, number>;146 consumption: Record<Resource, number>;147}148149/** Profession mix each personality drifts toward (per-mille, rest = idle). */150const BASE_ALLOC: Array<[Profession, number]> = [151 ['farmer', 220], ['fisher', 90], ['herder', 60], ['woodcutter', 80], ['miner', 70],152 ['miller', 40], ['baker', 50], ['smelter', 35], ['smith', 45], ['carpenter', 30],153 ['tanner', 20], ['weaver', 25], ['tailor', 20], ['builder', 50], ['hauler', 40],154 ['scholar', 30], ['priest', 15], ['brewer', 20],155];156157class MockWorld implements EngineWorld {158 tick = 0;159 readonly config: SimConfig;160 private rng: () => number;161 private map: string[];162 private tribes: MockTribe[] = [];163 private caravans: MockCaravan[] = [];164 private threats: MockThreat[] = [];165 private nextId = 1;166 private eventsByTribe: SimEvent[][] = [];167 private metrics: MetricsFrame | null = null;168169 constructor(config: SimConfig) {170 this.config = config;171 this.rng = makeRng(Number(config.seed) % 2147483647 || 1);172 const n = config.mapSize;173 this.map = new Array<string>(n * n).fill('.');174 const blob = (ch: string, count: number, len: number) => {175 for (let i = 0; i < count; i++) {176 let x = ri(this.rng, n);177 let y = ri(this.rng, n);178 for (let s = 0; s < len; s++) {179 this.map[y * n + x] = ch;180 x = clamp(x + ri(this.rng, 3) - 1, 0, n - 1);181 y = clamp(y + ri(this.rng, 3) - 1, 0, n - 1);182 }183 }184 };185 blob('w', 3, 90);186 blob('f', 8, 45);187 blob('h', 6, 30);188 blob('m', 3, 20);189 blob('t', 4, 30);190 blob('s', 2, 14);191 blob('O', 4, 3);192 blob('G', 2, 2);193194 const cx = n / 2;195 const r = n * 0.36;196 for (let t = 0; t < config.tribeCount; t++) {197 const ang = (t / config.tribeCount) * Math.PI * 2;198 const sx = Math.round(cx + Math.cos(ang) * r);199 const sy = Math.round(cx + Math.sin(ang) * r);200 for (let dy = -2; dy <= 2; dy++)201 for (let dx = -2; dx <= 2; dx++) {202 const x = sx + dx;203 const y = sy + dy;204 if (x >= 0 && x < n && y >= 0 && y < n) this.map[y * n + x] = '.';205 }206 const stores = zeroRecord(RESOURCES);207 stores.grain = 800;208 stores.fish = 200;209 stores.timber = 300;210 stores.stone = 150;211 stores.tools = 120;212 stores.coin = 100;213 stores.bread = 150;214 const allocations = zeroRecord(PROFESSIONS);215 for (const [p, v] of BASE_ALLOC) allocations[p] = v;216 const tribe: MockTribe = {217 id: t,218 alive: true,219 diedAt: null,220 p: PERSONALITIES[t % PERSONALITIES.length]!,221 settlements: [{ id: this.nextId++, x: sx, y: sy, pop: LIMITS.START_POP, walls: 0 }],222 warbands: [],223 stores,224 allocations,225 techs: [],226 knowledge: 0,227 faith: 100,228 explored: new Uint8Array(n * n),229 birthAcc: 0,230 deathAcc: 0,231 battlesWon: 0,232 battlesLost: 0,233 casualtiesDealt: 0,234 casualtiesTaken: 0,235 campsCleared: 0,236 buildingsCompleted: 0,237 tradeVolume: 0,238 caravansLost: 0,239 births: 0,240 deaths: 0,241 starvationDeaths: 0,242 production: zeroRecord(RESOURCES),243 consumption: zeroRecord(RESOURCES),244 };245 this.tribes.push(tribe);246 this.explore(tribe, sx, sy, 9);247 }248 this.eventsByTribe = this.tribes.map(() => []);249 }250251 /** Deliver an event to specific tribes (deduped), or broadcast to all alive. */252 private emitEvent(ev: SimEvent, to?: number[]) {253 const targets = to ?? this.tribes.filter((t) => t.alive).map((t) => t.id);254 for (const id of new Set(targets)) this.eventsByTribe[id]?.push(ev);255 }256257 private explore(tribe: MockTribe, x: number, y: number, radius: number) {258 const n = this.config.mapSize;259 for (let dy = -radius; dy <= radius; dy++)260 for (let dx = -radius; dx <= radius; dx++) {261 if (dx * dx + dy * dy > radius * radius) continue;262 const tx = x + dx;263 const ty = y + dy;264 if (tx >= 0 && tx < n && ty >= 0 && ty < n) tribe.explored[ty * n + tx] = 1;265 }266 }267268 private totalPop(t: MockTribe): number {269 let pop = 0;270 for (const s of t.settlements) pop += s.pop;271 return pop;272 }273274 private tierOf(pop: number): number {275 return pop >= 1000 ? 3 : pop >= 400 ? 2 : pop >= 100 ? 1 : 0;276 }277278 private power(t: MockTribe): number {279 let p = 0;280 for (const b of t.warbands) p += Math.round(b.size * (1.5 + b.veterancy / 1000));281 return p;282 }283284 viewFor(tribeId: number): TribeView {285 const n = this.config.mapSize;286 const t = this.tribes[tribeId];287 if (!t) throw new Error(`no tribe ${tribeId}`);288 const chars = new Array<string>(n * n);289 for (let i = 0; i < n * n; i++) chars[i] = t.explored[i] ? this.map[i]! : '?';290291 const near = (x: number, y: number, r: number) =>292 t.settlements.some((s) => (s.x - x) ** 2 + (s.y - y) ** 2 <= r * r) ||293 t.warbands.some((b) => (b.x - x) ** 2 + (b.y - y) ** 2 <= r * r);294295 const craft: Record<CraftTarget, number> = { tools: 600, weapons: 250, armor: 150, mill: 500, brew: 100, fodder: 400 };296 const decrees: Record<DecreeKey, number> = { rations: 1000, slaughter: 0, fodder: 400, ale_ration: 100, expansion: 500 };297 const pop = this.totalPop(t);298299 return {300 tick: this.tick,301 tribeId,302 mapSize: n,303 terrain: chars.join(''),304 distant: '?'.repeat(n * n),305 claims: '.'.repeat(n * n),306 age: Math.min(3, Math.floor(this.tick / 3000)),307 settlements: t.settlements.map((s, i) => ({308 id: s.id,309 x: s.x,310 y: s.y,311 tier: this.tierOf(s.pop),312 pop: s.pop,313 morale: 700,314 // The mock pools all stores in the founding settlement.315 stores: i === 0 ? { ...t.stores } : zeroRecord(RESOURCES),316 storeCap: 5000,317 buildings: { hall: 1, house: Math.max(1, Math.floor(s.pop / 40)), granary: 1, walls: s.walls },318 buildQueue: [],319 territoryTiles: 30 + this.tierOf(s.pop) * 60,320 workers: Object.fromEntries(321 PROFESSIONS.map((prof) => [prof, Math.floor((s.pop * t.allocations[prof]) / 1000)]),322 ),323 capacity: Object.fromEntries(PROFESSIONS.map((prof) => [prof, Math.ceil(s.pop / 3)])),324 herds: 40,325 horses: t.techs.includes('horsemanship') ? 25 : 0,326 })),327 warbands: t.warbands.map((b) => ({328 id: b.id,329 home: b.home,330 size: b.size,331 kind: b.kind,332 weapons: Math.floor(b.size * 0.8),333 armor: Math.floor(b.size * 0.4),334 veterancy: b.veterancy,335 morale: 700,336 x: b.x,337 y: b.y,338 order: { type: 'patrol' as const, target: { x: b.x, y: b.y } },339 drilling: 0,340 supply: b.size * 4,341 })),342 caravans: this.caravans343 .filter((c) => c.tribe === tribeId || near(c.x, c.y, 10))344 .map((c) => ({ ...c })),345 threats: this.threats.filter((th) => t.explored[th.y * n + th.x] === 1).map((th) => ({ ...th })),346 enemySettlements: this.tribes347 .filter((o) => o.id !== tribeId && o.alive)348 .flatMap((o) =>349 o.settlements350 .filter((s) => near(s.x, s.y, 12))351 .map((s) => ({ tribe: o.id, id: s.id, x: s.x, y: s.y, tier: this.tierOf(s.pop), wallLevel: s.walls })),352 ),353 enemyWarbands: this.tribes354 .filter((o) => o.id !== tribeId && o.alive)355 .flatMap((o) =>356 o.warbands357 .filter((b) => near(b.x, b.y, 12))358 .map((b) => ({ tribe: o.id, id: b.id, x: b.x, y: b.y, size: b.size, kind: b.kind })),359 ),360 offers: [],361 techs: [...t.techs],362 researching: t.techs.length < TECHS.length ? TECHS[t.techs.length]! : null,363 knowledge: t.knowledge,364 faith: t.faith,365 craft,366 decrees,367 allocations: { ...t.allocations },368 stances: Object.fromEntries(this.tribes.filter((o) => o.id !== tribeId).map((o) => [o.id, 'neutral' as const])),369 aliveTribes: this.tribes.filter((o) => o.alive).map((o) => o.id),370 events: (this.eventsByTribe[tribeId] ?? []).slice(-LIMITS.EVENTS_PER_VIEW),371 };372 }373374 /** Orders are accepted but the mock drives its own toy economy/warfare. */375 step(_ordersByTribe: ReadonlyMap<number, Order[]>): void {376 const n = this.config.mapSize;377 this.tick++;378 this.eventsByTribe = this.tribes.map(() => []);379 const rng = this.rng;380381 for (const t of this.tribes) {382 if (!t.alive) continue;383 const pop = this.totalPop(t);384 const workers = (p: Profession) => Math.floor((pop * t.allocations[p]) / 1000);385386 // --- population: separate logistic birth/death accumulators, so the387 // vitals sparklines carry real signals (births vs deaths vs starvation)388 const foodOk = t.stores.grain + t.stores.bread + t.stores.fish > pop * 0.3;389 const cap = 3200 * t.p.growth; // carrying capacity → S-curves that diverge390 const birthRate = foodOk ? Math.max(0.4, 3.4 * t.p.growth * (1 - pop / cap)) : 0.3;391 const deathRate = foodOk ? 1.0 + (rng() < 0.02 ? 1.5 : 0) : 3.2;392 t.birthAcc += Math.round(pop * birthRate);393 t.deathAcc += Math.round(pop * deathRate);394 const born = Math.trunc(t.birthAcc / 4000);395 t.birthAcc -= born * 4000;396 const died = Math.trunc(t.deathAcc / 4000);397 t.deathAcc -= died * 4000;398 t.births += born;399 t.deaths += died;400 let remaining = born - died;401 if (rng() < 0.0025 && pop > 400) {402 const starved = Math.floor(pop * (0.01 + rng() * 0.02));403 remaining -= starved;404 t.starvationDeaths += starved;405 t.deaths += starved;406 this.emitEvent(407 { t: 'starvation', tick: this.tick, tribe: t.id, settlementId: t.settlements[0]!.id, deaths: starved, x: t.settlements[0]!.x, y: t.settlements[0]!.y },408 [t.id],409 );410 }411 // apply delta to the largest settlement412 const s0 = t.settlements.reduce((a, b) => (b.pop > a.pop ? b : a), t.settlements[0]!);413 s0.pop = Math.max(20, s0.pop + remaining);414415 // --- allocation drift (small random walk, keeps stacked areas alive) ---416 if (this.tick % 25 === 0) {417 const profs: Profession[] = ['farmer', 'miner', 'smith', 'scholar', 'builder', 'woodcutter'];418 const a = profs[ri(rng, profs.length)]!;419 const b = profs[ri(rng, profs.length)]!;420 const move = Math.min(t.allocations[a], 5 + ri(rng, 15));421 t.allocations[a] -= move;422 t.allocations[b] += move;423 }424425 // --- toy economy: production/consumption accumulate per interval ---426 const prod = t.production;427 const cons = t.consumption;428 const toolBoost = t.stores.tools > workers('farmer') ? 1 : 0.6;429 const add = (r: Resource, v: number) => {430 const iv = Math.floor(v);431 if (iv <= 0) return;432 prod[r] += iv;433 t.stores[r] += iv;434 };435 const eat = (r: Resource, v: number) => {436 const iv = Math.min(t.stores[r], Math.floor(v));437 if (iv <= 0) return;438 cons[r] += iv;439 t.stores[r] -= iv;440 };441 add('grain', workers('farmer') * 0.30 * toolBoost * t.p.growth);442 add('fish', workers('fisher') * 0.22);443 add('meat', workers('herder') * 0.08);444 add('timber', workers('woodcutter') * 0.08);445 add('stone', workers('miner') * 0.04);446 add('ore', workers('miner') * 0.08 * toolBoost);447 add('flour', Math.min(workers('miller') * 0.12, t.stores.grain * 0.02));448 eat('grain', workers('miller') * 0.12);449 add('bread', Math.min(workers('baker') * 0.12, t.stores.flour * 0.5));450 eat('flour', workers('baker') * 0.1);451 add('metal', Math.min(workers('smelter') * 0.1, t.stores.ore * 0.5));452 eat('ore', workers('smelter') * 0.1);453 const smithOut = workers('smith') * 0.11;454 add('tools', smithOut * 0.6);455 t.stores.tools = Math.min(t.stores.tools, 2500); // warehouse cap456 add('weapons', smithOut * 0.25 * t.p.military);457 add('armor', smithOut * 0.15 * t.p.military);458 eat('metal', smithOut * 0.8);459 add('cloth', workers('weaver') * 0.05);460 add('coin', pop * 0.0015 * t.p.trade + (this.map[s0.y * n + s0.x] === 'G' ? 2 : 0));461 // people eat; tools wear out; coin pays upkeep462 eat('bread', pop * 0.012);463 eat('grain', pop * 0.030);464 eat('fish', pop * 0.008);465 eat('meat', pop * 0.004);466 eat('tools', pop * 0.003);467 eat('coin', pop * 0.0006);468 // raw/perishable stores spoil — keeps stores at an equilibrium instead469 // of growing without bound (counted as consumption for chain health)470 for (const r of ['grain', 'fish', 'meat', 'flour', 'bread'] as const) {471 eat(r, t.stores[r] * 0.004);472 }473474 // --- construction ---475 if (this.tick % 40 === 20 && t.stores.timber > 100) {476 t.stores.timber -= 80;477 t.buildingsCompleted++;478 const which = (['house', 'granary', 'smithy', 'market', 'walls'] as const)[ri(rng, 5)]!;479 if (which === 'walls') s0.walls++;480 this.emitEvent(481 { t: 'building_complete', tick: this.tick, tribe: t.id, building: which, settlementId: s0.id, x: s0.x, y: s0.y },482 [t.id],483 );484 }485486 // --- research ---487 t.knowledge += Math.floor(workers('scholar') * t.p.research);488 const nextTech = TECHS[t.techs.length];489 if (nextTech && t.knowledge >= 4000 + t.techs.length * 9000) {490 t.knowledge = 0;491 t.techs.push(nextTech);492 this.emitEvent({ t: 'tech_unlocked', tick: this.tick, tribe: t.id, tech: nextTech });493 }494 t.faith = clamp(t.faith + (rng() < 0.5 ? 1 : -1), 50, 900);495496 // --- expansion: found a settlement when crowded ---497 if (498 pop > 800 * t.settlements.length &&499 t.settlements.length < 5 &&500 rng() < 0.004 * t.p.expand501 ) {502 const base = t.settlements[ri(rng, t.settlements.length)]!;503 const x = clamp(base.x + ri(rng, 17) - 8, 1, n - 2);504 const y = clamp(base.y + ri(rng, 17) - 8, 1, n - 2);505 if (this.map[y * n + x] !== 'w') {506 const settlers = Math.min(150, Math.floor(base.pop * 0.15));507 base.pop -= settlers;508 const ns: MockSettlement = { id: this.nextId++, x, y, pop: settlers, walls: 0 };509 t.settlements.push(ns);510 this.explore(t, x, y, 7);511 this.emitEvent({ t: 'settlement_founded', tick: this.tick, tribe: t.id, settlementId: ns.id, x, y });512 }513 }514515 // --- muster warbands ---516 const wantBands = clamp(Math.floor((pop / 700) * t.p.military) + (t.p.military > 1 ? 1 : 0), 1, 4);517 if (t.warbands.length < wantBands && pop > 400 && t.stores.weapons > 15) {518 const size = 40 + ri(rng, 40);519 s0.pop -= Math.min(size, s0.pop - 50);520 t.stores.weapons = Math.max(0, t.stores.weapons - size);521 const kinds: WarbandKind[] = ['infantry', 'infantry', 'archers', 'cavalry'];522 t.warbands.push({523 id: this.nextId++,524 home: s0.id,525 kind: kinds[ri(rng, t.techs.includes('horsemanship') ? 4 : 3)]!,526 size,527 x: s0.x,528 y: s0.y,529 veterancy: 0,530 });531 }532533 // --- warband movement: patrol waypoints cycle between the contested534 // centre and (for raiders) enemy settlements, so bands actually meet ---535 for (const b of t.warbands) {536 const cxm = n >> 1;537 let tx: number;538 let ty: number;539 const phase = (Math.floor(this.tick / 300) + b.id) % 2 === 0;540 if (t.p.military > 1 && phase) {541 // raiders: head for the nearest enemy settlement542 let bestD = Infinity;543 tx = cxm;544 ty = cxm;545 for (const o of this.tribes) {546 if (o.id === t.id || !o.alive) continue;547 for (const s of o.settlements) {548 const d = Math.abs(s.x - b.x) + Math.abs(s.y - b.y);549 if (d < bestD) {550 bestD = d;551 tx = s.x;552 ty = s.y;553 }554 }555 }556 } else if (phase) {557 [tx, ty] = [cxm, cxm]; // everyone contests the centre periodically558 } else {559 [tx, ty] = [(s0.x + cxm) >> 1, (s0.y + cxm) >> 1];560 }561 let dx = ri(rng, 3) - 1;562 let dy = ri(rng, 3) - 1;563 if (Math.abs(b.x - tx) + Math.abs(b.y - ty) > 4 && rng() < 0.7) {564 dx = Math.sign(tx - b.x);565 dy = Math.sign(ty - b.y);566 }567 b.x = clamp(b.x + dx, 0, n - 1);568 b.y = clamp(b.y + dy, 0, n - 1);569 this.explore(t, b.x, b.y, 5);570 // opportunistic raid on a nearby enemy settlement571 for (const o of this.tribes) {572 if (o.id === t.id || !o.alive || t.p.military <= 1) continue;573 const target = o.settlements.find((s) => Math.abs(s.x - b.x) <= 3 && Math.abs(s.y - b.y) <= 3);574 if (target && rng() < 0.015) {575 const grain = Math.min(o.stores.grain, 40 + ri(rng, 60));576 const coin = Math.min(o.stores.coin, 10 + ri(rng, 20));577 o.stores.grain -= grain;578 o.stores.coin -= coin;579 t.stores.grain += grain;580 t.stores.coin += coin;581 this.emitEvent({ t: 'raid', tick: this.tick, tribe: o.id, by: t.id, settlementId: target.id, loot: { grain, coin }, x: target.x, y: target.y });582 }583 }584 }585586 // --- caravans (trade between own settlements) ---587 if (t.settlements.length > 1 && this.caravans.filter((c) => c.tribe === t.id).length < 2 && rng() < 0.01 * t.p.trade) {588 const from = t.settlements[0]!;589 const goods: Resource[] = ['bread', 'cloth', 'tools', 'ale'];590 this.caravans.push({591 id: this.nextId++,592 tribe: t.id,593 x: from.x,594 y: from.y,595 resource: goods[ri(rng, goods.length)]!,596 amount: 20 + ri(rng, 40),597 });598 }599 }600601 // --- caravan movement + trade (+ the odd highway robbery) ---602 this.caravans = this.caravans.filter((c) => {603 const t = this.tribes[c.tribe]!;604 if (!t.alive || t.settlements.length < 2) return false;605 const dest = t.settlements[t.settlements.length - 1]!;606 c.x += Math.sign(dest.x - c.x);607 c.y += Math.sign(dest.y - c.y);608 for (const o of this.tribes) {609 if (o.id === c.tribe || !o.alive || o.p.military <= 1) continue;610 if (o.warbands.some((b) => Math.abs(b.x - c.x) <= 2 && Math.abs(b.y - c.y) <= 2) && this.rng() < 0.2) {611 t.caravansLost++;612 o.stores.coin += Math.floor(c.amount / 2);613 this.emitEvent({614 t: 'caravan_raided',615 tick: this.tick,616 tribe: c.tribe,617 by: o.id,618 x: c.x,619 y: c.y,620 resource: c.resource,621 amount: c.amount,622 });623 return false;624 }625 }626 if (c.x === dest.x && c.y === dest.y) {627 t.tradeVolume += c.amount;628 t.stores.coin += Math.floor(c.amount / 4);629 return false;630 }631 return true;632 });633634 // --- battles between hostile warbands that meet ---635 for (const a of this.tribes) {636 if (!a.alive) continue;637 for (const b of this.tribes) {638 if (b.id <= a.id || !b.alive) continue;639 for (const wa of a.warbands) {640 for (const wb of b.warbands) {641 if (Math.abs(wa.x - wb.x) <= 3 && Math.abs(wa.y - wb.y) <= 3 && rng() < 0.3) {642 const pa = wa.size * (1.2 + wa.veterancy / 1000) * (0.8 + rng() * 0.4);643 const pb = wb.size * (1.2 + wb.veterancy / 1000) * (0.8 + rng() * 0.4);644 const aWins = pa >= pb;645 const [winT, loseT, winW, loseW] = aWins ? [a, b, wa, wb] : [b, a, wb, wa];646 const winLoss = Math.floor(loseW.size * (0.1 + rng() * 0.15));647 const loseLoss = Math.floor(loseW.size * (0.35 + rng() * 0.3));648 winW.size = Math.max(0, winW.size - winLoss);649 loseW.size = Math.max(0, loseW.size - loseLoss);650 winW.veterancy = clamp(winW.veterancy + 60, 0, 1000);651 winT.battlesWon++;652 loseT.battlesLost++;653 winT.casualtiesDealt += loseLoss;654 winT.casualtiesTaken += winLoss;655 loseT.casualtiesDealt += winLoss;656 loseT.casualtiesTaken += loseLoss;657 this.emitEvent({658 t: 'battle',659 tick: this.tick,660 x: wa.x,661 y: wa.y,662 attacker: a.id,663 defender: b.id,664 attackerLosses: aWins ? winLoss : loseLoss,665 defenderLosses: aWins ? loseLoss : winLoss,666 winner: winT.id,667 });668 // raiders loot after a win near an enemy settlement669 const target = loseT.settlements.find(670 (s) => Math.abs(s.x - wa.x) <= 3 && Math.abs(s.y - wa.y) <= 3,671 );672 if (target && winT.p.military > 1 && rng() < 0.5) {673 const grain = Math.min(loseT.stores.grain, 40 + ri(rng, 60));674 const coin = Math.min(loseT.stores.coin, 10 + ri(rng, 20));675 loseT.stores.grain -= grain;676 loseT.stores.coin -= coin;677 winT.stores.grain += grain;678 winT.stores.coin += coin;679 this.emitEvent({680 t: 'raid',681 tick: this.tick,682 tribe: loseT.id,683 by: winT.id,684 settlementId: target.id,685 loot: { grain, coin },686 x: target.x,687 y: target.y,688 });689 }690 }691 }692 }693 }694 a.warbands = a.warbands.filter((w) => w.size > 5);695 }696697 // --- threats spawn / get cleared ---698 if (this.tick % 250 === 100 && this.threats.length < 4) {699 const kinds: ThreatKind[] = ['goblin_camp', 'skeleton_barrow', 'ogre_den', 'demon_gate'];700 const th: MockThreat = {701 id: this.nextId++,702 kind: kinds[ri(rng, kinds.length)]!,703 x: ri(rng, n),704 y: ri(rng, n),705 strength: 80 + ri(rng, 200),706 };707 this.threats.push(th);708 this.emitEvent({ t: 'threat_spawned', tick: this.tick, kind: th.kind, x: th.x, y: th.y });709 }710 this.threats = this.threats.filter((th) => {711 for (const t of this.tribes) {712 if (!t.alive) continue;713 const wb = t.warbands.find((b) => Math.abs(b.x - th.x) <= 2 && Math.abs(b.y - th.y) <= 2);714 if (wb && rng() < 0.3) {715 const losses = Math.floor(th.strength * 0.15);716 wb.size = Math.max(6, wb.size - losses);717 t.casualtiesTaken += losses;718 t.casualtiesDealt += th.strength;719 t.campsCleared++;720 this.emitEvent({721 t: 'battle',722 tick: this.tick,723 x: th.x,724 y: th.y,725 attacker: t.id,726 defender: null,727 attackerLosses: losses,728 defenderLosses: th.strength,729 winner: t.id,730 });731 this.emitEvent({ t: 'threat_cleared', tick: this.tick, tribe: t.id, kind: th.kind, x: th.x, y: th.y });732 return false;733 }734 }735 return true;736 });737738 // --- says: rotating taunts so the world feed stays lively ---739 if (this.tick % 120 === 60) {740 const alive = this.tribes.filter((t) => t.alive);741 if (alive.length > 0) {742 const round = Math.floor(this.tick / 120);743 const sayer = alive[round % alive.length]!;744 this.emitEvent({ t: 'say', tick: this.tick, tribe: sayer.id, text: MOCK_SAYS[round % MOCK_SAYS.length]! });745 }746 }747748 // --- metrics frame on the slow-tick cadence (and on the final tick) ---749 if (this.tick % LIMITS.SLOW_TICK_INTERVAL === 0 || this.isOver()) {750 this.metrics = this.computeMetrics();751 // reset per-interval counters752 for (const t of this.tribes) {753 t.births = 0;754 t.deaths = 0;755 t.starvationDeaths = 0;756 t.production = zeroRecord(RESOURCES);757 t.consumption = zeroRecord(RESOURCES);758 }759 }760 }761762 private scoreOf(t: MockTribe): { score: number; breakdown: TribeMetrics['scoreBreakdown'] } {763 const breakdown = {764 pop: Math.floor(this.totalPop(t) / 2),765 settlements: t.settlements.length * 150,766 techs: t.techs.length * 100,767 military: Math.floor(this.power(t) / 4) + t.battlesWon * 25 + t.campsCleared * 40,768 coin: Math.floor(t.stores.coin / 10),769 buildings: t.buildingsCompleted * 10,770 age: Math.min(3, Math.floor(this.tick / 3000)) * 150,771 };772 const score = Object.values(breakdown).reduce((a, v) => a + v, 0);773 return { score, breakdown };774 }775776 private computeMetrics(): MetricsFrame {777 return {778 tick: this.tick,779 tribes: this.tribes.map((t): TribeMetrics => {780 const pop = this.totalPop(t);781 const popByProfession = zeroRecord(PROFESSIONS);782 let allocated = 0;783 for (const p of PROFESSIONS) {784 popByProfession[p] = Math.floor((pop * t.allocations[p]) / 1000);785 allocated += popByProfession[p];786 }787 let soldiers = 0;788 for (const b of t.warbands) soldiers += b.size;789 const { score, breakdown } = this.scoreOf(t);790 const extractive = popByProfession.farmer + popByProfession.miner + popByProfession.woodcutter + popByProfession.fisher;791 return {792 tribeId: t.id,793 alive: t.alive,794 pop,795 popByProfession,796 idle: Math.max(0, pop - allocated),797 soldiers,798 moraleAvg: 700,799 births: t.births,800 deaths: t.deaths,801 starvationDeaths: t.starvationDeaths,802 stores: { ...t.stores },803 production: { ...t.production },804 consumption: { ...t.consumption },805 toolCoverage: clamp(Math.floor((t.stores.tools / Math.max(1, extractive)) * 1000), 0, 1000),806 warbands: t.warbands.length,807 warbandPower: this.power(t),808 battlesWon: t.battlesWon,809 battlesLost: t.battlesLost,810 casualtiesDealt: t.casualtiesDealt,811 casualtiesTaken: t.casualtiesTaken,812 campsCleared: t.campsCleared,813 territoryTiles: t.settlements.reduce((a, s) => {814 const r = 4 + this.tierOf(s.pop) * 2;815 return a + Math.floor(Math.PI * r * r);816 }, 0),817 settlementsByTier: t.settlements.reduce<[number, number, number, number]>(818 (acc, s) => {819 acc[this.tierOf(s.pop) as 0 | 1 | 2 | 3]++;820 return acc;821 },822 [0, 0, 0, 0],823 ),824 buildingsCompleted: t.buildingsCompleted,825 techsUnlocked: t.techs.length,826 age: Math.min(3, Math.floor(this.tick / 3000)),827 knowledge: t.knowledge,828 faith: t.faith,829 coin: t.stores.coin,830 tradeVolume: t.tradeVolume,831 caravansLost: t.caravansLost,832 score,833 scoreBreakdown: breakdown,834 };835 }),836 };837 }838839 latestMetrics(): MetricsFrame | null {840 return this.metrics;841 }842843 forfeit(tribeId: number): void {844 const t = this.tribes[tribeId];845 if (t && t.alive) {846 t.alive = false;847 t.diedAt = this.tick;848 t.warbands = [];849 this.emitEvent({ t: 'forfeited', tick: this.tick, tribe: tribeId });850 }851 }852853 stateHash(): string {854 let hh = 2166136261 >>> 0;855 const mix = (v: number) => {856 hh ^= v >>> 0;857 hh = Math.imul(hh, 16777619) >>> 0;858 };859 mix(this.tick);860 for (const t of this.tribes) {861 mix(this.totalPop(t));862 mix(t.stores.coin);863 mix(t.stores.grain);864 mix(t.warbands.length);865 }866 return hh.toString(16).padStart(8, '0');867 }868869 isOver(): boolean {870 return this.tick >= this.config.maxTicks || this.tribes.filter((t) => t.alive).length <= 1;871 }872873 result(): SimResult {874 const rows = this.tribes.map((t) => {875 const { score, breakdown } = this.scoreOf(t);876 return {877 tribeId: t.id,878 alive: t.alive,879 score,880 scoreBreakdown: breakdown,881 pop: this.totalPop(t),882 settlements: t.settlements.length,883 casualtiesDealt: t.casualtiesDealt,884 diedAt: t.diedAt,885 };886 });887 const best = Math.max(...rows.map((s) => s.score));888 const winners = rows.filter((s) => s.score === best);889 return {890 endedAt: this.tick,891 reason: this.tribes.filter((t) => t.alive).length <= 1 ? 'last_standing' : 'tick_cap',892 winner: winners.length === 1 ? winners[0]!.tribeId : null,893 tribes: rows,894 };895 }896}897898export const createMockWorld: CreateWorld = (config) => new MockWorld(config);899900export async function createMockScriptHost(): Promise<ScriptHost> {901 return {902 compile(_source: string): CompileResult {903 return { ok: true, error: null };904 },905 createTribeVm(_source: string, opts: TribeVmOptions): TribeVm {906 const rng = makeRng(907 [...opts.rngSeed].reduce((a, c) => (Math.imul(a, 31) + c.charCodeAt(0)) >>> 0, 7),908 );909 const THOUGHTS = [910 'grain dipping — shifting 20‰ from builders to farmers',911 'smithy split now 60/25/15 tools/weapons/armor',912 'scouting the ore vein past the north ridge',913 'mustering a second infantry band at the hall',914 'route queued: bread from the capital to the river camp',915 ];916 return {917 runTick(view: TribeView): TickOutcome & { logs: string[] } {918 // The mock world ignores orders; report plausible gas usage and919 // emit an occasional api.log line so per-tribe feeds demo.920 const logs =921 view.tick % 40 === 0 ? [THOUGHTS[(view.tick / 40 + view.tribeId) % THOUGHTS.length]!] : [];922 return { orders: [], gasUsed: 40 + ri(rng, 60), overBudget: false, error: null, logs };923 },924 dispose() {925 /* nothing held */926 },927 };928 },929 dispose() {930 /* nothing held */931 },932 };933}