clone

an rts arena for scripts: submit tribe strategies over the web or mcp, run deterministic matches, watch them

main
tribes / packages / engine / src / world.ts
world.ts 131.4K
1/**2 * Tribes v2 simulation engine. Deterministic, integer-only.3 *4 * Phase order per step() (documented in packages/engine/README.md):5 * 1 Actions -> policies (tribes ascending id, first ACTIONS_PER_TICK each)6 * 2 Territory & census (slow ticks)7 * 3 Labor apportionment (largest remainder)8 * 4 Extraction (+ tool wear)9 * 5 Refinement, crafts & construction10 * 6 Herds (slow ticks)11 * 7 Logistics: caravan/wagon movement every tick; routes & food12 * equalization on slow ticks13 * 8 Consumption & needs14 * 9 Population & morale (slow ticks)15 * 10 Military: warband movement, field battles, camps, raids, sieges16 * (sieges & supply on slow ticks)17 * 11 Threats: spawn/growth/raids (slow ticks)18 * 12 Trade, faith & tech (slow ticks)19 * 13 Elimination & end check20 * 14 Sightings, metrics frame (slow boundary), tick++21 *22 * PRNG is consumed only by mapgen (construction), threat placement (phase 11)23 * and battle variance (phases 10/11), each in fixed iteration order.24 * SimEvents are per-step narration, fog-filtered in viewFor(), NOT hashed.25 */26import type {27 Building,28 CaravanView,29 CraftTarget,30 CreateWorld,31 DecreeKey,32 EngineWorld,33 EnemySettlementSighting,34 EnemyWarbandSighting,35 MetricsFrame,36 Order,37 Posture,38 Profession,39 Resource,40 RiteKind,41 SettlementView,42 SimConfig,43 SimEvent,44 SimResult,45 Tech,46 ThreatKind,47 ThreatView,48 TradeOfferView,49 TribeMetrics,50 TribeResult,51 TribeView,52 WarbandKind,53 WarbandOrder,54 WarbandView,55} from '@tribes/protocol';56import { BUILDINGS, LIMITS, PROFESSIONS, RESOURCES, TECHS } from '@tribes/protocol';57import { apportion } from './apportion.js';58import * as C from './constants.js';59import { BI, PI, RI, TC } from './constants.js';60import { Hasher } from './hash.js';61import { generateMap } from './mapgen.js';62import { Rng } from './rng.js';6364const SLOW = LIMITS.SLOW_TICK_INTERVAL;65const R_N = C.R_N;66const P_N = C.P_N;67const B_N = C.B_N;6869const CRAFT_KEYS: readonly CraftTarget[] = ['tools', 'weapons', 'armor', 'mill', 'brew', 'fodder'];70const DECREE_KEYS: readonly DecreeKey[] = ['rations', 'slaughter', 'fodder', 'ale_ration', 'expansion'];71const THREAT_KINDS: readonly ThreatKind[] = [72 'goblin_camp',73 'skeleton_barrow',74 'ogre_den',75 'demon_gate',76];77const WB_KINDS: readonly WarbandKind[] = ['infantry', 'archers', 'cavalry'];78const RITE_KINDS: readonly RiteKind[] = ['blessing', 'harvest', 'warding', 'war'];7980/** Warband order type codes. */81const WO = { garrison: 0, patrol: 1, march: 2, raid: 3, besiege: 4, escort: 5, disband: 6 } as const;82const WO_NAMES = ['garrison', 'patrol', 'march', 'raid', 'besiege', 'escort', 'disband'] as const;8384/** Effect kind codes. */85const FX = { blessing: 0, harvest: 1, warding: 2, war: 3 } as const;8687/** Posture codes. */88const POSTURES: readonly Posture[] = ['ally', 'neutral', 'hostile'];89const P_ALLY = 0;90const P_HOSTILE = 2;9192/** Extraction/refinement accumulator slots (per settlement). */93const ACC = {94 FARM: 0,95 FISH: 1,96 WOOD: 2,97 STONE: 3,98 ORE: 4,99 COIN: 5,100 MILL: 6,101 BAKE: 7,102 BREW: 8,103 SMELT: 9,104 SMITH: 10,105 CARP: 11,106 TAN: 12,107 WEAVE: 13,108 TAILOR: 14,109 FOOD: 15,110 ALE: 16,111 CLOTHES: 17,112 TOOL_WEAR: 18,113 BIRTH: 19,114 DEATH: 20,115 EMIG: 21,116 HERD: 22,117 WOOL: 23,118 SLAUGHTER: 24,119 HORSE: 25,120 SMITH_W: 26,121 SMITH_A: 27,122} as const;123const ACC_N = 28;124125/** Interval-flag bits (per settlement, hashed; reset each slow tick). */126const FLAG_ALE_SHORT = 1;127const FLAG_CLOTHES_SHORT = 2;128const FLAG_STARVED = 4;129130/** Fixed raid-loot priority: valuables, then provisions, then materiel. */131const LOOT_ORDER: readonly number[] = [132 RI.coin,133 RI.bread,134 RI.ale,135 RI.grain,136 RI.fish,137 RI.meat,138 RI.tools,139 RI.weapons,140 RI.armor,141 RI.metal,142 RI.cloth,143 RI.clothes,144 RI.timber,145 RI.stone,146];147/** Food resources taken for warband supply / equalization, in order. */148const SUPPLY_ORDER: readonly number[] = [RI.grain, RI.fish, RI.meat, RI.bread];149/** Raid building-destruction priority (skip hall & walls). */150const DESTROY_ORDER: readonly number[] = BUILDINGS.map((_, i) => i).filter(151 (i) => i !== BI.hall && i !== BI.walls,152);153154const cheb = (ax: number, ay: number, bx: number, by: number): number =>155 Math.max(Math.abs(ax - bx), Math.abs(ay - by));156157// ---------------------------------------------------------------------------158// Internal state159// ---------------------------------------------------------------------------160161interface Census {162 plains: number;163 forest: number;164 hills: number;165 water: number;166 ore: number;167 gold: number;168 cold: boolean;169}170171interface Settlement {172 id: number;173 tribe: number;174 x: number;175 y: number;176 tier: number;177 pop: number;178 morale: number;179 radius: number;180 herds: number;181 horses: number;182 wallDamage: number;183 stores: Int32Array; // R_N184 buildings: Int32Array; // B_N (walls slot = wall level)185 queue: Array<{ b: number; progress: number; req: number }>;186 acc: Int32Array; // ACC_N187 kindsEaten: number; // bitmask over SATIATION order188 flags: number;189 allocOverride: Int32Array; // P_N, -1 = no override190 // Derived caches (recomputed deterministically; not hashed):191 census: Census;192 territoryTiles: number;193 workers: Int32Array; // P_N, last apportionment194 capacity: Int32Array; // P_N195 herderEff1000: number;196}197198interface Warband {199 id: number;200 tribe: number;201 home: number;202 size: number;203 kind: number; // WB_KINDS index204 weapons: number;205 armor: number;206 horses: number;207 vet: number;208 morale: number;209 x: number;210 y: number;211 supply: number;212 siege: boolean;213 /** Training ticks remaining; >0 = reduced power, raid/besiege refused. */214 drill: number;215 order: { type: number; targetId: number; tx: number; ty: number };216 desertAcc: number;217 // per-tick scratch:218 engaged: boolean;219 moved: boolean;220}221222interface Caravan {223 id: number;224 tribe: number;225 x: number;226 y: number;227 res: number;228 amount: number;229 destId: number; // settlement id230}231232interface Wagon {233 id: number; // shares the caravan id space234 tribe: number;235 x: number;236 y: number;237 tx: number;238 ty: number;239 settlers: number;240 supplies: Int32Array; // R_N241}242243interface Camp {244 id: number;245 kind: number; // THREAT_KINDS index246 x: number;247 y: number;248 strength: number;249 nextRaidAt: number;250}251252interface Effect {253 kind: number; // FX254 tribe: number;255 target: number; // settlement id / warband id / -1256 expiresAt: number;257}258259interface Offer {260 id: number;261 tribe: number;262 give: number;263 giveAmount: number;264 want: number;265 wantAmount: number;266 counterparty: number; // -1 = anyone267 repeat: boolean;268}269270interface Route {271 fromId: number;272 toId: number;273 res: number;274 permille: number;275}276277interface Tribe {278 id: number;279 alive: boolean;280 diedAt: number;281 /** Prestige age index into AGES (hashed state). */282 age: number;283 alloc: Int32Array; // P_N permille284 craft: Int32Array; // 6 (CRAFT_KEYS order)285 decrees: Int32Array; // 5 (DECREE_KEYS order)286 stances: Int8Array; // tribeCount, POSTURES index287 techMask: number;288 researching: number; // TECHS index or -1289 knowledge: number;290 faith: number;291 knowAcc: number;292 faithAcc: number;293 routes: Route[];294 explored: Uint8Array;295 exploredDirty: boolean;296 terrainCache: string | null;297 /** Category-glimpsed band beyond explored (persistent, hashed). */298 glimpsed: Uint8Array;299 glimpsedDirty: boolean;300 distantCache: string | null;301 claimsCache: string | null;302 claimsCacheVersion: number;303 // Counters (buildingsCompleted feeds score and is hashed; the rest are304 // metrics narration — cumulative unless noted).305 buildingsCompleted: number;306 battlesWon: number;307 battlesLost: number;308 casDealt: number;309 casTaken: number;310 campsCleared: number;311 tradeVolume: number;312 /** Coin EARNED (peddler, matched trades, gold veins) — gifts excluded.313 * The coin score term is capped by this, so gifted coin never scores. */314 coinEarned: number;315 caravansLost: number;316 // Interval flows (reset after each metrics frame):317 prodI: Int32Array; // R_N318 consI: Int32Array; // R_N319 birthsI: number;320 deathsI: number;321 starvI: number;322 // per-tick derived:323 coveredExtractors: number;324 totalExtractors: number;325}326327// ---------------------------------------------------------------------------328329class World implements EngineWorld {330 tick = 0;331 readonly config: SimConfig;332333 private readonly rng: Rng;334 private readonly size: number;335 private readonly terrain: Uint8Array;336 /** Claim map: settlement id per cell, -1 unclaimed. Derived (not hashed),337 * recomputed every slow tick by deterministic bucket Dijkstra. */338 private claims: Int16Array;339 /** Bumped on every claim recompute; invalidates per-tribe view caches. */340 private claimsVersion = 0;341 private readonly tribes: Tribe[] = [];342 private readonly settlements: Settlement[] = []; // ascending id343 private readonly warbands: Warband[] = [];344 private readonly caravans: Caravan[] = [];345 private readonly wagons: Wagon[] = [];346 private readonly camps: Camp[] = [];347 private effects: Effect[] = [];348 private readonly offers: Offer[] = []; // ascending id349350 private nextSettlementId = 1;351 private nextWarbandId = 1;352 private nextCaravanId = 1; // shared with wagons353 private nextCampId = 1;354 private nextOfferId = 1;355356 private over = false;357 private endReason: 'last_standing' | 'tick_cap' = 'tick_cap';358 private events: SimEvent[] = [];359 private readonly saysThisStep: number[];360 private readonly lastSighted: Int32Array;361 private metrics: MetricsFrame | null = null;362 private readonly scratchMask: Uint8Array;363364 constructor(config: SimConfig) {365 if (366 !Number.isInteger(config.tribeCount) ||367 config.tribeCount < LIMITS.MIN_TRIBES ||368 config.tribeCount > LIMITS.MAX_TRIBES369 ) {370 throw new Error(`tribeCount out of range: ${config.tribeCount}`);371 }372 if (!Number.isInteger(config.mapSize) || config.mapSize < 32 || config.mapSize > 256) {373 throw new Error(`mapSize out of range: ${config.mapSize}`);374 }375 if (!Number.isInteger(config.maxTicks) || config.maxTicks < 1) {376 throw new Error(`maxTicks out of range: ${config.maxTicks}`);377 }378 this.config = { ...config };379 this.size = config.mapSize;380 this.rng = new Rng(config.seed);381 this.saysThisStep = new Array<number>(config.tribeCount).fill(0);382 this.lastSighted = new Int32Array(config.tribeCount * config.tribeCount).fill(383 -C.SIGHT_COOLDOWN,384 );385 this.scratchMask = new Uint8Array(this.size * this.size);386387 const map = generateMap(this.size, config.tribeCount, this.rng);388 this.terrain = map.terrain;389 this.claims = new Int16Array(this.size * this.size).fill(-1);390391 for (let t = 0; t < config.tribeCount; t++) {392 const alloc = new Int32Array(P_N);393 const craft = Int32Array.from(C.DEFAULT_CRAFT);394 const decrees = Int32Array.from(C.DEFAULT_DECREES);395 const stances = new Int8Array(config.tribeCount).fill(1); // neutral396 this.tribes.push({397 id: t,398 alive: true,399 diedAt: -1,400 age: 0,401 alloc,402 craft,403 decrees,404 stances,405 techMask: 0,406 researching: -1,407 knowledge: 0,408 faith: 0,409 knowAcc: 0,410 faithAcc: 0,411 routes: [],412 explored: new Uint8Array(this.size * this.size),413 exploredDirty: true,414 glimpsed: new Uint8Array(this.size * this.size),415 glimpsedDirty: true,416 distantCache: null,417 claimsCache: null,418 claimsCacheVersion: -1,419 terrainCache: null,420 buildingsCompleted: 0,421 battlesWon: 0,422 battlesLost: 0,423 casDealt: 0,424 casTaken: 0,425 campsCleared: 0,426 tradeVolume: 0,427 coinEarned: 0,428 caravansLost: 0,429 prodI: new Int32Array(R_N),430 consI: new Int32Array(R_N),431 birthsI: 0,432 deathsI: 0,433 starvI: 0,434 coveredExtractors: 0,435 totalExtractors: 0,436 });437 }438 for (let t = 0; t < config.tribeCount; t++) {439 const sp = map.spawns[t]!;440 const s = this.addSettlement(t, sp.x, sp.y, LIMITS.START_POP);441 s.morale = C.START_MORALE;442 s.herds = C.START_HERDS;443 s.horses = C.START_HORSES;444 for (const [b, n] of Object.entries(C.START_BUILDINGS)) {445 s.buildings[C.BLD_IDX[b as Building]] = n;446 }447 for (const [r, n] of Object.entries(C.START_STORES)) {448 s.stores[C.RES_IDX[r as Resource]] = n;449 }450 }451 }452453 // -- small helpers --------------------------------------------------------454455 private inMap(x: number, y: number): boolean {456 return x >= 0 && y >= 0 && x < this.size && y < this.size;457 }458459 private terrainAt(x: number, y: number): number {460 return this.terrain[y * this.size + x]!;461 }462463 private passable(x: number, y: number): boolean {464 return this.inMap(x, y) && this.terrainAt(x, y) !== TC.WATER;465 }466467 private hasTech(tribe: number, tech: number): boolean {468 return tech >= 0 && (this.tribes[tribe]!.techMask & (1 << tech)) !== 0;469 }470471 private findSettlement(id: number): Settlement | undefined {472 for (const s of this.settlements) if (s.id === id) return s;473 return undefined;474 }475476 private findWarband(id: number): Warband | undefined {477 for (const w of this.warbands) if (w.id === id) return w;478 return undefined;479 }480481 private findCaravan(id: number): Caravan | undefined {482 for (const c of this.caravans) if (c.id === id) return c;483 return undefined;484 }485486 private settlementsOf(tribe: number): Settlement[] {487 return this.settlements.filter((s) => s.tribe === tribe);488 }489490 private nearestOwnSettlement(tribe: number, x: number, y: number): Settlement | null {491 let best: Settlement | null = null;492 let bestD = 0x7fffffff;493 for (const s of this.settlements) {494 if (s.tribe !== tribe) continue;495 const d = cheb(x, y, s.x, s.y);496 if (d < bestD) {497 best = s;498 bestD = d;499 }500 }501 return best;502 }503504 private storeCapOf(s: Settlement): number {505 return (506 C.BASE_STORE_CAP +507 s.buildings[BI.granary]! * C.GRANARY_CAP_BONUS +508 s.buildings[BI.warehouse]! * C.WAREHOUSE_CAP_BONUS509 );510 }511512 private addStore(s: Settlement, res: number, amount: number): number {513 if (amount <= 0) return 0;514 const cap = this.storeCapOf(s);515 const space = cap - s.stores[res]!;516 const added = Math.max(0, Math.min(space, amount));517 s.stores[res]! += added;518 return added;519 }520521 private prod(tribe: number, res: number, n: number): void {522 if (n > 0) this.tribes[tribe]!.prodI[res]! += n;523 }524525 private cons(tribe: number, res: number, n: number): void {526 if (n > 0) this.tribes[tribe]!.consI[res]! += n;527 }528529 private effectActive(kind: number, tribe: number, target: number): boolean {530 for (const e of this.effects) {531 if (e.kind === kind && e.tribe === tribe && e.target === target && e.expiresAt > this.tick) {532 return true;533 }534 }535 return false;536 }537538 /** Consume a one-battle war rite on this warband, returning its ‰ bonus. */539 private consumeWarRite(w: Warband): number {540 for (let i = 0; i < this.effects.length; i++) {541 const e = this.effects[i]!;542 if (e.kind === FX.war && e.tribe === w.tribe && e.target === w.id && e.expiresAt > this.tick) {543 this.effects.splice(i, 1);544 return C.WAR_RITE_POWER_BONUS;545 }546 }547 return 0;548 }549550 /**551 * Organic territory: multi-source bucket-Dijkstra from every settlement.552 * Cost per tile from CLAIM_COST (0 = impassable to claims: water, river,553 * volcano); budget by tier from TIER_CLAIM_BUDGET. Deterministic: seeds in554 * ascending settlement id (= founding order), FIFO within each cost bucket,555 * first claim wins. Derived state — recomputed every slow tick, not hashed.556 */557 private recomputeClaims(): void {558 const n = this.size * this.size;559 this.claims.fill(-1);560 const cost = new Int16Array(n).fill(32767);561 const maxBudget =562 C.TIER_CLAIM_BUDGET[C.TIER_CLAIM_BUDGET.length - 1]! +563 C.AGE_CLAIM_BONUS[C.AGE_CLAIM_BONUS.length - 1]! +564 C.POP_CLAIM_CAP;565 const buckets: number[][] = [];566 for (let i = 0; i <= maxBudget; i++) buckets.push([]);567 const budgetOf = new Map<number, number>();568 for (const st of this.settlements) {569 const i = st.y * this.size + st.x;570 this.claims[i] = st.id;571 cost[i] = 0;572 // Territory is a CHOICE: the base camp footprint is automatic, but573 // everything beyond it — tier, age, and population reach — is scaled574 // by the tribe's `expansion` decree. Nomads and isolationists stay575 // small on purpose; manifest destiny is a policy, not a side effect.576 const tribe = this.tribes[st.tribe]!;577 const popBonus = Math.min(578 C.POP_CLAIM_CAP,579 Math.trunc(Math.floor(Math.sqrt(st.pop)) / C.POP_CLAIM_DIV),580 );581 const base = C.TIER_CLAIM_BUDGET[0]!;582 const reach =583 C.TIER_CLAIM_BUDGET[st.tier]! - base + C.AGE_CLAIM_BONUS[tribe.age]! + popBonus;584 const expansion = tribe.decrees[4]!;585 budgetOf.set(st.id, base + Math.trunc((reach * expansion) / 1000));586 buckets[0]!.push(i);587 }588 for (let c = 0; c <= maxBudget; c++) {589 const bucket = buckets[c]!;590 for (let bi = 0; bi < bucket.length; bi++) {591 const i = bucket[bi]!;592 if (cost[i]! !== c) continue; // stale entry593 const owner = this.claims[i]!;594 const budget = budgetOf.get(owner) ?? 0;595 const x = i % this.size;596 const y = (i / this.size) | 0;597 // 4-neighbors in fixed order (N, W, E, S)598 for (let d = 0; d < 4; d++) {599 const nx = x + (d === 1 ? -1 : d === 2 ? 1 : 0);600 const ny = y + (d === 0 ? -1 : d === 3 ? 1 : 0);601 if (nx < 0 || ny < 0 || nx >= this.size || ny >= this.size) continue;602 const ni = ny * this.size + nx;603 const stepCost = C.CLAIM_COST[this.terrain[ni]!]!;604 if (stepCost === 0) continue; // impassable to claims605 const nc = c + stepCost;606 if (nc > budget) continue;607 if (nc < cost[ni]!) {608 cost[ni] = nc;609 this.claims[ni] = owner;610 buckets[nc]!.push(ni);611 }612 }613 }614 }615 // Census + territory size per settlement, derived from the claim map.616 const byId = new Map<number, Settlement>();617 for (const st of this.settlements) {618 st.census = { plains: 0, forest: 0, hills: 0, water: 0, ore: 0, gold: 0, cold: st.census.cold };619 st.territoryTiles = 0;620 byId.set(st.id, st);621 }622 for (let i = 0; i < n; i++) {623 const owner = this.claims[i]!;624 if (owner === -1) continue;625 const st = byId.get(owner);626 if (st === undefined) continue;627 st.territoryTiles++;628 const t = this.terrain[i]!;629 if (t === TC.PLAINS || t === TC.STEPPE) st.census.plains++;630 else if (t === TC.FOREST) st.census.forest++;631 else if (C.HILLS_CLASS.has(t)) {632 st.census.hills++;633 if (t === TC.ORE) st.census.ore++;634 else if (t === TC.GOLD) st.census.gold++;635 }636 }637 // Water access is adjacency-based (docks fish the shoreline, not claims).638 for (const st of this.settlements) {639 let water = 0;640 const r = st.radius;641 const x0 = Math.max(0, st.x - r);642 const x1 = Math.min(this.size - 1, st.x + r);643 const y0 = Math.max(0, st.y - r);644 const y1 = Math.min(this.size - 1, st.y + r);645 for (let y = y0; y <= y1; y++) {646 for (let x = x0; x <= x1; x++) {647 if (C.WATER_CLASS.has(this.terrainAt(x, y))) water++;648 }649 }650 st.census.water = water;651 let cold = false;652 for (let dy = -1; dy <= 1 && !cold; dy++) {653 for (let dx = -1; dx <= 1; dx++) {654 const x = st.x + dx;655 const y = st.y + dy;656 if (this.inMap(x, y) && C.COLD_CLASS.has(this.terrainAt(x, y))) {657 cold = true;658 break;659 }660 }661 }662 st.census.cold = cold;663 }664 this.claimsVersion++;665 }666667 private computeCapacity(s: Settlement): void {668 const b = s.buildings;669 const c = s.census;670 const cap = s.capacity;671 cap.fill(0);672 cap[PI.farmer] = Math.min(673 b[BI.farm]! * C.FARM_SLOTS + b[BI.hall]! * C.HALL_GARDEN_SLOTS,674 c.plains * C.FARMERS_PER_PLAINS,675 );676 cap[PI.fisher] = Math.min(b[BI.dock]! * C.DOCK_SLOTS, c.water * C.FISHERS_PER_WATER);677 cap[PI.herder] = b[BI.pasture]! * C.PASTURE_HERDER_SLOTS + C.HALL_HERDER_SLOTS;678 cap[PI.woodcutter] = Math.min(679 b[BI.lumber_camp]! * C.LUMBER_SLOTS + b[BI.hall]! * C.HALL_YARD_SLOTS,680 c.forest * C.WOODCUTTERS_PER_FOREST,681 );682 cap[PI.miner] = Math.min(b[BI.mine]! * C.MINE_SLOTS, c.hills * C.MINERS_PER_HILL);683 cap[PI.miller] = b[BI.mill]! * C.MILL_SLOTS;684 cap[PI.baker] = b[BI.bakery]! * C.BAKERY_SLOTS;685 cap[PI.brewer] = b[BI.brewery]! * C.BREWERY_SLOTS;686 cap[PI.smelter] = b[BI.smelter]! * C.SMELTER_SLOTS;687 cap[PI.smith] = b[BI.smithy]! * C.SMITHY_SLOTS;688 cap[PI.carpenter] =689 b[BI.lumber_camp]! * C.CARPENTER_PER_LUMBER + b[BI.hall]! * C.CARPENTER_PER_HALL;690 cap[PI.tanner] = b[BI.tannery]! * C.TANNERY_SLOTS;691 cap[PI.weaver] = b[BI.weavers_hall]! * C.WEAVER_SLOTS;692 cap[PI.tailor] = b[BI.tailor_shop]! * C.TAILOR_SLOTS;693 cap[PI.builder] = C.UNCAPPED_SLOTS;694 cap[PI.hauler] = C.UNCAPPED_SLOTS;695 cap[PI.scholar] = b[BI.library]! * C.LIBRARY_SLOTS + b[BI.academy]! * C.ACADEMY_SLOTS;696 cap[PI.priest] = b[BI.shrine]! * C.SHRINE_SLOTS + b[BI.church]! * C.CHURCH_SLOTS;697 }698699 private housingOf(s: Settlement): number {700 return (701 s.buildings[BI.hall]! * (C.HALL_HOUSING_BASE + C.HALL_HOUSING_PER_TIER * s.tier) +702 s.buildings[BI.house]! * C.HOUSE_HOUSING703 );704 }705706 private addSettlement(tribe: number, x: number, y: number, pop: number): Settlement {707 const s: Settlement = {708 id: this.nextSettlementId++,709 tribe,710 x,711 y,712 tier: 0,713 pop,714 morale: 500,715 radius: C.TIER_RADIUS[0],716 herds: 0,717 horses: 0,718 wallDamage: 0,719 stores: new Int32Array(R_N),720 buildings: new Int32Array(B_N),721 queue: [],722 acc: new Int32Array(ACC_N),723 kindsEaten: 0,724 flags: 0,725 allocOverride: new Int32Array(P_N).fill(-1),726 census: { plains: 0, forest: 0, hills: 0, water: 0, ore: 0, gold: 0, cold: false },727 territoryTiles: 0,728 workers: new Int32Array(P_N),729 capacity: new Int32Array(P_N),730 herderEff1000: 0,731 };732 s.buildings[BI.hall] = 1;733 this.settlements.push(s);734 this.updateTier(s);735 this.recomputeClaims();736 this.reveal(tribe, x, y, s.radius + C.SETTLEMENT_VISION_BONUS);737 return s;738 }739740 /** Tier never downgrades. Updates radius, census and vision on upgrade. */741 private updateTier(s: Settlement): void {742 let t = 0;743 for (let i = C.TIER_POP.length - 1; i >= 0; i--) {744 if (s.pop >= C.TIER_POP[i]!) {745 t = i;746 break;747 }748 }749 if (t > s.tier || s.radius === 0) {750 s.tier = Math.max(s.tier, t);751 const r = C.TIER_RADIUS[s.tier]!;752 if (r !== s.radius) {753 s.radius = r;754 this.recomputeClaims();755 this.reveal(s.tribe, s.x, s.y, r + C.SETTLEMENT_VISION_BONUS);756 }757 }758 }759760 private reveal(tribe: number, x: number, y: number, radius: number): void {761 const tr = this.tribes[tribe]!;762 const ex = tr.explored;763 const gl = tr.glimpsed;764 const s = this.size;765 const x0 = Math.max(0, x - radius);766 const x1 = Math.min(s - 1, x + radius);767 const y0 = Math.max(0, y - radius);768 const y1 = Math.min(s - 1, y + radius);769 for (let yy = y0; yy <= y1; yy++) {770 const row = yy * s;771 for (let xx = x0; xx <= x1; xx++) {772 if (ex[row + xx] === 0) {773 ex[row + xx] = 1;774 tr.exploredDirty = true;775 if (gl[row + xx] === 1) {776 // Promoted from glimpse to explored: distant string loses it.777 tr.glimpsedDirty = true;778 }779 }780 }781 }782 // Distant glimpse band: category-only knowledge past the vision edge.783 const gr = radius + C.DISTANT_VISION_BONUS;784 const gx0 = Math.max(0, x - gr);785 const gx1 = Math.min(s - 1, x + gr);786 const gy0 = Math.max(0, y - gr);787 const gy1 = Math.min(s - 1, y + gr);788 for (let yy = gy0; yy <= gy1; yy++) {789 const row = yy * s;790 for (let xx = gx0; xx <= gx1; xx++) {791 if (gl[row + xx] === 0) {792 gl[row + xx] = 1;793 tr.glimpsedDirty = true;794 }795 }796 }797 }798799 /** One greedy step toward (tx,ty), sliding around blockers (v1 technique). */800 private stepEntity(e: { x: number; y: number }, tx: number, ty: number): boolean {801 const dx = Math.sign(tx - e.x);802 const dy = Math.sign(ty - e.y);803 if (dx === 0 && dy === 0) return false;804 const tryStep = (sx: number, sy: number): boolean => {805 if (sx === 0 && sy === 0) return false;806 if (!this.passable(e.x + sx, e.y + sy)) return false;807 e.x += sx;808 e.y += sy;809 return true;810 };811 return (812 tryStep(dx, dy) ||813 tryStep(dx, 0) ||814 tryStep(0, dy) ||815 (dx !== 0 && dy !== 0 && (tryStep(dx, -dy) || tryStep(-dx, dy))) ||816 (dx === 0 && (tryStep(1, dy) || tryStep(-1, dy))) ||817 (dy === 0 && (tryStep(dx, 1) || tryStep(dx, -1)))818 );819 }820821 // -- step -----------------------------------------------------------------822823 /** order_dropped events emitted this tick, per tribe (capped). */824 private dropsThisStep: number[] = [];825826 /** Report a dropped order with its reason — the engine's answer to827 * silent-failure freezes. Capped so a runaway script cannot flood feeds. */828 private drop(tribe: Tribe, kind: string, reason: string): void {829 this.dropsThisStep[tribe.id] ??= 0;830 if (this.dropsThisStep[tribe.id]! >= 8) return;831 this.dropsThisStep[tribe.id]!++;832 this.events.push({ t: 'order_dropped', tick: this.tick, tribe: tribe.id, order: kind, reason });833 }834835 step(ordersByTribe: ReadonlyMap<number, Order[]>): void {836 if (this.over) return;837 this.events = [];838 this.dropsThisStep = [];839 this.saysThisStep.fill(0);840 const slow = (this.tick + 1) % SLOW === 0;841842 // 1. Actions.843 for (const tribe of this.tribes) {844 if (!tribe.alive) continue;845 const orders = ordersByTribe.get(tribe.id);846 if (!Array.isArray(orders)) continue;847 const n = Math.min(orders.length, LIMITS.ACTIONS_PER_TICK);848 for (let i = 0; i < n; i++) this.applyOrder(tribe, orders[i] as Order);849 }850851 // 2. Territory & census.852 if (slow) this.recomputeClaims();853854 // 3. Labor.855 this.phaseLabor();856857 // 4. Extraction.858 this.phaseExtraction();859860 // 5. Refinement & construction.861 this.phaseRefinement();862 this.phaseConstruction();863864 // 6. Herds.865 if (slow) this.phaseHerds();866867 // 7. Logistics.868 this.phaseLogisticsMove();869 if (slow) this.phaseRoutes();870871 // 8. Consumption.872 this.phaseConsumption();873874 // 9. Population & morale.875 if (slow) this.phasePopulation();876877 // 10. Military.878 this.phaseMilitary(slow);879880 // 11. Threats.881 if (slow) this.phaseThreats();882883 // 12. Trade, faith & tech.884 if (slow) this.phaseTradeFaithTech();885886 // 13. Elimination & end.887 for (const tribe of this.tribes) {888 if (!tribe.alive) continue;889 let hasS = false;890 for (const s of this.settlements) {891 if (s.tribe === tribe.id) {892 hasS = true;893 break;894 }895 }896 if (hasS) continue;897 let hasW = false;898 for (const w of this.warbands) {899 if (w.tribe === tribe.id) {900 hasW = true;901 break;902 }903 }904 if (!hasW) this.eliminate(tribe.id, 'eliminated');905 }906907 // 14. Sightings, metrics, advance.908 this.emitSightings();909 this.tick++;910 this.checkEnd();911 if (this.tick % SLOW === 0 || this.over) {912 this.metrics = this.computeMetrics();913 for (const t of this.tribes) {914 t.prodI.fill(0);915 t.consI.fill(0);916 t.birthsI = 0;917 t.deathsI = 0;918 t.starvI = 0;919 }920 }921 }922923 private checkEnd(): void {924 const alive = this.tribes.filter((t) => t.alive).length;925 if (alive <= 1) {926 this.over = true;927 this.endReason = 'last_standing';928 } else if (this.tick >= this.config.maxTicks) {929 this.over = true;930 this.endReason = 'tick_cap';931 }932 }933934 forfeit(tribeId: number): void {935 const tribe = this.tribes[tribeId];936 if (tribe === undefined || !tribe.alive || this.over) return;937 this.eliminate(tribeId, 'forfeited');938 this.checkEnd();939 if (this.over) this.metrics = this.computeMetrics();940 }941942 private eliminate(tribeId: number, cause: 'eliminated' | 'forfeited'): void {943 const tribe = this.tribes[tribeId]!;944 tribe.alive = false;945 tribe.diedAt = this.tick;946 this.events.push({ t: cause, tick: this.tick, tribe: tribeId });947 const drop = <T extends { tribe: number }>(arr: T[]): void => {948 for (let i = arr.length - 1; i >= 0; i--) if (arr[i]!.tribe === tribeId) arr.splice(i, 1);949 };950 drop(this.caravans);951 drop(this.wagons);952 drop(this.warbands);953 for (let i = this.settlements.length - 1; i >= 0; i--) {954 if (this.settlements[i]!.tribe === tribeId) this.settlements.splice(i, 1);955 }956 for (let i = this.offers.length - 1; i >= 0; i--) {957 if (this.offers[i]!.tribe === tribeId) this.offers.splice(i, 1);958 }959 this.effects = this.effects.filter((e) => e.tribe !== tribeId);960 tribe.routes.length = 0;961 }962963 // -- phase 1: actions -----------------------------------------------------964965 private applyOrder(tribe: Tribe, order: Order): void {966 if (order === null || typeof order !== 'object') return;967 switch (order.kind) {968 case 'allocate': {969 const p = C.PROF_IDX[order.profession as Profession] as number | undefined;970 if (p === undefined) return;971 const v = order.permille;972 if (!Number.isInteger(v) || v < 0 || v > 1000) return;973 if (order.settlementId === undefined) {974 tribe.alloc[p] = v;975 return;976 }977 const s = this.findSettlement(order.settlementId);978 if (s === undefined || s.tribe !== tribe.id) return;979 s.allocOverride[p] = v;980 return;981 }982 case 'craft': {983 const i = CRAFT_KEYS.indexOf(order.target as CraftTarget);984 if (i === -1) return;985 const v = order.permille;986 if (!Number.isInteger(v) || v < 0 || v > 1000) return;987 tribe.craft[i] = v;988 return;989 }990 case 'decree': {991 const i = DECREE_KEYS.indexOf(order.key as DecreeKey);992 if (i === -1) return;993 const v = order.value;994 const max = order.key === 'rations' ? 2000 : 1000;995 if (!Number.isInteger(v) || v < 0 || v > max) return;996 tribe.decrees[i] = v;997 return;998 }999 case 'build':1000 this.orderBuild(tribe, order);1001 return;1002 case 'demolish': {1003 const b = C.BLD_IDX[order.building as Building] as number | undefined;1004 if (b === undefined || b === BI.hall) return;1005 const s = this.findSettlement(order.settlementId);1006 if (s === undefined || s.tribe !== tribe.id || s.buildings[b]! <= 0) return;1007 s.buildings[b]!--;1008 return;1009 }1010 case 'found':1011 this.orderFound(tribe, order);1012 return;1013 case 'route': {1014 const res = C.RES_IDX[order.resource as Resource] as number | undefined;1015 if (res === undefined) return;1016 const v = order.permilleOfSurplus;1017 if (!Number.isInteger(v) || v < 0 || v > 1000) return;1018 const from = this.findSettlement(order.fromId);1019 const to = this.findSettlement(order.toId);1020 if (from === undefined || to === undefined) return;1021 if (from.tribe !== tribe.id || to.tribe !== tribe.id || from.id === to.id) return;1022 const existing = tribe.routes.findIndex(1023 (r) => r.fromId === order.fromId && r.toId === order.toId && r.res === res,1024 );1025 if (v === 0) {1026 if (existing !== -1) tribe.routes.splice(existing, 1);1027 return;1028 }1029 if (existing !== -1) {1030 tribe.routes[existing]!.permille = v;1031 return;1032 }1033 if (tribe.routes.length >= LIMITS.MAX_ROUTES) return;1034 tribe.routes.push({ fromId: order.fromId, toId: order.toId, res, permille: v });1035 return;1036 }1037 case 'muster':1038 this.orderMuster(tribe, order);1039 return;1040 case 'command':1041 this.orderCommand(tribe, order);1042 return;1043 case 'trade_offer': {1044 const give = C.RES_IDX[order.give as Resource] as number | undefined;1045 const want = C.RES_IDX[order.want as Resource] as number | undefined;1046 if (give === undefined || want === undefined || give === want) return;1047 const ga = order.giveAmount;1048 const wa = order.wantAmount;1049 if (!Number.isInteger(ga) || !Number.isInteger(wa) || ga < 1 || wa < 1) return;1050 if (ga > 1_000_000 || wa > 1_000_000) return;1051 let cp = -1;1052 if (order.tribe !== null) {1053 if (1054 !Number.isInteger(order.tribe) ||1055 order.tribe === tribe.id ||1056 order.tribe < 0 ||1057 order.tribe >= this.tribes.length1058 ) {1059 return;1060 }1061 cp = order.tribe;1062 }1063 let own = 0;1064 for (const o of this.offers) if (o.tribe === tribe.id) own++;1065 if (own >= LIMITS.MAX_OFFERS) return;1066 this.offers.push({1067 id: this.nextOfferId++,1068 tribe: tribe.id,1069 give,1070 giveAmount: ga,1071 want,1072 wantAmount: wa,1073 counterparty: cp,1074 repeat: order.repeat === true,1075 });1076 return;1077 }1078 case 'trade_cancel': {1079 const i = this.offers.findIndex((o) => o.id === order.offerId && o.tribe === tribe.id);1080 if (i !== -1) this.offers.splice(i, 1);1081 return;1082 }1083 case 'stance': {1084 const p = POSTURES.indexOf(order.posture as Posture);1085 if (p === -1) return;1086 const t = order.tribeId;1087 if (!Number.isInteger(t) || t === tribe.id || t < 0 || t >= this.tribes.length) return;1088 tribe.stances[t] = p;1089 return;1090 }1091 case 'research': {1092 const t = C.TECH_IDX[order.tech as Tech] as number | undefined;1093 if (t === undefined || this.hasTech(tribe.id, t)) return;1094 for (const pre of C.TECH_PREREQS[TECHS[t]!]!) {1095 if (!this.hasTech(tribe.id, C.TECH_IDX[pre])) return;1096 }1097 tribe.researching = t;1098 return;1099 }1100 case 'advance':1101 this.orderAdvance(tribe);1102 return;1103 case 'rite':1104 this.orderRite(tribe, order);1105 return;1106 case 'say': {1107 if (typeof order.text !== 'string' || order.text.length === 0) return;1108 if (this.saysThisStep[tribe.id]! >= LIMITS.SAYS_PER_TICK) return;1109 this.saysThisStep[tribe.id] = this.saysThisStep[tribe.id]! + 1;1110 this.events.push({1111 t: 'say',1112 tick: this.tick,1113 tribe: tribe.id,1114 text: order.text.slice(0, LIMITS.SAY_MAX_CHARS),1115 });1116 return;1117 }1118 default:1119 return;1120 }1121 }11221123 private orderBuild(tribe: Tribe, order: Extract<Order, { kind: 'build' }>): void {1124 const b = C.BLD_IDX[order.building as Building] as number | undefined;1125 if (b === undefined) return this.drop(tribe, 'build', `unknown building '${String(order.building)}'`);1126 const spec = C.BUILD_SPECS[order.building as Building];1127 if (spec.cost === null) return this.drop(tribe, 'build', 'halls cannot be built (found a settlement)');1128 const s = this.findSettlement(order.settlementId);1129 if (s === undefined || s.tribe !== tribe.id) return this.drop(tribe, 'build', 'settlementId is not one of your settlements');1130 let count = order.count === undefined ? 1 : order.count;1131 if (!Number.isInteger(count) || count < 1) return this.drop(tribe, 'build', 'count must be a positive integer');1132 count = Math.min(count, 5);1133 for (let k = 0; k < count; k++) {1134 if (s.queue.length >= C.BUILD_QUEUE_MAX) return this.drop(tribe, 'build', 'build queue is full');1135 let cost = spec.cost;1136 let points = spec.points;1137 let tech = spec.tech;1138 if (b === BI.walls) {1139 const queued = s.queue.filter((q) => q.b === BI.walls).length;1140 const target = s.buildings[BI.walls]! + queued + 1;1141 if (target > C.MAX_WALL_LEVEL) return this.drop(tribe, 'build', 'walls already at max level');1142 const lvl = C.WALL_LEVELS[target - 1]!;1143 cost = lvl.cost;1144 points = lvl.points;1145 tech = lvl.tech;1146 }1147 if (tech !== -1 && !this.hasTech(tribe.id, tech)) return this.drop(tribe, 'build', `${String(order.building)} needs the '${TECHS[tech]}' tech`);1148 if (b === BI.tower && s.buildings[BI.walls]! < 1) return this.drop(tribe, 'build', 'towers need walls first');1149 if (b === BI.farm && s.census.plains < 1) return this.drop(tribe, 'build', 'no claimed plains for a farm');1150 if (b === BI.dock && s.census.water < 1) return this.drop(tribe, 'build', 'no shoreline in reach for a dock');1151 if (b === BI.lumber_camp && s.census.forest < 1) return this.drop(tribe, 'build', 'no claimed forest for a lumber camp (grow claims or raise the expansion decree)');1152 if (b === BI.mine && s.census.hills < 1) return this.drop(tribe, 'build', 'no claimed hills for a mine (grow claims or raise the expansion decree)');1153 const timber = cost.timber ?? 0;1154 const planks = cost.planks ?? 0;1155 const stone = cost.stone ?? 0;1156 if (1157 s.stores[RI.timber]! < timber ||1158 s.stores[RI.planks]! < planks ||1159 s.stores[RI.stone]! < stone1160 ) {1161 return this.drop(tribe, 'build', `cannot afford ${String(order.building)} (needs ${timber} timber, ${planks} planks, ${stone} stone)`);1162 }1163 s.stores[RI.timber]! -= timber;1164 s.stores[RI.planks]! -= planks;1165 s.stores[RI.stone]! -= stone;1166 this.cons(tribe.id, RI.timber, timber);1167 this.cons(tribe.id, RI.planks, planks);1168 this.cons(tribe.id, RI.stone, stone);1169 s.queue.push({ b, progress: 0, req: points });1170 }1171 }11721173 private orderFound(tribe: Tribe, order: Extract<Order, { kind: 'found' }>): void {1174 const { x, y, settlers } = order;1175 if (!Number.isInteger(x) || !Number.isInteger(y) || !this.inMap(x, y)) {1176 return this.drop(tribe, 'found', 'target is outside the map');1177 }1178 if (!this.passable(x, y) || this.terrainAt(x, y) === TC.VOLCANO) {1179 return this.drop(tribe, 'found', 'target tile is not passable ground');1180 }1181 if (!Number.isInteger(settlers) || settlers < C.FOUND_MIN_SETTLERS) {1182 return this.drop(tribe, 'found', `settlers must be >= ${C.FOUND_MIN_SETTLERS}`);1183 }1184 for (const s of this.settlements) {1185 if (cheb(x, y, s.x, s.y) < C.MIN_SETTLE_DIST) {1186 return this.drop(tribe, 'found', `too close to an existing settlement (min ${C.MIN_SETTLE_DIST} apart)`);1187 }1188 }1189 // Source: own settlement with the largest pop (tie: lowest id).1190 let src: Settlement | null = null;1191 for (const s of this.settlements) {1192 if (s.tribe !== tribe.id) continue;1193 if (src === null || s.pop > src.pop) src = s;1194 }1195 if (src === null || src.pop < settlers + C.MIN_REMAINING_POP) {1196 return this.drop(tribe, 'found', `source settlement must keep ${C.MIN_REMAINING_POP} pop after the wagons leave`);1197 }1198 if (order.supplies === null || typeof order.supplies !== 'object') {1199 return this.drop(tribe, 'found', 'supplies must be an object of resource amounts');1200 }1201 const supplies = new Int32Array(R_N);1202 for (const [rName, amt] of Object.entries(order.supplies)) {1203 const r = C.RES_IDX[rName as Resource] as number | undefined;1204 if (r === undefined || !Number.isInteger(amt) || (amt as number) < 0) {1205 return this.drop(tribe, 'found', `bad supplies entry '${rName}'`);1206 }1207 supplies[r] = amt as number;1208 }1209 for (let r = 0; r < R_N; r++) {1210 if (src.stores[r]! < supplies[r]!) {1211 return this.drop(tribe, 'found', `cannot afford supplies (${RESOURCES[r]}: have ${src.stores[r]}, packing ${supplies[r]})`);1212 }1213 }1214 src.pop -= settlers;1215 for (let r = 0; r < R_N; r++) src.stores[r]! -= supplies[r]!;1216 this.wagons.push({1217 id: this.nextCaravanId++,1218 tribe: tribe.id,1219 x: src.x,1220 y: src.y,1221 tx: x,1222 ty: y,1223 settlers,1224 supplies,1225 });1226 }12271228 /** Prestige: burn the age hoard (drawn across settlements ascending id). */1229 private orderAdvance(tribe: Tribe): void {1230 if (tribe.age >= C.AGE_COSTS.length) return; // already at max age1231 const cost = C.AGE_COSTS[tribe.age]!;1232 const own = this.settlements.filter((s) => s.tribe === tribe.id);1233 // Affordability check over the aggregate.1234 for (const [resName, amount] of Object.entries(cost)) {1235 const ri = C.RES_IDX[resName as Resource];1236 let have = 0;1237 for (const s of own) have += s.stores[ri]!;1238 if (have < (amount as number)) {1239 return this.drop(tribe, 'advance', `age hoard short on ${resName} (have ${have}, need ${amount})`);1240 }1241 }1242 // Pay, drawing ascending settlement id.1243 for (const [resName, amount] of Object.entries(cost)) {1244 const ri = C.RES_IDX[resName as Resource];1245 let left = amount as number;1246 for (const s of own) {1247 if (left <= 0) break;1248 const take = Math.min(left, s.stores[ri]!);1249 s.stores[ri]! -= take;1250 left -= take;1251 }1252 this.cons(tribe.id, ri, amount as number);1253 }1254 tribe.age++;1255 this.events.push({ t: 'age_advanced', tick: this.tick, tribe: tribe.id, age: tribe.age });1256 }12571258 private orderMuster(tribe: Tribe, order: Extract<Order, { kind: 'muster' }>): void {1259 const kind = WB_KINDS.indexOf(order.unitKind as WarbandKind);1260 if (kind === -1) return this.drop(tribe, 'muster', `unknown unit kind '${String(order.unitKind)}'`);1261 const s = this.findSettlement(order.settlementId);1262 if (s === undefined || s.tribe !== tribe.id) {1263 return this.drop(tribe, 'muster', 'settlementId is not one of your settlements');1264 }1265 if (s.buildings[BI.barracks]! < 1) return this.drop(tribe, 'muster', 'needs a barracks');1266 const size = order.size;1267 if (!Number.isInteger(size) || size < C.MUSTER_MIN_SIZE) {1268 return this.drop(tribe, 'muster', `size must be >= ${C.MUSTER_MIN_SIZE}`);1269 }1270 if (s.pop < size + C.MIN_REMAINING_POP) {1271 return this.drop(tribe, 'muster', `settlement must keep ${C.MIN_REMAINING_POP} pop after mustering`);1272 }1273 if (s.stores[RI.weapons]! < size) {1274 return this.drop(tribe, 'muster', `needs ${size} weapons (have ${s.stores[RI.weapons]})`);1275 }1276 if (kind === 2) {1277 if (!this.hasTech(tribe.id, C.TECH_IDX.horsemanship)) {1278 return this.drop(tribe, 'muster', "cavalry needs the 'horsemanship' tech");1279 }1280 if (s.horses < size) return this.drop(tribe, 'muster', `needs ${size} horses (have ${s.horses})`);1281 }1282 s.pop -= size;1283 s.stores[RI.weapons]! -= size;1284 this.cons(tribe.id, RI.weapons, size);1285 const armor = Math.min(size, s.stores[RI.armor]!);1286 s.stores[RI.armor]! -= armor;1287 this.cons(tribe.id, RI.armor, armor);1288 let horses = 0;1289 if (kind === 2) {1290 horses = size;1291 s.horses -= size;1292 }1293 let siege = false;1294 if (1295 this.hasTech(tribe.id, C.TECH_IDX.siegecraft) &&1296 s.stores[RI.planks]! >= C.RAM_PLANKS &&1297 s.stores[RI.metal]! >= C.RAM_METAL1298 ) {1299 s.stores[RI.planks]! -= C.RAM_PLANKS;1300 s.stores[RI.metal]! -= C.RAM_METAL;1301 this.cons(tribe.id, RI.planks, C.RAM_PLANKS);1302 this.cons(tribe.id, RI.metal, C.RAM_METAL);1303 siege = true;1304 }1305 const w: Warband = {1306 id: this.nextWarbandId++,1307 tribe: tribe.id,1308 home: s.id,1309 size,1310 kind,1311 weapons: size,1312 armor,1313 horses,1314 vet: 0,1315 morale: C.STARTING_WARBAND_MORALE,1316 x: s.x,1317 y: s.y,1318 supply: 0,1319 siege,1320 drill: C.MUSTER_DRILL_TICKS,1321 order: { type: WO.garrison, targetId: s.id, tx: s.x, ty: s.y },1322 desertAcc: 0,1323 engaged: false,1324 moved: false,1325 };1326 w.supply = this.takeFood(s, size * C.SUPPLY_CAP_PER_SOLDIER);1327 this.warbands.push(w);1328 }13291330 /** Take up to `amount` food units (grain/fish/meat/bread) from stores. */1331 private takeFood(s: Settlement, amount: number): number {1332 let left = amount;1333 for (const r of SUPPLY_ORDER) {1334 if (left <= 0) break;1335 const take = Math.min(left, s.stores[r]!);1336 s.stores[r]! -= take;1337 this.cons(s.tribe, r, take);1338 left -= take;1339 }1340 return amount - left;1341 }13421343 private orderCommand(tribe: Tribe, order: Extract<Order, { kind: 'command' }>): void {1344 const w = this.findWarband(order.warbandId);1345 if (w === undefined || w.tribe !== tribe.id) return;1346 const o = order.order as WarbandOrder;1347 if (o === null || typeof o !== 'object') return;1348 const type = WO[o.type as keyof typeof WO] as number | undefined;1349 if (type === undefined) return;1350 const tgt = o.target;1351 switch (type) {1352 case WO.march:1353 case WO.patrol: {1354 if (tgt === null || typeof tgt !== 'object') return;1355 const { x, y } = tgt;1356 if (!Number.isInteger(x) || !Number.isInteger(y) || !this.inMap(x, y)) return;1357 w.order = { type, targetId: -1, tx: x, ty: y };1358 return;1359 }1360 case WO.garrison: {1361 if (typeof tgt !== 'number') return;1362 const s = this.findSettlement(tgt);1363 if (s === undefined || s.tribe !== tribe.id) return;1364 w.order = { type, targetId: s.id, tx: s.x, ty: s.y };1365 return;1366 }1367 case WO.besiege: {1368 if (typeof tgt !== 'number') return;1369 if (w.drill > 0) return; // green troops can't campaign yet1370 const s = this.findSettlement(tgt);1371 if (s === undefined || s.tribe === tribe.id) return;1372 if (tribe.stances[s.tribe] === P_ALLY) return;1373 w.order = { type, targetId: s.id, tx: s.x, ty: s.y };1374 return;1375 }1376 case WO.raid: {1377 if (typeof tgt !== 'number') return;1378 if (w.drill > 0) return; // green troops can't campaign yet1379 const s = this.findSettlement(tgt);1380 if (s !== undefined) {1381 if (s.tribe === tribe.id || tribe.stances[s.tribe] === P_ALLY) return;1382 w.order = { type, targetId: s.id, tx: s.x, ty: s.y };1383 return;1384 }1385 const c = this.findCaravan(tgt);1386 if (c === undefined || c.tribe === tribe.id) return;1387 if (tribe.stances[c.tribe] === P_ALLY) return;1388 w.order = { type, targetId: c.id, tx: c.x, ty: c.y };1389 return;1390 }1391 case WO.escort: {1392 if (typeof tgt !== 'number') return;1393 const c = this.findCaravan(tgt);1394 if (c === undefined || c.tribe !== tribe.id) return;1395 w.order = { type, targetId: c.id, tx: c.x, ty: c.y };1396 return;1397 }1398 case WO.disband: {1399 if (tgt !== null) return;1400 w.order = { type, targetId: -1, tx: w.x, ty: w.y };1401 return;1402 }1403 default:1404 return;1405 }1406 }14071408 private orderRite(tribe: Tribe, order: Extract<Order, { kind: 'rite' }>): void {1409 const kind = RITE_KINDS.indexOf(order.rite as RiteKind);1410 if (kind === -1) return;1411 const cost = C.RITE_COSTS[order.rite as RiteKind];1412 if (tribe.faith < cost) return;1413 let target = -1;1414 let expiresAt: number;1415 if (kind === FX.blessing || kind === FX.harvest) {1416 if (typeof order.target !== 'number') return;1417 const s = this.findSettlement(order.target);1418 if (s === undefined || s.tribe !== tribe.id) return;1419 target = s.id;1420 expiresAt =1421 this.tick + (kind === FX.blessing ? C.RITE_DURATION.blessing : C.RITE_DURATION.harvest);1422 } else if (kind === FX.warding) {1423 if (order.target !== null) return;1424 expiresAt = this.tick + C.RITE_DURATION.warding;1425 } else {1426 // war: one battle, or 200 ticks, whichever first1427 if (typeof order.target !== 'number') return;1428 const w = this.findWarband(order.target);1429 if (w === undefined || w.tribe !== tribe.id) return;1430 target = w.id;1431 expiresAt = this.tick + 200;1432 }1433 tribe.faith -= cost;1434 this.effects.push({ kind, tribe: tribe.id, target, expiresAt });1435 }14361437 // -- phase 3: labor -------------------------------------------------------14381439 private phaseLabor(): void {1440 for (const s of this.settlements) this.computeCapacity(s);1441 for (const tribe of this.tribes) {1442 if (!tribe.alive) continue;1443 const own = this.settlementsOf(tribe.id);1444 if (own.length === 0) continue;1445 const poolS: Settlement[] = [];1446 for (const s of own) {1447 s.workers.fill(0);1448 let hasOverride = false;1449 for (let p = 0; p < P_N; p++) {1450 if (s.allocOverride[p]! >= 0) {1451 hasOverride = true;1452 break;1453 }1454 }1455 if (hasOverride) this.laborLocal(tribe, s);1456 else poolS.push(s);1457 }1458 if (poolS.length === 0) continue;1459 // Tribe-pool: apportion total pop across professions (+idle), then each1460 // profession across settlements by capacity (largest remainder, ties1461 // ascending settlement id = array order).1462 let popTotal = 0;1463 for (const s of poolS) popTotal += s.pop;1464 const weights: number[] = new Array(P_N + 1);1465 let sum = 0;1466 for (let p = 0; p < P_N; p++) {1467 weights[p] = tribe.alloc[p]!;1468 sum += tribe.alloc[p]!;1469 }1470 weights[P_N] = Math.max(0, 1000 - sum);1471 const totals = apportion(popTotal, weights);1472 const remaining = poolS.map((s) => s.pop);1473 const caps = new Array<number>(poolS.length);1474 for (let p = 0; p < P_N; p++) {1475 const total = totals[p]!;1476 if (total === 0) continue;1477 for (let i = 0; i < poolS.length; i++) caps[i] = poolS[i]!.capacity[p]!;1478 const shares = apportion(total, caps);1479 for (let i = 0; i < poolS.length; i++) {1480 const s = poolS[i]!;1481 const wk = Math.min(shares[i]!, s.capacity[p]!, remaining[i]!);1482 s.workers[p] = wk;1483 remaining[i]! -= wk;1484 }1485 }1486 }1487 }14881489 /** Per-settlement scheme for settlements with any allocation override. */1490 private laborLocal(tribe: Tribe, s: Settlement): void {1491 const weights: number[] = new Array(P_N + 1);1492 let sum = 0;1493 for (let p = 0; p < P_N; p++) {1494 const v = s.allocOverride[p]! >= 0 ? s.allocOverride[p]! : tribe.alloc[p]!;1495 weights[p] = v;1496 sum += v;1497 }1498 weights[P_N] = Math.max(0, 1000 - sum);1499 const totals = apportion(s.pop, weights);1500 for (let p = 0; p < P_N; p++) {1501 s.workers[p] = Math.min(totals[p]!, s.capacity[p]!);1502 }1503 }15041505 // -- phase 4: extraction --------------------------------------------------15061507 /**1508 * Fixed-point flows use out = (effWorkers1000 * rate + acc) div 1_000_0001509 * where effWorkers1000 = covered*1000 + uncovered*NO_TOOL_YIELD (the1510 * documented 1e6-denominator variant of the accumulator rule).1511 */1512 private flow(s: Settlement, slot: number, effW1000: number, rate: number): number {1513 // Age bonus: advanced civilizations extract and refine faster.1514 const aged = Math.trunc((rate * (1000 + C.AGE_RATE_BONUS[this.tribes[s.tribe]!.age]!)) / 1000);1515 const total = effW1000 * aged + s.acc[slot]!;1516 const out = Math.trunc(total / 1_000_000);1517 s.acc[slot] = total - out * 1_000_000;1518 return out;1519 }15201521 private phaseExtraction(): void {1522 for (const t of this.tribes) {1523 t.coveredExtractors = 0;1524 t.totalExtractors = 0;1525 }1526 for (const s of this.settlements) {1527 const tribe = this.tribes[s.tribe]!;1528 const wk = s.workers;1529 const extractive =1530 wk[PI.farmer]! + wk[PI.fisher]! + wk[PI.herder]! + wk[PI.woodcutter]! + wk[PI.miner]!;1531 let coverLeft = Math.min(s.stores[RI.tools]!, extractive);1532 const covered = coverLeft;1533 tribe.coveredExtractors += covered;1534 tribe.totalExtractors += extractive;1535 const eff = (p: number): number => {1536 const w = wk[p]!;1537 const cov = Math.min(w, coverLeft);1538 coverLeft -= cov;1539 return cov * 1000 + (w - cov) * C.NO_TOOL_YIELD;1540 };1541 // Coverage assignment in profession enum order.1542 const farmerEff = eff(PI.farmer);1543 const fisherEff = eff(PI.fisher);1544 s.herderEff1000 = eff(PI.herder);1545 const woodEff = eff(PI.woodcutter);1546 const minerEff = eff(PI.miner);15471548 let farmRate = C.FARMER_RATE;1549 let bonus = 0;1550 if (this.hasTech(s.tribe, C.TECH_IDX.crop_rotation)) bonus += C.CROP_ROTATION_BONUS;1551 if (this.effectActive(FX.harvest, s.tribe, s.id)) bonus += C.HARVEST_RITE_BONUS;1552 if (bonus > 0) farmRate = Math.trunc((farmRate * (1000 + bonus)) / 1000);15531554 const outputs: Array<[number, number]> = [1555 [RI.grain, this.flow(s, ACC.FARM, farmerEff, farmRate)],1556 [RI.fish, this.flow(s, ACC.FISH, fisherEff, C.FISHER_RATE)],1557 [RI.timber, this.flow(s, ACC.WOOD, woodEff, C.WOODCUTTER_RATE)],1558 [RI.stone, this.flow(s, ACC.STONE, minerEff, C.MINER_STONE_RATE)],1559 [1560 RI.ore,1561 s.census.ore > 01562 ? this.flow(1563 s,1564 ACC.ORE,1565 minerEff,1566 C.MINER_ORE_RATE + C.MINER_ORE_RATE_PER_EXTRA_VEIN * Math.min(s.census.ore - 1, 3),1567 )1568 : 0,1569 ],1570 [RI.coin, s.census.gold > 0 ? this.flow(s, ACC.COIN, minerEff, C.MINER_COIN_RATE) : 0],1571 ];1572 for (const [res, n] of outputs) {1573 if (n > 0) this.prod(s.tribe, res, this.addStore(s, res, n));1574 }15751576 // Tool wear: 1 per TOOL_WEAR_TICKS covered worker-ticks.1577 const wearTotal = covered + s.acc[ACC.TOOL_WEAR]!;1578 const wear = Math.trunc(wearTotal / C.TOOL_WEAR_TICKS);1579 s.acc[ACC.TOOL_WEAR] = wearTotal - wear * C.TOOL_WEAR_TICKS;1580 if (wear > 0) {1581 const worn = Math.min(wear, s.stores[RI.tools]!);1582 s.stores[RI.tools]! -= worn;1583 this.cons(s.tribe, RI.tools, worn);1584 }1585 }1586 }15871588 // -- phase 5: refinement & construction -----------------------------------15891590 /** potential-limited output with input caps; starved capacity is discarded1591 * (accumulator clamped below 1000 worker-permille). */1592 private refine(s: Settlement, slot: number, workers: number, rate: number, maxIn: number): number {1593 const total = workers * rate + s.acc[slot]!;1594 const byLabor = Math.trunc(total / 1000);1595 const out = Math.max(0, Math.min(byLabor, maxIn));1596 s.acc[slot] = Math.min(total - out * 1000, 999);1597 return out;1598 }15991600 private phaseRefinement(): void {1601 for (const s of this.settlements) {1602 const tribe = this.tribes[s.tribe]!;1603 const wk = s.workers;1604 const st = s.stores;16051606 // Grain gates (of current stock, per the craft split).1607 const grainMill = Math.trunc((st[RI.grain]! * tribe.craft[3]!) / 1000);1608 const grainBrew = Math.trunc((st[RI.grain]! * tribe.craft[4]!) / 1000);16091610 // miller: grain -> flour 1:11611 let out = this.refine(s, ACC.MILL, wk[PI.miller]!, C.MILLER_RATE, grainMill);1612 if (out > 0) {1613 st[RI.grain]! -= out;1614 this.cons(s.tribe, RI.grain, out);1615 this.prod(s.tribe, RI.flour, this.addStore(s, RI.flour, out));1616 }1617 // baker: flour -> bread 1:11618 out = this.refine(s, ACC.BAKE, wk[PI.baker]!, C.BAKER_RATE, st[RI.flour]!);1619 if (out > 0) {1620 st[RI.flour]! -= out;1621 this.cons(s.tribe, RI.flour, out);1622 this.prod(s.tribe, RI.bread, this.addStore(s, RI.bread, out));1623 }1624 // brewer: 2 grain -> 1 ale1625 out = this.refine(1626 s,1627 ACC.BREW,1628 wk[PI.brewer]!,1629 C.BREWER_RATE,1630 Math.trunc(grainBrew / C.ALE_GRAIN_COST),1631 );1632 if (out > 0) {1633 st[RI.grain]! -= out * C.ALE_GRAIN_COST;1634 this.cons(s.tribe, RI.grain, out * C.ALE_GRAIN_COST);1635 this.prod(s.tribe, RI.ale, this.addStore(s, RI.ale, out));1636 }1637 // smelter: 2 ore + 1 timber -> 1 metal1638 out = this.refine(1639 s,1640 ACC.SMELT,1641 wk[PI.smelter]!,1642 C.SMELTER_RATE,1643 Math.min(Math.trunc(st[RI.ore]! / C.METAL_ORE_COST), st[RI.timber]!),1644 );1645 if (out > 0) {1646 st[RI.ore]! -= out * C.METAL_ORE_COST;1647 st[RI.timber]! -= out * C.METAL_TIMBER_COST;1648 this.cons(s.tribe, RI.ore, out * C.METAL_ORE_COST);1649 this.cons(s.tribe, RI.timber, out * C.METAL_TIMBER_COST);1650 this.prod(s.tribe, RI.metal, this.addStore(s, RI.metal, out));1651 }1652 // smith: 1 metal + 1 timber -> tool/weapon; armor additionally 1 leather.1653 out = this.refine(1654 s,1655 ACC.SMITH,1656 wk[PI.smith]!,1657 C.SMITH_RATE,1658 Math.min(st[RI.metal]!, st[RI.timber]!),1659 );1660 if (out > 0) {1661 const wWeapons = this.hasTech(s.tribe, C.TECH_IDX.weaponsmithing) ? tribe.craft[1]! : 0;1662 const wArmor = this.hasTech(s.tribe, C.TECH_IDX.plate_armor) ? tribe.craft[2]! : 0;1663 // Fixed-point split, NOT per-tick apportionment: with 0-2 units per1664 // tick, largest-remainder hands every unit to the heaviest target1665 // and a 400-permille weapons split never sees a single one. The1666 // accumulators make the split hold in the long run exactly.1667 const wTools = tribe.craft[0]!;1668 const totalW = wTools + wWeapons + wArmor;1669 let split: number[];1670 if (totalW === 0) {1671 split = [out, 0, 0];1672 } else {1673 const wTot = out * wWeapons + s.acc[ACC.SMITH_W]!;1674 const w = Math.trunc(wTot / totalW);1675 s.acc[ACC.SMITH_W] = wTot - w * totalW;1676 const aTot = out * wArmor + s.acc[ACC.SMITH_A]!;1677 const a = Math.trunc(aTot / totalW);1678 s.acc[ACC.SMITH_A] = aTot - a * totalW;1679 split = [out - w - a, w, a];1680 }1681 const armorMade = Math.min(split[2]!, st[RI.leather]!);1682 const made: Array<[number, number]> = [1683 [RI.tools, split[0]!],1684 [RI.weapons, split[1]!],1685 [RI.armor, armorMade],1686 ];1687 for (const [res, n] of made) {1688 if (n <= 0) continue;1689 st[RI.metal]! -= n;1690 st[RI.timber]! -= n;1691 this.cons(s.tribe, RI.metal, n);1692 this.cons(s.tribe, RI.timber, n);1693 if (res === RI.armor) {1694 st[RI.leather]! -= n;1695 this.cons(s.tribe, RI.leather, n);1696 }1697 this.prod(s.tribe, res, this.addStore(s, res, n));1698 }1699 }1700 // carpenter: 1 timber -> 1 plank (idle at the planks soft cap — they1701 // must not starve construction of raw timber)1702 const plankRoom = Math.max(0, C.PLANKS_SOFT_CAP - st[RI.planks]!);1703 out = this.refine(s, ACC.CARP, wk[PI.carpenter]!, C.CARPENTER_RATE, Math.min(st[RI.timber]!, plankRoom));1704 if (out > 0) {1705 st[RI.timber]! -= out;1706 this.cons(s.tribe, RI.timber, out);1707 this.prod(s.tribe, RI.planks, this.addStore(s, RI.planks, out));1708 }1709 // tanner: 1 hide -> 1 leather1710 out = this.refine(s, ACC.TAN, wk[PI.tanner]!, C.TANNER_RATE, st[RI.hides]!);1711 if (out > 0) {1712 st[RI.hides]! -= out;1713 this.cons(s.tribe, RI.hides, out);1714 this.prod(s.tribe, RI.leather, this.addStore(s, RI.leather, out));1715 }1716 // weaver: 2 wool -> 1 cloth1717 out = this.refine(1718 s,1719 ACC.WEAVE,1720 wk[PI.weaver]!,1721 C.WEAVER_RATE,1722 Math.trunc(st[RI.wool]! / C.CLOTH_WOOL_COST),1723 );1724 if (out > 0) {1725 st[RI.wool]! -= out * C.CLOTH_WOOL_COST;1726 this.cons(s.tribe, RI.wool, out * C.CLOTH_WOOL_COST);1727 this.prod(s.tribe, RI.cloth, this.addStore(s, RI.cloth, out));1728 }1729 // tailor: 1 cloth + 1 leather -> 1 clothes1730 out = this.refine(1731 s,1732 ACC.TAILOR,1733 wk[PI.tailor]!,1734 C.TAILOR_RATE,1735 Math.min(st[RI.cloth]!, st[RI.leather]!),1736 );1737 if (out > 0) {1738 st[RI.cloth]! -= out;1739 st[RI.leather]! -= out;1740 this.cons(s.tribe, RI.cloth, out);1741 this.cons(s.tribe, RI.leather, out);1742 this.prod(s.tribe, RI.clothes, this.addStore(s, RI.clothes, out));1743 }1744 }1745 }17461747 private phaseConstruction(): void {1748 for (const s of this.settlements) {1749 if (s.queue.length === 0) continue;1750 const builders = s.workers[PI.builder]!;1751 if (builders === 0) continue;1752 const active = Math.min(s.queue.length, Math.max(1, Math.trunc(builders / C.BUILDERS_PER_SITE)));1753 const shares = apportion(builders, new Array<number>(active).fill(1));1754 let completedAny = false;1755 for (let k = 0; k < active; k++) {1756 const site = s.queue[k]!;1757 site.progress += shares[k]!;1758 if (site.progress >= site.req) completedAny = true;1759 }1760 if (!completedAny) continue;1761 for (let k = s.queue.length - 1; k >= 0; k--) {1762 const site = s.queue[k]!;1763 if (site.progress < site.req) continue;1764 s.queue.splice(k, 1);1765 s.buildings[site.b]!++;1766 this.tribes[s.tribe]!.buildingsCompleted++;1767 this.events.push({1768 t: 'building_complete',1769 tick: this.tick,1770 tribe: s.tribe,1771 building: BUILDINGS[site.b]!,1772 settlementId: s.id,1773 x: s.x,1774 y: s.y,1775 });1776 }1777 }1778 }17791780 // -- phase 6: herds -------------------------------------------------------17811782 private phaseHerds(): void {1783 for (const s of this.settlements) {1784 const tribe = this.tribes[s.tribe]!;1785 const pastures = s.buildings[BI.pasture]!;1786 const capHerds = pastures * C.HERDS_PER_PASTURE + C.HALL_HERD_SLOTS;1787 const capHorses = pastures * C.HORSES_PER_PASTURE;1788 const tended = Math.min(1789 s.herds,1790 Math.trunc((s.herderEff1000 * C.HERDS_PER_HERDER) / 1000),1791 );1792 // Fodder: decree ‰ of herds, gated by the grain craft split.1793 const fodderGate = Math.trunc((s.stores[RI.grain]! * tribe.craft[5]!) / 1000);1794 const fedTarget = Math.min(tended, Math.trunc((s.herds * tribe.decrees[2]!) / 1000));1795 const fed = Math.min(fedTarget, Math.trunc(fodderGate / C.FODDER_GRAIN_PER_HERD));1796 if (fed > 0) {1797 const grain = fed * C.FODDER_GRAIN_PER_HERD;1798 s.stores[RI.grain]! -= grain;1799 this.cons(s.tribe, RI.grain, grain);1800 }1801 // Growth (fodder-fed herds grow at double rate), capped by pasture.1802 const growTotal = (tended - fed) * C.HERD_GROWTH + fed * C.HERD_GROWTH * 2 + s.acc[ACC.HERD]!;1803 const grown = Math.trunc(growTotal / 1000);1804 s.acc[ACC.HERD] = growTotal - grown * 1000;1805 // Pasture cap; herds above cap (lost pastures) cull immediately.1806 s.herds = Math.max(0, Math.min(capHerds, s.herds + grown));1807 // Wool.1808 const woolTotal = tended * C.WOOL_PER_HERD + s.acc[ACC.WOOL]!;1809 const wool = Math.trunc(woolTotal / 1000);1810 s.acc[ACC.WOOL] = woolTotal - wool * 1000;1811 if (wool > 0) this.prod(s.tribe, RI.wool, this.addStore(s, RI.wool, wool));1812 // Slaughter.1813 const slTotal = s.herds * tribe.decrees[1]! + s.acc[ACC.SLAUGHTER]!;1814 let sl = Math.trunc(slTotal / 1000);1815 s.acc[ACC.SLAUGHTER] = slTotal - sl * 1000;1816 sl = Math.min(sl, s.herds);1817 if (sl > 0) {1818 s.herds -= sl;1819 this.prod(s.tribe, RI.meat, this.addStore(s, RI.meat, sl * C.MEAT_PER_SLAUGHTER));1820 this.prod(s.tribe, RI.hides, this.addStore(s, RI.hides, sl * C.HIDES_PER_SLAUGHTER));1821 }1822 // Horses (pasture-capped separately).1823 const hTotal = s.horses * C.HORSE_GROWTH + s.acc[ACC.HORSE]!;1824 const h = Math.trunc(hTotal / 1000);1825 s.acc[ACC.HORSE] = hTotal - h * 1000;1826 s.horses = Math.min(capHorses, s.horses + h);1827 if (s.horses < 0) s.horses = 0;1828 }1829 }18301831 // -- phase 7: logistics ---------------------------------------------------18321833 private phaseLogisticsMove(): void {1834 // Caravans (ascending id).1835 for (let i = 0; i < this.caravans.length; i++) {1836 const c = this.caravans[i]!;1837 // Deliveries land at the destination settlement even if it changed1838 // hands en route (the captor gets the goods).1839 let dest = this.findSettlement(c.destId);1840 if (dest === undefined) {1841 const near = this.nearestOwnSettlement(c.tribe, c.x, c.y);1842 if (near === null) {1843 this.caravans.splice(i, 1);1844 i--;1845 continue;1846 }1847 c.destId = near.id;1848 dest = near;1849 }1850 if (c.x === dest.x && c.y === dest.y) {1851 this.addStore(dest, c.res, c.amount); // overflow above cap is lost1852 this.caravans.splice(i, 1);1853 i--;1854 continue;1855 }1856 this.stepEntity(c, dest.x, dest.y);1857 this.reveal(c.tribe, c.x, c.y, C.UNIT_VISION);1858 if (c.x === dest.x && c.y === dest.y) {1859 this.addStore(dest, c.res, c.amount);1860 this.caravans.splice(i, 1);1861 i--;1862 }1863 }1864 // Settler wagons (ascending id).1865 for (let i = 0; i < this.wagons.length; i++) {1866 const wg = this.wagons[i]!;1867 if (wg.x !== wg.tx || wg.y !== wg.ty) {1868 this.stepEntity(wg, wg.tx, wg.ty);1869 this.reveal(wg.tribe, wg.x, wg.y, C.UNIT_VISION);1870 }1871 if (wg.x !== wg.tx || wg.y !== wg.ty) continue;1872 // Arrived: found if the site is still clear, else merge into own town.1873 let blocked = false;1874 for (const s of this.settlements) {1875 if (cheb(wg.x, wg.y, s.x, s.y) < C.MIN_SETTLE_DIST) {1876 blocked = true;1877 break;1878 }1879 }1880 if (!blocked) {1881 const s = this.addSettlement(wg.tribe, wg.x, wg.y, wg.settlers);1882 for (let r = 0; r < R_N; r++) {1883 if (wg.supplies[r]! > 0) this.addStore(s, r, wg.supplies[r]!);1884 }1885 this.events.push({1886 t: 'settlement_founded',1887 tick: this.tick,1888 tribe: wg.tribe,1889 settlementId: s.id,1890 x: wg.x,1891 y: wg.y,1892 });1893 } else {1894 const near = this.nearestOwnSettlement(wg.tribe, wg.x, wg.y);1895 if (near !== null) {1896 near.pop += wg.settlers;1897 for (let r = 0; r < R_N; r++) {1898 if (wg.supplies[r]! > 0) this.addStore(near, r, wg.supplies[r]!);1899 }1900 }1901 // No own settlement: the wagon is lost.1902 }1903 this.wagons.splice(i, 1);1904 i--;1905 }1906 }19071908 private phaseRoutes(): void {1909 for (const tribe of this.tribes) {1910 if (!tribe.alive) continue;1911 const own = this.settlementsOf(tribe.id);1912 if (own.length === 0) continue;1913 // Hauler budget per settlement for this slow tick.1914 const budget = new Map<number, number>();1915 let anyHauler = false;1916 for (const s of own) {1917 const b = s.workers[PI.hauler]! * C.HAULER_THROUGHPUT;1918 budget.set(s.id, b);1919 if (s.workers[PI.hauler]! > 0) anyHauler = true;1920 }1921 // Standing routes, ascending array (insertion) order.1922 for (let i = tribe.routes.length - 1; i >= 0; i--) {1923 const r = tribe.routes[i]!;1924 const from = this.findSettlement(r.fromId);1925 const to = this.findSettlement(r.toId);1926 if (1927 from === undefined ||1928 to === undefined ||1929 from.tribe !== tribe.id ||1930 to.tribe !== tribe.id1931 ) {1932 tribe.routes.splice(i, 1);1933 continue;1934 }1935 }1936 for (const r of tribe.routes) {1937 const from = this.findSettlement(r.fromId)!;1938 const to = this.findSettlement(r.toId)!;1939 const b = budget.get(from.id)!;1940 if (b <= 0) continue;1941 const surplus = from.stores[r.res]! - C.ROUTE_KEEP_LEVEL;1942 if (surplus <= 0) continue;1943 let move = Math.trunc((surplus * r.permille) / 1000);1944 move = Math.min(move, b, this.storeCapOf(to) - to.stores[r.res]!);1945 if (move <= 0) continue;1946 from.stores[r.res]! -= move;1947 to.stores[r.res]! += move;1948 budget.set(from.id, b - move);1949 }1950 // Default food equalization (needs >=1 hauler and >=2 settlements).1951 if (!anyHauler || own.length < 2) continue;1952 let totalPop = 0;1953 const points = own.map((s) => {1954 totalPop += s.pop;1955 let p = 0;1956 for (const [res, sat] of C.SATIATION) p += s.stores[res]! * sat;1957 return p;1958 });1959 if (totalPop === 0) continue;1960 let totalPoints = 0;1961 for (const p of points) totalPoints += p;1962 const excess = own.map(1963 (s, i) => points[i]! - Math.trunc((totalPoints * s.pop) / totalPop),1964 );1965 let di = 0;1966 let ri = 0;1967 while (di < own.length && ri < own.length) {1968 if (excess[di]! <= 0) {1969 di++;1970 continue;1971 }1972 if (excess[ri]! >= 0) {1973 ri++;1974 continue;1975 }1976 const donor = own[di]!;1977 const recip = own[ri]!;1978 let b = budget.get(donor.id)!;1979 if (b <= 0) {1980 di++;1981 continue;1982 }1983 for (const [res, sat] of C.SATIATION) {1984 if (excess[di]! <= 0 || excess[ri]! >= 0 || b <= 0) break;1985 const maxUnits = Math.min(1986 donor.stores[res]!,1987 b,1988 Math.trunc(excess[di]! / sat),1989 Math.ceil(-excess[ri]! / sat),1990 this.storeCapOf(recip) - recip.stores[res]!,1991 );1992 if (maxUnits <= 0) continue;1993 donor.stores[res]! -= maxUnits;1994 recip.stores[res]! += maxUnits;1995 excess[di]! -= maxUnits * sat;1996 excess[ri]! += maxUnits * sat;1997 b -= maxUnits;1998 }1999 budget.set(donor.id, b);2000 if (excess[di]! <= 0 || b <= 0) di++;2001 else ri++;2002 }2003 }2004 }20052006 // -- phase 8: consumption -------------------------------------------------20072008 private phaseConsumption(): void {2009 for (const s of this.settlements) {2010 if (s.pop <= 0) continue;2011 const tribe = this.tribes[s.tribe]!;2012 // Food (satiation points).2013 const dTotal = s.pop * tribe.decrees[0]! + s.acc[ACC.FOOD]!;2014 let need = Math.trunc(dTotal / C.FOOD_DEMAND_DIV);2015 s.acc[ACC.FOOD] = dTotal - need * C.FOOD_DEMAND_DIV;2016 for (let k = 0; k < C.SATIATION.length && need > 0; k++) {2017 const [res, sat] = C.SATIATION[k]!;2018 const units = Math.min(s.stores[res]!, Math.ceil(need / sat));2019 if (units <= 0) continue;2020 s.stores[res]! -= units;2021 this.cons(s.tribe, res, units);2022 need -= units * sat;2023 s.kindsEaten |= 1 << k;2024 }2025 if (need > 0) {2026 // Starvation (floored: a remnant always survives — see constants).2027 const cap = Math.max(1, Math.trunc(s.pop / C.STARVATION_DEATH_CAP_DIV));2028 const roomAboveFloor = Math.max(0, s.pop - C.SETTLEMENT_POP_FLOOR);2029 const deaths = Math.min(roomAboveFloor, Math.min(need, cap));2030 s.pop -= deaths;2031 s.morale = Math.max(0, s.morale - C.STARVATION_MORALE_HIT);2032 s.flags |= FLAG_STARVED;2033 tribe.starvI += deaths;2034 tribe.deathsI += deaths;2035 this.events.push({2036 t: 'starvation',2037 tick: this.tick,2038 tribe: s.tribe,2039 settlementId: s.id,2040 deaths,2041 x: s.x,2042 y: s.y,2043 });2044 }2045 // Ale ration.2046 const aleDecree = tribe.decrees[3]!;2047 if (aleDecree > 0) {2048 const aTotal = s.pop * aleDecree + s.acc[ACC.ALE]!;2049 const wantAle = Math.trunc(aTotal / C.ALE_DEMAND_DIV);2050 s.acc[ACC.ALE] = aTotal - wantAle * C.ALE_DEMAND_DIV;2051 if (wantAle > 0) {2052 const served = Math.min(wantAle, s.stores[RI.ale]!);2053 s.stores[RI.ale]! -= served;2054 this.cons(s.tribe, RI.ale, served);2055 if (served < wantAle) s.flags |= FLAG_ALE_SHORT;2056 }2057 }2058 // Clothes upkeep in cold biomes.2059 if (s.census.cold) {2060 const cTotal = s.pop + s.acc[ACC.CLOTHES]!;2061 const wantC = Math.trunc(cTotal / C.CLOTHES_DEMAND_DIV);2062 s.acc[ACC.CLOTHES] = cTotal - wantC * C.CLOTHES_DEMAND_DIV;2063 if (wantC > 0) {2064 const got = Math.min(wantC, s.stores[RI.clothes]!);2065 s.stores[RI.clothes]! -= got;2066 this.cons(s.tribe, RI.clothes, got);2067 if (got < wantC) s.flags |= FLAG_CLOTHES_SHORT;2068 }2069 }2070 }2071 }20722073 // -- phase 9: population & morale -----------------------------------------20742075 private phasePopulation(): void {2076 for (const s of this.settlements) {2077 const tribe = this.tribes[s.tribe]!;2078 // Morale drifts toward its target.2079 let kinds = 0;2080 let mask = s.kindsEaten;2081 while (mask !== 0) {2082 kinds += mask & 1;2083 mask >>= 1;2084 }2085 let target = C.MORALE_BASE_TARGET;2086 if (kinds >= 2) target += C.MORALE_VARIETY_BONUS;2087 if (tribe.decrees[3]! > 0 && (s.flags & FLAG_ALE_SHORT) === 0) target += C.MORALE_ALE_BONUS;2088 if ((s.flags & FLAG_CLOTHES_SHORT) !== 0) target -= C.MORALE_CLOTHES_PENALTY;2089 if ((s.flags & FLAG_STARVED) !== 0) target -= C.MORALE_STARVED_PENALTY;2090 if (this.effectActive(FX.blessing, s.tribe, s.id)) target += C.MORALE_BLESSING_BONUS;2091 target = Math.max(0, Math.min(1000, target));2092 s.morale += Math.trunc((target - s.morale) / 4);2093 s.morale = Math.max(0, Math.min(1000, s.morale));2094 s.kindsEaten = 0;2095 s.flags = 0;2096 if (s.pop <= 0) continue;2097 // Births.2098 const housing = this.housingOf(s);2099 let fHousing = 0;2100 if (housing > 0 && s.pop < housing) {2101 const softCap = Math.trunc((housing * C.HOUSING_SOFT_PERMILLE) / 1000);2102 fHousing =2103 s.pop <= softCap2104 ? 10002105 : Math.trunc(((housing - s.pop) * 1000) / Math.max(1, housing - softCap));2106 }2107 let storePoints = 0;2108 for (const [res, sat] of C.SATIATION) storePoints += s.stores[res]! * sat;2109 const needPer50 = Math.max(2110 1,2111 Math.trunc((s.pop * tribe.decrees[0]! * C.SURPLUS_TICKS) / C.FOOD_DEMAND_DIV),2112 );2113 const fSurplus = Math.min(1000, Math.trunc((storePoints * 1000) / needPer50));2114 // Combined factor in ‰ (each stage floors; keeps accumulators 32-bit).2115 let f = Math.trunc((fHousing * s.morale) / 1000);2116 f = Math.trunc((f * fSurplus) / 1000);2117 // Frontier boom: young settlements with empty housing grow fast.2118 const boom = Math.trunc((C.BIRTH_BOOM_MAX * Math.max(0, housing - s.pop)) / Math.max(1, housing));2119 const bTotal = s.pop * (C.BIRTH_RATE + boom) * f + s.acc[ACC.BIRTH]!;2120 const births = Math.trunc(bTotal / 1_000_000);2121 s.acc[ACC.BIRTH] = bTotal - births * 1_000_000;2122 if (births > 0) {2123 s.pop += births;2124 tribe.birthsI += births;2125 }2126 // Natural deaths (floored — see SETTLEMENT_POP_FLOOR).2127 const dTotal = s.pop * C.NATURAL_DEATH_RATE + s.acc[ACC.DEATH]!;2128 const deaths = Math.min(Math.max(0, s.pop - C.SETTLEMENT_POP_FLOOR), Math.trunc(dTotal / 1000));2129 s.acc[ACC.DEATH] = dTotal - Math.trunc(dTotal / 1000) * 1000;2130 if (deaths > 0) {2131 s.pop -= deaths;2132 tribe.deathsI += deaths;2133 }2134 // Emigration under low morale.2135 if (s.morale < C.EMIGRATION_MORALE) {2136 const eTotal = s.pop * C.EMIGRATION_RATE + s.acc[ACC.EMIG]!;2137 const gone = Math.min(Math.max(0, s.pop - C.SETTLEMENT_POP_FLOOR), Math.trunc(eTotal / 1000));2138 s.acc[ACC.EMIG] = eTotal - Math.trunc(eTotal / 1000) * 1000;2139 if (gone > 0) {2140 s.pop -= gone;2141 tribe.deathsI += gone;2142 }2143 }2144 this.updateTier(s);2145 }2146 }21472148 // -- phase 10: military ---------------------------------------------------21492150 private warbandPower(w: Warband, defTerrain: number, wallLevel: number): number {2151 if (w.size <= 0) return 0;2152 let per = C.POWER_BASE;2153 if (w.weapons >= w.size) per += C.POWER_ARMED;2154 per += Math.trunc((w.armor * 10) / w.size); // armorRatio‰ / 1002155 per += Math.trunc(w.vet / 100);2156 per += this.tribes[w.tribe]!.age * C.AGE_POWER_BONUS;2157 if (defTerrain >= 0) per += C.TERRAIN_DEF_MOD[defTerrain] ?? 0;2158 if (wallLevel > 0) per += wallLevel * C.WALL_DEF_PER_SOLDIER;2159 let p = w.size * Math.max(1, per);2160 // Green troops: still drilling -> reduced fighting power.2161 if (w.drill > 0) p = Math.trunc((p * C.DRILLING_POWER_PERMILLE) / 1000);2162 return Math.max(1, p);2163 }21642165 private variance(): number {2166 return C.VARIANCE_BASE + this.rng.int(C.VARIANCE_SPAN);2167 }21682169 private casualties(2170 winnerP: number,2171 loserP: number,2172 ): { winnerPm: number; loserPm: number } {2173 const r = Math.trunc((winnerP * 1000) / Math.max(1, loserP));2174 const loserPm = Math.min(C.LOSER_LOSS_CAP, C.LOSER_LOSS_BASE + Math.trunc(r / 5));2175 const winnerPm = Math.min(2176 C.WINNER_LOSS_CAP,2177 Math.max(C.WINNER_LOSS_MIN, Math.trunc(350000 / Math.max(1, r))),2178 );2179 return { winnerPm, loserPm };2180 }21812182 private applyWarbandLoss(w: Warband, pm: number, minOne: boolean): number {2183 let loss = Math.trunc((w.size * pm) / 1000);2184 if (minOne && loss === 0 && w.size > 0) loss = 1;2185 loss = Math.min(loss, w.size);2186 w.size -= loss;2187 w.weapons = Math.min(w.weapons, w.size);2188 w.armor = Math.min(w.armor, w.size);2189 w.horses = Math.min(w.horses, w.size);2190 w.supply = Math.min(w.supply, w.size * C.SUPPLY_CAP_PER_SOLDIER);2191 return loss;2192 }21932194 private routHome(w: Warband): void {2195 const home = this.findSettlement(w.home);2196 const dest =2197 home !== undefined && home.tribe === w.tribe2198 ? home2199 : this.nearestOwnSettlement(w.tribe, w.x, w.y);2200 w.order = {2201 type: WO.march,2202 targetId: -1,2203 tx: dest !== null && dest !== undefined ? dest.x : w.x,2204 ty: dest !== null && dest !== undefined ? dest.y : w.y,2205 };2206 w.morale = Math.max(0, w.morale - C.ROUT_MORALE_HIT);2207 }22082209 private resolveFieldBattle(a: Warband, b: Warband): void {2210 const riteA = this.consumeWarRite(a);2211 const riteB = this.consumeWarRite(b);2212 let pa = this.warbandPower(a, -1, 0);2213 let pb = this.warbandPower(b, this.terrainAt(b.x, b.y), 0);2214 pa = Math.trunc((pa * (1000 + riteA)) / 1000);2215 pb = Math.trunc((pb * (1000 + riteB)) / 1000);2216 pa = Math.trunc((pa * this.variance()) / 1000);2217 pb = Math.trunc((pb * this.variance()) / 1000);2218 const aWins = pa > pb;2219 const win = aWins ? a : b;2220 const lose = aWins ? b : a;2221 const winP = aWins ? pa : pb;2222 const loseP = aWins ? pb : pa;2223 const { winnerPm, loserPm } = this.casualties(winP, loseP);2224 const winLoss = this.applyWarbandLoss(win, winnerPm, false);2225 const loseLoss = this.applyWarbandLoss(lose, loserPm, true);2226 win.vet = Math.min(1000, win.vet + C.VET_WIN);2227 lose.vet = Math.min(1000, lose.vet + C.VET_LOSS);2228 this.tribes[win.tribe]!.battlesWon++;2229 this.tribes[lose.tribe]!.battlesLost++;2230 this.tribes[win.tribe]!.casDealt += loseLoss;2231 this.tribes[win.tribe]!.casTaken += winLoss;2232 this.tribes[lose.tribe]!.casDealt += winLoss;2233 this.tribes[lose.tribe]!.casTaken += loseLoss;2234 if (lose.size > 0) this.routHome(lose);2235 a.engaged = true;2236 b.engaged = true;2237 this.events.push({2238 t: 'battle',2239 tick: this.tick,2240 x: b.x,2241 y: b.y,2242 attacker: a.tribe,2243 defender: b.tribe,2244 attackerLosses: aWins ? winLoss : loseLoss,2245 defenderLosses: aWins ? loseLoss : winLoss,2246 winner: win.tribe,2247 });2248 }22492250 private resolveCampBattle(w: Warband, camp: Camp): void {2251 const rite = this.consumeWarRite(w);2252 let pw = this.warbandPower(w, -1, 0);2253 pw = Math.trunc((pw * (1000 + rite)) / 1000);2254 let pc = camp.strength * C.CAMP_POWER_MULT;2255 pw = Math.trunc((pw * this.variance()) / 1000);2256 pc = Math.trunc((pc * this.variance()) / 1000);2257 w.engaged = true;2258 const tribe = this.tribes[w.tribe]!;2259 if (pw > pc) {2260 const { winnerPm } = this.casualties(pw, pc);2261 const loss = this.applyWarbandLoss(w, winnerPm, false);2262 tribe.casTaken += loss;2263 tribe.casDealt += camp.strength;2264 tribe.battlesWon++;2265 tribe.campsCleared++;2266 w.vet = Math.min(1000, w.vet + C.VET_WIN);2267 const home = this.findSettlement(w.home) ?? this.nearestOwnSettlement(w.tribe, w.x, w.y);2268 if (home !== null && home !== undefined && home.tribe === w.tribe) {2269 this.addStore(home, RI.coin, camp.strength * C.CAMP_CLEAR_COIN_MULT);2270 }2271 this.events.push({2272 t: 'battle',2273 tick: this.tick,2274 x: camp.x,2275 y: camp.y,2276 attacker: w.tribe,2277 defender: null,2278 attackerLosses: loss,2279 defenderLosses: camp.strength,2280 winner: w.tribe,2281 });2282 this.events.push({2283 t: 'threat_cleared',2284 tick: this.tick,2285 tribe: w.tribe,2286 kind: THREAT_KINDS[camp.kind]!,2287 x: camp.x,2288 y: camp.y,2289 });2290 const i = this.camps.indexOf(camp);2291 if (i !== -1) this.camps.splice(i, 1);2292 } else {2293 const { loserPm } = this.casualties(pc, pw);2294 const loss = this.applyWarbandLoss(w, loserPm, true);2295 tribe.casTaken += loss;2296 tribe.battlesLost++;2297 const campLoss = Math.max(1, Math.trunc(camp.strength / 4));2298 camp.strength -= campLoss;2299 tribe.casDealt += campLoss;2300 w.vet = Math.min(1000, w.vet + C.VET_LOSS);2301 this.events.push({2302 t: 'battle',2303 tick: this.tick,2304 x: camp.x,2305 y: camp.y,2306 attacker: w.tribe,2307 defender: null,2308 attackerLosses: loss,2309 defenderLosses: campLoss,2310 winner: null,2311 });2312 this.routHome(w);2313 }2314 }23152316 private garrisonsAt(s: Settlement): Warband[] {2317 return this.warbands.filter(2318 (w) =>2319 w.tribe === s.tribe &&2320 w.order.type === WO.garrison &&2321 w.order.targetId === s.id &&2322 w.x === s.x &&2323 w.y === s.y &&2324 w.size > 0,2325 );2326 }23272328 private settlementDefense(s: Settlement, garrisons: Warband[]): number {2329 const wall = s.buildings[BI.walls]!;2330 let def =2331 Math.trunc(s.pop / C.MILITIA_POP_DIV) +2332 wall * C.WALL_DEFENSE +2333 s.buildings[BI.tower]! * C.TOWER_DEFENSE;2334 for (const g of garrisons) def += this.warbandPower(g, this.terrainAt(s.x, s.y), wall);2335 return def;2336 }23372338 private resolveRaid(w: Warband, s: Settlement): void {2339 const garrisons = this.garrisonsAt(s);2340 // Nothing worth taking: an empty, cowed camp yields no battle. The band2341 // waits at the gates (burning supply) until stores or people return.2342 if (garrisons.length === 0 && Math.trunc(s.pop / 50) <= 0) {2343 let lootable = 0;2344 for (const res of LOOT_ORDER) lootable += s.stores[res]!;2345 if (lootable <= 0) return;2346 }2347 w.engaged = true;2348 const tribe = this.tribes[w.tribe]!;2349 const victim = this.tribes[s.tribe]!;2350 const rite = this.consumeWarRite(w);2351 let pa = Math.trunc((this.warbandPower(w, -1, 0) * (1000 + rite)) / 1000);2352 let pd = this.settlementDefense(s, garrisons);2353 pa = Math.trunc((pa * this.variance()) / 1000);2354 pd = Math.trunc((pd * this.variance()) / 1000);2355 let attackerLosses: number;2356 let defenderLosses = 0;2357 if (pa > pd) {2358 const { winnerPm, loserPm } = this.casualties(pa, pd);2359 attackerLosses = this.applyWarbandLoss(w, winnerPm, false);2360 for (const g of garrisons) defenderLosses += this.applyWarbandLoss(g, loserPm, true);2361 const popLoss = Math.min(s.pop, Math.min(Math.trunc(s.pop / 50), Math.trunc(w.size / 2)));2362 s.pop -= popLoss;2363 defenderLosses += popLoss;2364 victim.deathsI += popLoss;2365 s.morale = Math.max(0, s.morale - 50);2366 // Loot.2367 let carry = w.size * C.RAID_CARRY_PER_SOLDIER;2368 const loot: Partial<Record<Resource, number>> = {};2369 const home = this.findSettlement(w.home) ?? this.nearestOwnSettlement(w.tribe, w.x, w.y);2370 for (const res of LOOT_ORDER) {2371 if (carry <= 0) break;2372 const take = Math.min(carry, s.stores[res]!);2373 if (take <= 0) continue;2374 s.stores[res]! -= take;2375 carry -= take;2376 loot[RESOURCES[res]!] = take;2377 if (home !== null && home !== undefined && home.tribe === w.tribe) {2378 this.addStore(home, res, take);2379 }2380 }2381 // Building damage on an overwhelming win.2382 if (pa >= pd * C.RAID_DESTROY_RATIO) {2383 for (const b of DESTROY_ORDER) {2384 if (s.buildings[b]! > 0) {2385 s.buildings[b]!--;2386 this.events.push({2387 t: 'building_destroyed',2388 tick: this.tick,2389 tribe: s.tribe,2390 building: BUILDINGS[b]!,2391 settlementId: s.id,2392 by: w.tribe,2393 x: s.x,2394 y: s.y,2395 });2396 break;2397 }2398 }2399 }2400 tribe.battlesWon++;2401 victim.battlesLost++;2402 w.vet = Math.min(1000, w.vet + C.VET_WIN);2403 this.events.push({2404 t: 'raid',2405 tick: this.tick,2406 tribe: s.tribe,2407 by: w.tribe,2408 settlementId: s.id,2409 loot,2410 x: s.x,2411 y: s.y,2412 });2413 } else {2414 const { winnerPm, loserPm } = this.casualties(pd, pa);2415 attackerLosses = this.applyWarbandLoss(w, loserPm, true);2416 for (const g of garrisons) defenderLosses += this.applyWarbandLoss(g, winnerPm, false);2417 tribe.battlesLost++;2418 victim.battlesWon++;2419 for (const g of garrisons) g.vet = Math.min(1000, g.vet + C.VET_WIN);2420 }2421 tribe.casTaken += attackerLosses;2422 tribe.casDealt += defenderLosses;2423 victim.casDealt += attackerLosses;2424 victim.casTaken += defenderLosses;2425 this.events.push({2426 t: 'battle',2427 tick: this.tick,2428 x: s.x,2429 y: s.y,2430 attacker: w.tribe,2431 defender: s.tribe,2432 attackerLosses,2433 defenderLosses,2434 winner: pa > pd ? w.tribe : s.tribe,2435 });2436 if (w.size > 0) this.routHome(w);2437 }24382439 private resolveCaravanRaid(w: Warband, c: Caravan): void {2440 // An escorting warband within 1 tile defends the caravan first.2441 let escort: Warband | null = null;2442 for (const e of this.warbands) {2443 if (2444 e.tribe === c.tribe &&2445 e.order.type === WO.escort &&2446 e.order.targetId === c.id &&2447 cheb(e.x, e.y, c.x, c.y) <= 1 &&2448 e.size > 0 &&2449 !e.engaged2450 ) {2451 escort = e;2452 break;2453 }2454 }2455 if (escort !== null) {2456 this.resolveFieldBattle(w, escort);2457 if (w.size <= 0 || escort.size > 0) return; // escort held (or raider died)2458 if (cheb(w.x, w.y, c.x, c.y) > 1) return;2459 } else {2460 w.engaged = true;2461 }2462 const owner = this.tribes[c.tribe]!;2463 owner.caravansLost++;2464 const home = this.findSettlement(w.home) ?? this.nearestOwnSettlement(w.tribe, w.x, w.y);2465 if (home !== null && home !== undefined && home.tribe === w.tribe) {2466 this.addStore(home, c.res, c.amount);2467 }2468 this.events.push({2469 t: 'caravan_raided',2470 tick: this.tick,2471 tribe: c.tribe,2472 by: w.tribe,2473 x: c.x,2474 y: c.y,2475 resource: RESOURCES[c.res]!,2476 amount: c.amount,2477 });2478 const i = this.caravans.indexOf(c);2479 if (i !== -1) this.caravans.splice(i, 1);2480 if (w.size > 0) this.routHome(w);2481 }24822483 private resolveSiege(w: Warband, s: Settlement): void {2484 const garrisons = this.garrisonsAt(s);2485 if (garrisons.length > 0) {2486 // One battle per slow tick against the lowest-id garrison (with walls).2487 const g = garrisons[0]!;2488 const riteA = this.consumeWarRite(w);2489 const riteD = this.consumeWarRite(g);2490 let pa = Math.trunc((this.warbandPower(w, -1, 0) * (1000 + riteA)) / 1000);2491 let pd = Math.trunc(2492 (this.warbandPower(g, this.terrainAt(s.x, s.y), s.buildings[BI.walls]!) *2493 (1000 + riteD)) /2494 1000,2495 );2496 pa = Math.trunc((pa * this.variance()) / 1000);2497 pd = Math.trunc((pd * this.variance()) / 1000);2498 const aWins = pa > pd;2499 const win = aWins ? w : g;2500 const lose = aWins ? g : w;2501 const winP = aWins ? pa : pd;2502 const loseP = aWins ? pd : pa;2503 const { winnerPm, loserPm } = this.casualties(winP, loseP);2504 const winLoss = this.applyWarbandLoss(win, winnerPm, false);2505 const loseLoss = this.applyWarbandLoss(lose, loserPm, true);2506 win.vet = Math.min(1000, win.vet + C.VET_WIN);2507 lose.vet = Math.min(1000, lose.vet + C.VET_LOSS);2508 this.tribes[win.tribe]!.battlesWon++;2509 this.tribes[lose.tribe]!.battlesLost++;2510 this.tribes[win.tribe]!.casDealt += loseLoss;2511 this.tribes[win.tribe]!.casTaken += winLoss;2512 this.tribes[lose.tribe]!.casDealt += winLoss;2513 this.tribes[lose.tribe]!.casTaken += loseLoss;2514 if (lose === w && w.size > 0) this.routHome(w);2515 w.engaged = true;2516 g.engaged = true;2517 this.events.push({2518 t: 'battle',2519 tick: this.tick,2520 x: s.x,2521 y: s.y,2522 attacker: w.tribe,2523 defender: s.tribe,2524 attackerLosses: aWins ? winLoss : loseLoss,2525 defenderLosses: aWins ? loseLoss : winLoss,2526 winner: win.tribe,2527 });2528 return;2529 }2530 const wall = s.buildings[BI.walls]!;2531 if (wall > 0) {2532 let dmg = this.warbandPower(w, -1, 0);2533 if (wall >= 2 && !w.siege) dmg = Math.trunc(dmg / C.NO_RAM_DIV);2534 s.wallDamage += dmg;2535 if (s.wallDamage >= wall * C.WALL_HP) {2536 s.buildings[BI.walls] = wall - 1;2537 s.wallDamage = 0;2538 this.events.push({2539 t: 'building_destroyed',2540 tick: this.tick,2541 tribe: s.tribe,2542 building: 'walls',2543 settlementId: s.id,2544 by: w.tribe,2545 x: s.x,2546 y: s.y,2547 });2548 }2549 return;2550 }2551 // No walls, no garrison: storm vs militia.2552 const rite = this.consumeWarRite(w);2553 let pa = Math.trunc((this.warbandPower(w, -1, 0) * (1000 + rite)) / 1000);2554 let pd =2555 Math.trunc(s.pop / C.MILITIA_POP_DIV) + s.buildings[BI.tower]! * C.TOWER_DEFENSE;2556 pa = Math.trunc((pa * this.variance()) / 1000);2557 pd = Math.trunc((pd * this.variance()) / 1000);2558 w.engaged = true;2559 const tribe = this.tribes[w.tribe]!;2560 const victim = this.tribes[s.tribe]!;2561 if (pa > pd) {2562 const { winnerPm } = this.casualties(pa, pd);2563 const loss = this.applyWarbandLoss(w, winnerPm, false);2564 const popLoss = Math.min(s.pop, Math.trunc(s.pop / 20));2565 s.pop -= popLoss;2566 victim.deathsI += popLoss;2567 tribe.casTaken += loss;2568 tribe.casDealt += popLoss;2569 victim.casTaken += popLoss;2570 victim.casDealt += loss;2571 tribe.battlesWon++;2572 victim.battlesLost++;2573 w.vet = Math.min(1000, w.vet + C.VET_WIN);2574 this.events.push({2575 t: 'battle',2576 tick: this.tick,2577 x: s.x,2578 y: s.y,2579 attacker: w.tribe,2580 defender: s.tribe,2581 attackerLosses: loss,2582 defenderLosses: popLoss,2583 winner: w.tribe,2584 });2585 this.captureSettlement(s, w.tribe);2586 w.order = { type: WO.garrison, targetId: s.id, tx: s.x, ty: s.y };2587 } else {2588 const { loserPm } = this.casualties(pd, pa);2589 const loss = this.applyWarbandLoss(w, loserPm, true);2590 tribe.casTaken += loss;2591 victim.casDealt += loss;2592 tribe.battlesLost++;2593 victim.battlesWon++;2594 this.events.push({2595 t: 'battle',2596 tick: this.tick,2597 x: s.x,2598 y: s.y,2599 attacker: w.tribe,2600 defender: s.tribe,2601 attackerLosses: loss,2602 defenderLosses: 0,2603 winner: s.tribe,2604 });2605 this.routHome(w);2606 }2607 }26082609 private captureSettlement(s: Settlement, by: number): void {2610 const old = s.tribe;2611 s.tribe = by;2612 s.morale = 300;2613 s.wallDamage = 0;2614 s.allocOverride.fill(-1);2615 // Old owner's routes touching this settlement die.2616 const oldTribe = this.tribes[old]!;2617 oldTribe.routes = oldTribe.routes.filter((r) => r.fromId !== s.id && r.toId !== s.id);2618 // Blessing/harvest effects on this settlement end.2619 this.effects = this.effects.filter(2620 (e) => !((e.kind === FX.blessing || e.kind === FX.harvest) && e.target === s.id),2621 );2622 this.reveal(by, s.x, s.y, s.radius + C.SETTLEMENT_VISION_BONUS);2623 this.events.push({2624 t: 'settlement_captured',2625 tick: this.tick,2626 tribe: old,2627 by,2628 settlementId: s.id,2629 x: s.x,2630 y: s.y,2631 });2632 }26332634 private phaseMilitary(slow: boolean): void {2635 // Movement (ascending id).2636 for (const w of this.warbands) {2637 if (w.drill > 0) w.drill--;2638 w.engaged = false;2639 w.moved = false;2640 const o = w.order;2641 let tx = w.x;2642 let ty = w.y;2643 switch (o.type) {2644 case WO.garrison: {2645 const s = this.findSettlement(o.targetId);2646 if (s !== undefined && s.tribe === w.tribe) {2647 tx = s.x;2648 ty = s.y;2649 }2650 break;2651 }2652 case WO.patrol: {2653 // Intercept the nearest hostile warband or camp near the anchor.2654 let best: { x: number; y: number } | null = null;2655 let bestD = C.PATROL_RADIUS + 1;2656 for (const e of this.warbands) {2657 if (e.tribe === w.tribe || e.size <= 0) continue;2658 if (2659 this.tribes[w.tribe]!.stances[e.tribe] !== P_HOSTILE &&2660 this.tribes[e.tribe]!.stances[w.tribe] !== P_HOSTILE2661 ) {2662 continue;2663 }2664 const d = cheb(e.x, e.y, o.tx, o.ty);2665 if (d < bestD) {2666 best = e;2667 bestD = d;2668 }2669 }2670 for (const camp of this.camps) {2671 const d = cheb(camp.x, camp.y, o.tx, o.ty);2672 if (d < bestD) {2673 best = camp;2674 bestD = d;2675 }2676 }2677 if (best !== null) {2678 tx = best.x;2679 ty = best.y;2680 } else {2681 tx = o.tx;2682 ty = o.ty;2683 }2684 break;2685 }2686 case WO.march:2687 tx = o.tx;2688 ty = o.ty;2689 break;2690 case WO.raid: {2691 const s = this.findSettlement(o.targetId);2692 if (s !== undefined && s.tribe !== w.tribe) {2693 tx = s.x;2694 ty = s.y;2695 } else {2696 const c = this.findCaravan(o.targetId);2697 if (c !== undefined && c.tribe !== w.tribe) {2698 tx = c.x;2699 ty = c.y;2700 } else {2701 w.order = { type: WO.march, targetId: -1, tx: w.x, ty: w.y };2702 }2703 }2704 break;2705 }2706 case WO.besiege: {2707 const s = this.findSettlement(o.targetId);2708 if (s !== undefined && s.tribe !== w.tribe) {2709 tx = s.x;2710 ty = s.y;2711 } else {2712 w.order = { type: WO.march, targetId: -1, tx: w.x, ty: w.y };2713 }2714 break;2715 }2716 case WO.escort: {2717 const c = this.findCaravan(o.targetId);2718 if (c !== undefined && c.tribe === w.tribe) {2719 tx = c.x;2720 ty = c.y;2721 } else {2722 w.order = { type: WO.march, targetId: -1, tx: w.x, ty: w.y };2723 }2724 break;2725 }2726 case WO.disband: {2727 const s = this.nearestOwnSettlement(w.tribe, w.x, w.y);2728 if (s !== null) {2729 tx = s.x;2730 ty = s.y;2731 }2732 break;2733 }2734 default:2735 break;2736 }2737 if (tx !== w.x || ty !== w.y) {2738 w.moved = this.stepEntity(w, tx, ty);2739 if (w.moved) this.reveal(w.tribe, w.x, w.y, C.UNIT_VISION);2740 }2741 }27422743 // Disband completion (at an own settlement tile).2744 for (let i = this.warbands.length - 1; i >= 0; i--) {2745 const w = this.warbands[i]!;2746 if (w.order.type !== WO.disband) continue;2747 const s = this.settlements.find(2748 (t) => t.tribe === w.tribe && t.x === w.x && t.y === w.y,2749 );2750 if (s === undefined) continue;2751 s.pop += w.size;2752 this.addStore(s, RI.weapons, w.weapons);2753 this.addStore(s, RI.armor, w.armor);2754 s.horses += w.horses;2755 this.addStore(s, RI.grain, w.supply); // supply returns as grain2756 this.warbands.splice(i, 1);2757 }27582759 // Field battles: each warband fights at most once; lower ids act first.2760 for (const w of this.warbands) {2761 if (w.engaged || w.size <= 0) continue;2762 let target: Warband | null = null;2763 let bestD = 2;2764 for (const e of this.warbands) {2765 if (e === w || e.tribe === w.tribe || e.engaged || e.size <= 0) continue;2766 if (2767 this.tribes[w.tribe]!.stances[e.tribe] !== P_HOSTILE &&2768 this.tribes[e.tribe]!.stances[w.tribe] !== P_HOSTILE2769 ) {2770 continue;2771 }2772 const d = cheb(w.x, w.y, e.x, e.y);2773 if (d < bestD) {2774 target = e;2775 bestD = d;2776 }2777 }2778 if (target !== null) this.resolveFieldBattle(w, target);2779 }27802781 // Camp assaults (warband standing on the camp tile).2782 for (const w of this.warbands) {2783 if (w.engaged || w.size <= 0) continue;2784 const camp = this.camps.find((c) => c.x === w.x && c.y === w.y);2785 if (camp !== undefined) this.resolveCampBattle(w, camp);2786 }27872788 // Raids (within 1 tile of the target).2789 for (const w of this.warbands) {2790 if (w.engaged || w.size <= 0 || w.order.type !== WO.raid) continue;2791 const s = this.findSettlement(w.order.targetId);2792 if (s !== undefined && s.tribe !== w.tribe) {2793 if (cheb(w.x, w.y, s.x, s.y) <= 1) this.resolveRaid(w, s);2794 continue;2795 }2796 const c = this.findCaravan(w.order.targetId);2797 if (c !== undefined && c.tribe !== w.tribe && cheb(w.x, w.y, c.x, c.y) <= 1) {2798 this.resolveCaravanRaid(w, c);2799 }2800 }28012802 // Sieges (slow ticks, within 1 tile).2803 if (slow) {2804 for (const w of this.warbands) {2805 if (w.engaged || w.size <= 0 || w.order.type !== WO.besiege) continue;2806 const s = this.findSettlement(w.order.targetId);2807 if (s === undefined || s.tribe === w.tribe) continue;2808 if (cheb(w.x, w.y, s.x, s.y) <= 1) this.resolveSiege(w, s);2809 }2810 }28112812 // Supply & attrition (slow ticks).2813 if (slow) {2814 for (const w of this.warbands) {2815 if (w.size <= 0) continue;2816 const need = w.size * C.SUPPLY_PER_SOLDIER_10T;2817 let inOwn: Settlement | null = null;2818 for (const s of this.settlements) {2819 if (s.tribe !== w.tribe) continue;2820 if (cheb(w.x, w.y, s.x, s.y) <= s.radius) {2821 inOwn = s;2822 break;2823 }2824 }2825 if (inOwn !== null) {2826 const cap = w.size * C.SUPPLY_CAP_PER_SOLDIER;2827 const wanted = cap - w.supply + need;2828 w.supply += this.takeFood(inOwn, Math.max(0, wanted));2829 }2830 if (w.supply >= need) {2831 w.supply -= need;2832 } else {2833 w.supply = 0;2834 // Empty supply outside own territory: desertion + morale decay.2835 w.desertAcc += w.size * C.DESERTION_RATE;2836 const gone = Math.trunc(w.desertAcc / 1000);2837 w.desertAcc -= gone * 1000;2838 if (gone > 0) {2839 w.size = Math.max(0, w.size - gone);2840 w.weapons = Math.min(w.weapons, w.size);2841 w.armor = Math.min(w.armor, w.size);2842 w.horses = Math.min(w.horses, w.size);2843 }2844 w.morale = Math.max(0, w.morale - C.NO_SUPPLY_MORALE_HIT);2845 }2846 }2847 }28482849 // Sweep destroyed warbands.2850 for (let i = this.warbands.length - 1; i >= 0; i--) {2851 if (this.warbands[i]!.size <= 0) this.warbands.splice(i, 1);2852 }2853 }28542855 // -- phase 11: threats ----------------------------------------------------28562857 private phaseThreats(): void {2858 // Grace period: the wilderness is quiet while camps are founded.2859 if (this.tick < C.THREAT_GRACE_TICKS) return;2860 // Growth.2861 for (const camp of this.camps) {2862 const cap = C.THREAT_BASE_STRENGTH[camp.kind]! * C.THREAT_STRENGTH_CAP_MULT;2863 camp.strength = Math.min(cap, camp.strength + C.THREAT_GROWTH_PER_10T);2864 }2865 // Spawn attempt (PRNG: fixed order, once per slow tick).2866 const maxCamps = Math.min(2867 C.THREAT_MAX_CAMPS_CAP,2868 1 + Math.trunc(this.tick / C.THREAT_RAMP_TICKS),2869 );2870 if (this.camps.length < maxCamps && this.settlements.length > 0) {2871 for (let t = 0; t < C.THREAT_SPAWN_TRIES; t++) {2872 const x = this.rng.int(this.size);2873 const y = this.rng.int(this.size);2874 if (!this.passable(x, y)) continue;2875 let minD = 0x7fffffff;2876 for (const s of this.settlements) minD = Math.min(minD, cheb(x, y, s.x, s.y));2877 if (minD < C.THREAT_MIN_DIST || minD > C.THREAT_MAX_DIST) continue;2878 const terr = this.terrainAt(x, y);2879 let kind = 0; // goblin2880 if (terr === TC.SWAMP) kind = 1;2881 else if (terr === TC.VOLCANO && this.tick > C.DEMON_GATE_MIN_TICK) kind = 3;2882 else if (terr === TC.MOUNTAIN || terr === TC.VOLCANO) kind = 2;2883 let wealth = 0;2884 for (const s of this.settlements) {2885 if (cheb(x, y, s.x, s.y) > C.THREAT_WEALTH_RADIUS) continue;2886 for (let r = 0; r < R_N; r++) wealth += s.stores[r]!;2887 }2888 const strength =2889 C.THREAT_BASE_STRENGTH[kind]! +2890 Math.trunc(this.tick / C.THREAT_STRENGTH_TICK_DIV) +2891 Math.trunc(wealth / C.THREAT_WEALTH_DIV);2892 this.camps.push({2893 id: this.nextCampId++,2894 kind,2895 x,2896 y,2897 strength,2898 nextRaidAt: this.tick + C.THREAT_FIRST_RAID_DELAY,2899 });2900 this.events.push({2901 t: 'threat_spawned',2902 tick: this.tick,2903 kind: THREAT_KINDS[kind]!,2904 x,2905 y,2906 });2907 break;2908 }2909 }2910 // Raids (camps ascending id).2911 for (const camp of this.camps) {2912 if (this.tick < camp.nextRaidAt) continue;2913 let target: Settlement | null = null;2914 let bestD = C.THREAT_RAID_RANGE + 1;2915 for (const s of this.settlements) {2916 const d = cheb(camp.x, camp.y, s.x, s.y);2917 if (d < bestD) {2918 target = s;2919 bestD = d;2920 }2921 }2922 if (target === null) continue;2923 camp.nextRaidAt = this.tick + C.THREAT_RAID_COOLDOWN;2924 const victim = this.tribes[target.tribe]!;2925 let pa = camp.strength * C.THREAT_RAID_POWER_MULT;2926 if (this.effectActive(FX.warding, target.tribe, -1)) pa = Math.trunc(pa / 2);2927 const garrisons = this.garrisonsAt(target);2928 let pd = this.settlementDefense(target, garrisons);2929 pa = Math.trunc((pa * this.variance()) / 1000);2930 pd = Math.trunc((pd * this.variance()) / 1000);2931 if (pa > pd) {2932 let carry = camp.strength * C.RAID_CARRY_PER_SOLDIER;2933 const loot: Partial<Record<Resource, number>> = {};2934 for (const res of LOOT_ORDER) {2935 if (carry <= 0) break;2936 const take = Math.min(carry, target.stores[res]!);2937 if (take <= 0) continue;2938 target.stores[res]! -= take;2939 carry -= take;2940 loot[RESOURCES[res]!] = take;2941 }2942 const popLoss = Math.min(2943 target.pop,2944 Math.min(Math.trunc(target.pop / 50), Math.trunc(camp.strength / 2)),2945 );2946 target.pop -= popLoss;2947 victim.deathsI += popLoss;2948 victim.casTaken += popLoss;2949 target.morale = Math.max(0, target.morale - 50);2950 let totalBuildings = 0;2951 for (let b = 0; b < target.buildings.length; b++) totalBuildings += target.buildings[b]!;2952 if (pa >= pd * C.RAID_DESTROY_RATIO && totalBuildings >= C.THREAT_ARSON_MIN_BUILDINGS) {2953 for (const b of DESTROY_ORDER) {2954 if (target.buildings[b]! > 0) {2955 target.buildings[b]!--;2956 this.events.push({2957 t: 'building_destroyed',2958 tick: this.tick,2959 tribe: target.tribe,2960 building: BUILDINGS[b]!,2961 settlementId: target.id,2962 by: null,2963 x: target.x,2964 y: target.y,2965 });2966 break;2967 }2968 }2969 }2970 // Threat raids use by = -1 (documented convention).2971 this.events.push({2972 t: 'raid',2973 tick: this.tick,2974 tribe: target.tribe,2975 by: -1,2976 settlementId: target.id,2977 loot,2978 x: target.x,2979 y: target.y,2980 });2981 } else {2982 // Repelled: the camp is bloodied.2983 const loss = Math.max(1, Math.trunc(camp.strength / 4));2984 camp.strength -= loss;2985 victim.casDealt += loss;2986 for (const g of garrisons) g.vet = Math.min(1000, g.vet + C.VET_LOSS);2987 }2988 }2989 // Starved-out camps despawn.2990 for (let i = this.camps.length - 1; i >= 0; i--) {2991 if (this.camps[i]!.strength <= 0) this.camps.splice(i, 1);2992 }2993 }29942995 // -- phase 12: trade, faith & tech ----------------------------------------29962997 private marketSettlementOf(tribe: number): Settlement | null {2998 for (const s of this.settlements) {2999 if (s.tribe === tribe && s.buildings[BI.market]! > 0) return s;3000 }3001 return null;3002 }30033004 private phaseTradeFaithTech(): void {3005 // Knowledge & faith accrual; research completion (tribes ascending).3006 for (const tribe of this.tribes) {3007 if (!tribe.alive) continue;3008 let scholars = 0;3009 let priests = 0;3010 for (const s of this.settlements) {3011 if (s.tribe !== tribe.id) continue;3012 scholars += s.workers[PI.scholar]!;3013 priests += s.workers[PI.priest]!;3014 }3015 const kTotal = scholars * C.SCHOLAR_RATE + tribe.knowAcc;3016 const kp = Math.trunc(kTotal / 1000);3017 tribe.knowAcc = kTotal - kp * 1000;3018 tribe.knowledge += kp;3019 const fTotal = priests * C.PRIEST_RATE + tribe.faithAcc;3020 const fp = Math.trunc(fTotal / 1000);3021 tribe.faithAcc = fTotal - fp * 1000;3022 tribe.faith += fp;3023 if (tribe.researching >= 0) {3024 const tech = TECHS[tribe.researching]!;3025 let owned = 0;3026 let m = tribe.techMask;3027 while (m !== 0) {3028 owned += m & 1;3029 m >>>= 1;3030 }3031 const cost = Math.trunc(3032 (C.TECH_COSTS[tech] * (C.TECH_ESCALATION_DIV + owned)) / C.TECH_ESCALATION_DIV,3033 );3034 if (tribe.knowledge >= cost) {3035 tribe.knowledge -= cost;3036 tribe.techMask |= 1 << tribe.researching;3037 this.events.push({ t: 'tech_unlocked', tick: this.tick, tribe: tribe.id, tech });3038 tribe.researching = -1;3039 }3040 }3041 }3042 // Expired timed effects.3043 this.effects = this.effects.filter((e) => e.expiresAt > this.tick);30443045 // Peddler sales (offers ascending id): want coin, open counterparty.3046 for (let i = 0; i < this.offers.length; i++) {3047 const o = this.offers[i]!;3048 if (o.want !== RI.coin || o.counterparty !== -1) continue;3049 const tribe = this.tribes[o.tribe]!;3050 if (!tribe.alive) continue;3051 const ms = this.marketSettlementOf(o.tribe);3052 if (ms === null) continue;3053 const amt = Math.min(o.giveAmount, ms.stores[o.give]!);3054 if (amt <= 0) continue;3055 const rate = this.hasTech(o.tribe, C.TECH_IDX.coinage)3056 ? C.PEDDLER_RATE_COINAGE3057 : C.PEDDLER_RATE;3058 const coins = Math.trunc((amt * C.RESOURCE_VALUE[RESOURCES[o.give]!] * rate) / 1000);3059 ms.stores[o.give]! -= amt;3060 this.addStore(ms, RI.coin, coins);3061 tribe.tradeVolume += amt;3062 tribe.coinEarned += coins;3063 if (!o.repeat) {3064 this.offers.splice(i, 1);3065 i--;3066 }3067 }30683069 // Gifts/tribute: wantAmount 0 with a NAMED counterparty delivers3070 // one-way by caravan (both sides need markets). Documented since v2 but3071 // previously dead — matching skipped zero-quantity crosses. Gifted coin3072 // does not count as earned: it moves goods, never score (collusion-3073 // proofing the ladder by construction).3074 for (let i = 0; i < this.offers.length; i++) {3075 const o = this.offers[i]!;3076 if (o.wantAmount !== 0 || o.counterparty === -1) continue;3077 const giver = this.tribes[o.tribe]!;3078 const taker = this.tribes[o.counterparty];3079 if (!giver.alive || taker === undefined || !taker.alive) continue;3080 if (giver.stances[o.counterparty] === P_HOSTILE) continue;3081 const msG = this.marketSettlementOf(o.tribe);3082 const msT = this.marketSettlementOf(o.counterparty);3083 if (msG === null || msT === null) continue;3084 const amt = Math.min(o.giveAmount, msG.stores[o.give]!);3085 if (amt <= 0) continue;3086 msG.stores[o.give]! -= amt;3087 this.caravans.push({3088 id: this.nextCaravanId++,3089 tribe: o.counterparty,3090 x: msG.x,3091 y: msG.y,3092 res: o.give,3093 amount: amt,3094 destId: msT.id,3095 });3096 if (!o.repeat) {3097 this.offers.splice(i, 1);3098 i--;3099 }3100 }31013102 // Matching (canonical: ascending offer id pairs; one match per offer per3103 // slow tick; midpoint integer clearing; caravans both ways).3104 const matched = new Set<number>();3105 for (let i = 0; i < this.offers.length; i++) {3106 const a = this.offers[i]!;3107 if (matched.has(a.id)) continue;3108 if (a.want === RI.coin && a.counterparty === -1) continue; // peddler-only3109 const ta = this.tribes[a.tribe]!;3110 if (!ta.alive) continue;3111 const msA = this.marketSettlementOf(a.tribe);3112 if (msA === null) continue;3113 for (let j = 0; j < this.offers.length; j++) {3114 if (j === i) continue;3115 const b = this.offers[j]!;3116 if (matched.has(b.id) || b.tribe === a.tribe) continue;3117 if (b.give !== a.want || b.want !== a.give) continue;3118 if (a.counterparty !== -1 && a.counterparty !== b.tribe) continue;3119 if (b.counterparty !== -1 && b.counterparty !== a.tribe) continue;3120 const tb = this.tribes[b.tribe]!;3121 if (!tb.alive) continue;3122 if (ta.stances[b.tribe] === P_HOSTILE || tb.stances[a.tribe] === P_HOSTILE) continue;3123 // Price cross: A bids giveA/wantA, B asks wantB/giveB (in A.give units).3124 if (a.giveAmount * b.giveAmount < a.wantAmount * b.wantAmount) continue;3125 const msB = this.marketSettlementOf(b.tribe);3126 if (msB === null) continue;3127 const q = Math.min(a.wantAmount, b.giveAmount);3128 const pay = Math.trunc(3129 (q * (a.giveAmount * b.giveAmount + a.wantAmount * b.wantAmount)) /3130 (2 * a.wantAmount * b.giveAmount),3131 );3132 if (pay <= 0 || q <= 0) continue;3133 if (msA.stores[a.give]! < pay || msB.stores[b.give]! < q) continue;3134 msA.stores[a.give]! -= pay;3135 msB.stores[b.give]! -= q;3136 // Goods travel by caravan to the buyer's market; the caravan belongs3137 // to the receiving tribe.3138 this.caravans.push({3139 id: this.nextCaravanId++,3140 tribe: b.tribe,3141 x: msA.x,3142 y: msA.y,3143 res: a.give,3144 amount: pay,3145 destId: msB.id,3146 });3147 this.caravans.push({3148 id: this.nextCaravanId++,3149 tribe: a.tribe,3150 x: msB.x,3151 y: msB.y,3152 res: b.give,3153 amount: q,3154 destId: msA.id,3155 });3156 ta.tradeVolume += pay + q;3157 tb.tradeVolume += pay + q;3158 if (b.give === RI.coin) ta.coinEarned += q; // a receives coin3159 if (a.give === RI.coin) tb.coinEarned += pay; // b receives coin3160 matched.add(a.id);3161 matched.add(b.id);3162 break;3163 }3164 }3165 for (let i = this.offers.length - 1; i >= 0; i--) {3166 const o = this.offers[i]!;3167 if (matched.has(o.id) && !o.repeat) this.offers.splice(i, 1);3168 }3169 }31703171 // -- sightings ------------------------------------------------------------31723173 /** Fill the scratch mask with this tribe's current vision. */3174 private visionMask(tribeId: number): Uint8Array {3175 const mask = this.scratchMask;3176 mask.fill(0);3177 const s = this.size;3178 const mark = (x: number, y: number, r: number): void => {3179 const x0 = Math.max(0, x - r);3180 const x1 = Math.min(s - 1, x + r);3181 const y0 = Math.max(0, y - r);3182 const y1 = Math.min(s - 1, y + r);3183 for (let yy = y0; yy <= y1; yy++) {3184 const row = yy * s;3185 for (let xx = x0; xx <= x1; xx++) mask[row + xx] = 1;3186 }3187 };3188 for (const t of this.settlements) {3189 if (t.tribe === tribeId) mark(t.x, t.y, t.radius + C.SETTLEMENT_VISION_BONUS);3190 }3191 for (const w of this.warbands) if (w.tribe === tribeId) mark(w.x, w.y, C.UNIT_VISION);3192 for (const c of this.caravans) if (c.tribe === tribeId) mark(c.x, c.y, C.UNIT_VISION);3193 for (const wg of this.wagons) if (wg.tribe === tribeId) mark(wg.x, wg.y, C.UNIT_VISION);3194 return mask;3195 }31963197 /** True when `t`'s smoke is within sight of any of `obs`'s eyes: the3198 * column reads at SMOKE_RANGE[tier] from the observer's settlements and3199 * warbands, regardless of terrain vision. */3200 private smokeVisible(obs: number, t: Settlement): boolean {3201 const range = C.SMOKE_RANGE[t.tier]!;3202 for (const mine of this.settlements) {3203 if (mine.tribe !== obs) continue;3204 if (cheb(mine.x, mine.y, t.x, t.y) <= range) return true;3205 }3206 for (const w of this.warbands) {3207 if (w.tribe !== obs) continue;3208 if (cheb(w.x, w.y, t.x, t.y) <= range) return true;3209 }3210 return false;3211 }32123213 private emitSightings(): void {3214 const n = this.tribes.length;3215 const s = this.size;3216 for (let obs = 0; obs < n; obs++) {3217 if (!this.tribes[obs]!.alive) continue;3218 let mask: Uint8Array | null = null;3219 for (let enemy = 0; enemy < n; enemy++) {3220 if (enemy === obs || !this.tribes[enemy]!.alive) continue;3221 if (this.tick - this.lastSighted[obs * n + enemy]! < C.SIGHT_COOLDOWN) continue;3222 mask ??= this.visionMask(obs);3223 let sx = -1;3224 let sy = -1;3225 for (const w of this.warbands) {3226 if (w.tribe !== enemy) continue;3227 if (mask[w.y * s + w.x] === 1) {3228 sx = w.x;3229 sy = w.y;3230 break;3231 }3232 }3233 if (sx === -1) {3234 for (const t of this.settlements) {3235 if (t.tribe !== enemy) continue;3236 if (mask[t.y * s + t.x] === 1 || this.smokeVisible(obs, t)) {3237 sx = t.x;3238 sy = t.y;3239 break;3240 }3241 }3242 }3243 if (sx === -1) continue;3244 this.lastSighted[obs * n + enemy] = this.tick;3245 this.events.push({ t: 'sighted', tick: this.tick, tribe: obs, enemy, x: sx, y: sy });3246 // The observer's mask lives in the shared scratch buffer; visionMask3247 // recomputes it for the next observer.3248 }3249 }3250 }32513252 // -- views ----------------------------------------------------------------32533254 viewFor(tribeId: number): TribeView {3255 const tribe = this.tribes[tribeId];3256 if (tribe === undefined) throw new RangeError(`no such tribe: ${tribeId}`);3257 const s = this.size;3258 const visible = this.visionMask(tribeId);32593260 let terrainStr = tribe.terrainCache;3261 if (tribe.exploredDirty || terrainStr === null) {3262 const chars = new Array<string>(s * s);3263 const ex = tribe.explored;3264 for (let i = 0; i < s * s; i++) {3265 chars[i] = ex[i] === 1 ? C.TERRAIN_CHARS[this.terrain[i]!]! : '?';3266 }3267 terrainStr = chars.join('');3268 tribe.terrainCache = terrainStr;3269 tribe.exploredDirty = false;3270 tribe.glimpsedDirty = true; // explored changes shrink the distant band3271 }32723273 let distantStr = tribe.distantCache;3274 if (tribe.glimpsedDirty || distantStr === null) {3275 const chars = new Array<string>(s * s);3276 const ex = tribe.explored;3277 const gl = tribe.glimpsed;3278 for (let i = 0; i < s * s; i++) {3279 chars[i] =3280 gl[i] === 1 && ex[i] === 0 ? C.DISTANT_CHARS[this.terrain[i]!]! : '?';3281 }3282 distantStr = chars.join('');3283 tribe.distantCache = distantStr;3284 tribe.glimpsedDirty = false;3285 }32863287 let claimsStr = tribe.claimsCache;3288 if (3289 claimsStr === null ||3290 tribe.claimsCacheVersion !== this.claimsVersion ||3291 tribe.exploredDirty3292 ) {3293 // Owner tribe char over explored ground; '.' elsewhere.3294 const chars = new Array<string>(s * s);3295 const ex = tribe.explored;3296 const settlementTribe = new Map<number, number>();3297 for (const st of this.settlements) settlementTribe.set(st.id, st.tribe);3298 for (let i = 0; i < s * s; i++) {3299 const owner = ex[i] === 1 ? this.claims[i]! : -1;3300 const ownerTribe = owner === -1 ? -1 : settlementTribe.get(owner) ?? -1;3301 chars[i] = ownerTribe === -1 ? '.' : String(ownerTribe);3302 }3303 claimsStr = chars.join('');3304 tribe.claimsCache = claimsStr;3305 tribe.claimsCacheVersion = this.claimsVersion;3306 }33073308 const settlements: SettlementView[] = [];3309 for (const t of this.settlements) {3310 if (t.tribe !== tribeId) continue;3311 const stores = {} as Record<Resource, number>;3312 for (let r = 0; r < R_N; r++) stores[RESOURCES[r]!] = t.stores[r]!;3313 const buildings: Partial<Record<Building, number>> = {};3314 for (let b = 0; b < B_N; b++) {3315 if (t.buildings[b]! > 0) buildings[BUILDINGS[b]!] = t.buildings[b]!;3316 }3317 const workers: Partial<Record<Profession, number>> = {};3318 const capacity: Partial<Record<Profession, number>> = {};3319 for (let p = 0; p < P_N; p++) {3320 if (t.workers[p]! > 0) workers[PROFESSIONS[p]!] = t.workers[p]!;3321 if (t.capacity[p]! > 0) capacity[PROFESSIONS[p]!] = t.capacity[p]!;3322 }3323 settlements.push({3324 id: t.id,3325 x: t.x,3326 y: t.y,3327 tier: t.tier,3328 pop: t.pop,3329 morale: t.morale,3330 stores,3331 storeCap: this.storeCapOf(t),3332 buildings,3333 buildQueue: t.queue.map((q) => ({ building: BUILDINGS[q.b]!, progress: q.progress })),3334 territoryTiles: t.territoryTiles,3335 workers,3336 capacity,3337 herds: t.herds,3338 horses: t.horses,3339 });3340 }33413342 const warbands: WarbandView[] = [];3343 for (const w of this.warbands) {3344 if (w.tribe !== tribeId) continue;3345 let target: WarbandOrder['target'] = null;3346 const type = WO_NAMES[w.order.type]!;3347 if (type === 'march' || type === 'patrol') target = { x: w.order.tx, y: w.order.ty };3348 else if (type !== 'disband') target = w.order.targetId;3349 warbands.push({3350 id: w.id,3351 home: w.home,3352 size: w.size,3353 kind: WB_KINDS[w.kind]!,3354 weapons: w.weapons,3355 armor: w.armor,3356 veterancy: w.vet,3357 morale: w.morale,3358 x: w.x,3359 y: w.y,3360 order: { type, target },3361 supply: w.supply,3362 drilling: w.drill,3363 });3364 }33653366 const caravans: CaravanView[] = [];3367 for (const c of this.caravans) {3368 if (c.tribe === tribeId || visible[c.y * s + c.x] === 1) {3369 caravans.push({3370 id: c.id,3371 tribe: c.tribe,3372 x: c.x,3373 y: c.y,3374 resource: RESOURCES[c.res]!,3375 amount: c.amount,3376 });3377 }3378 }3379 // Settler wagons appear as caravans carrying their grain supplies.3380 for (const wg of this.wagons) {3381 if (wg.tribe === tribeId || visible[wg.y * s + wg.x] === 1) {3382 caravans.push({3383 id: wg.id,3384 tribe: wg.tribe,3385 x: wg.x,3386 y: wg.y,3387 resource: 'grain',3388 amount: wg.supplies[RI.grain]!,3389 });3390 }3391 }33923393 const threats: ThreatView[] = [];3394 for (const camp of this.camps) {3395 if (visible[camp.y * s + camp.x] === 1) {3396 threats.push({3397 id: camp.id,3398 kind: THREAT_KINDS[camp.kind]!,3399 x: camp.x,3400 y: camp.y,3401 strength: camp.strength,3402 });3403 }3404 }34053406 const enemySettlements: EnemySettlementSighting[] = [];3407 for (const t of this.settlements) {3408 if (t.tribe === tribeId) continue;3409 // Direct vision, or the smoke column on the horizon.3410 if (visible[t.y * s + t.x] === 1 || this.smokeVisible(tribeId, t)) {3411 enemySettlements.push({3412 tribe: t.tribe,3413 id: t.id,3414 x: t.x,3415 y: t.y,3416 tier: t.tier,3417 wallLevel: t.buildings[BI.walls]!,3418 });3419 }3420 }3421 const enemyWarbands: EnemyWarbandSighting[] = [];3422 for (const w of this.warbands) {3423 if (w.tribe === tribeId) continue;3424 if (visible[w.y * s + w.x] === 1) {3425 enemyWarbands.push({3426 tribe: w.tribe,3427 id: w.id,3428 x: w.x,3429 y: w.y,3430 size: w.size,3431 kind: WB_KINDS[w.kind]!,3432 });3433 }3434 }34353436 const hasMarket = this.marketSettlementOf(tribeId) !== null;3437 const offers: TradeOfferView[] = [];3438 for (const o of this.offers) {3439 const foreign = o.tribe !== tribeId;3440 if (foreign) {3441 if (!hasMarket) continue;3442 if (!this.tribes[o.tribe]!.alive) continue;3443 if (o.counterparty !== -1 && o.counterparty !== tribeId) continue;3444 if (3445 tribe.stances[o.tribe] === P_HOSTILE ||3446 this.tribes[o.tribe]!.stances[tribeId] === P_HOSTILE3447 ) {3448 continue;3449 }3450 }3451 offers.push({3452 id: o.id,3453 tribe: o.tribe,3454 give: RESOURCES[o.give]!,3455 giveAmount: o.giveAmount,3456 want: RESOURCES[o.want]!,3457 wantAmount: o.wantAmount,3458 counterparty: o.counterparty === -1 ? null : o.counterparty,3459 repeat: o.repeat,3460 });3461 }34623463 const techs: Tech[] = [];3464 for (let t = 0; t < TECHS.length; t++) {3465 if ((tribe.techMask & (1 << t)) !== 0) techs.push(TECHS[t]!);3466 }3467 const craft = {} as Record<CraftTarget, number>;3468 CRAFT_KEYS.forEach((k, i) => {3469 craft[k] = tribe.craft[i]!;3470 });3471 const decrees = {} as Record<DecreeKey, number>;3472 DECREE_KEYS.forEach((k, i) => {3473 decrees[k] = tribe.decrees[i]!;3474 });3475 const allocations = {} as Record<Profession, number>;3476 PROFESSIONS.forEach((p, i) => {3477 allocations[p] = tribe.alloc[i]!;3478 });3479 const stances: Record<number, Posture> = {};3480 for (let t = 0; t < this.tribes.length; t++) {3481 if (t !== tribeId) stances[t] = POSTURES[tribe.stances[t]!]!;3482 }34833484 // Fog-filtered events (involved OR say OR inside current vision).3485 const events: SimEvent[] = [];3486 for (const e of this.events) {3487 let deliver =3488 e.t === 'say' ||3489 ('tribe' in e && e.tribe === tribeId) ||3490 ('by' in e && e.by === tribeId) ||3491 ('enemy' in e && e.enemy === tribeId) ||3492 (e.t === 'battle' && (e.attacker === tribeId || e.defender === tribeId));3493 if (!deliver && 'x' in e && 'y' in e) deliver = visible[e.y * s + e.x] === 1;3494 if (deliver) events.push('loot' in e ? { ...e, loot: { ...e.loot } } : { ...e });3495 }3496 if (events.length > LIMITS.EVENTS_PER_VIEW) {3497 events.splice(0, events.length - LIMITS.EVENTS_PER_VIEW);3498 }34993500 return {3501 tick: this.tick,3502 tribeId,3503 mapSize: s,3504 terrain: terrainStr,3505 distant: distantStr,3506 claims: claimsStr,3507 age: tribe.age,3508 settlements,3509 warbands,3510 caravans,3511 threats,3512 enemySettlements,3513 enemyWarbands,3514 offers,3515 techs,3516 researching: tribe.researching === -1 ? null : TECHS[tribe.researching]!,3517 knowledge: tribe.knowledge,3518 faith: tribe.faith,3519 craft,3520 decrees,3521 allocations,3522 stances,3523 aliveTribes: this.tribes.filter((t) => t.alive).map((t) => t.id),3524 events,3525 };3526 }35273528 // -- metrics, score, results ----------------------------------------------35293530 private warbandPowerOf(tribeId: number): number {3531 let p = 0;3532 for (const w of this.warbands) {3533 if (w.tribe === tribeId) p += this.warbandPower(w, -1, 0);3534 }3535 return p;3536 }35373538 private scoreBreakdownOf(tribeId: number): TribeMetrics['scoreBreakdown'] {3539 const tribe = this.tribes[tribeId]!;3540 let pop = 0;3541 let coin = 0;3542 let nSettlements = 0;3543 for (const s of this.settlements) {3544 if (s.tribe !== tribeId) continue;3545 pop += s.pop;3546 coin += s.stores[RI.coin]!;3547 nSettlements++;3548 }3549 let techs = 0;3550 let m = tribe.techMask;3551 while (m !== 0) {3552 techs += m & 1;3553 m >>>= 1;3554 }3555 // Buildings: STANDING structures with a usefulness audit. Houses score3556 // only while occupied; other types pay full up to a per-settlement cap,3557 // half beyond. (The old formula scored a lifetime completion counter —3558 // demolished spam paid forever, and 50-60% of tournament wins were3559 // building points.)3560 let buildingPts = 0;3561 let settlementPts = 0;3562 let defensePts = 0;3563 for (const st of this.settlements) {3564 if (st.tribe !== tribeId) continue;3565 settlementPts += C.SCORE_SETTLEMENT_BASE + C.SCORE_SETTLEMENT_PER_TIER * st.tier;3566 const occupiedHouses = Math.min(3567 st.buildings[BI.house]!,3568 Math.ceil(st.pop / C.HOUSE_HOUSING),3569 );3570 buildingPts += occupiedHouses * C.SCORE_PER_BUILDING;3571 for (let b = 0; b < B_N; b++) {3572 if (b === BI.house || b === BI.walls) continue;3573 const n = st.buildings[b]!;3574 if (n <= 0) continue;3575 const full = Math.min(n, C.SCORE_BUILDING_TYPE_CAP);3576 buildingPts += full * C.SCORE_PER_BUILDING + (n - full);3577 }3578 defensePts +=3579 st.buildings[BI.walls]! * C.SCORE_WALL_LEVEL + st.buildings[BI.tower]! * C.SCORE_TOWER;3580 }3581 return {3582 pop: Math.trunc(pop / C.SCORE_POP_DIV),3583 settlements: settlementPts,3584 techs: techs * C.SCORE_PER_TECH,3585 military: Math.trunc(this.warbandPowerOf(tribeId) / C.SCORE_POWER_DIV) + defensePts,3586 coin:3587 Math.trunc(Math.min(coin, tribe.coinEarned) / C.SCORE_COIN_DIV) +3588 Math.trunc(tribe.tradeVolume / C.SCORE_TRADE_DIV),3589 buildings: buildingPts,3590 age: tribe.age * C.SCORE_PER_AGE,3591 };3592 }35933594 private scoreOf(tribeId: number): number {3595 const b = this.scoreBreakdownOf(tribeId);3596 // No total multiplier: ages pay through the flat term and rate bonuses3597 // (a multiplier made free speculative advance() spam a compounding3598 // lottery on every other point).3599 return b.pop + b.settlements + b.techs + b.military + b.coin + b.buildings + b.age;3600 }36013602 private computeMetrics(): MetricsFrame {3603 const frame: MetricsFrame = { tick: this.tick, tribes: [] };3604 const mask = this.scratchMask;3605 for (const tribe of this.tribes) {3606 let pop = 0;3607 let moraleWeighted = 0;3608 let soldiers = 0;3609 let coin = 0;3610 const tiers: [number, number, number, number] = [0, 0, 0, 0];3611 const workerTotals = new Int32Array(P_N);3612 const stores = {} as Record<Resource, number>;3613 for (let r = 0; r < R_N; r++) stores[RESOURCES[r]!] = 0;3614 mask.fill(0);3615 for (const s of this.settlements) {3616 if (s.tribe !== tribe.id) continue;3617 pop += s.pop;3618 moraleWeighted += s.pop * s.morale;3619 tiers[s.tier]! += 1;3620 coin += s.stores[RI.coin]!;3621 for (let r = 0; r < R_N; r++) stores[RESOURCES[r]!]! += s.stores[r]!;3622 for (let p = 0; p < P_N; p++) workerTotals[p]! += s.workers[p]!;3623 const x0 = Math.max(0, s.x - s.radius);3624 const x1 = Math.min(this.size - 1, s.x + s.radius);3625 const y0 = Math.max(0, s.y - s.radius);3626 const y1 = Math.min(this.size - 1, s.y + s.radius);3627 for (let yy = y0; yy <= y1; yy++) {3628 const row = yy * this.size;3629 for (let xx = x0; xx <= x1; xx++) mask[row + xx] = 1;3630 }3631 }3632 let territory = 0;3633 for (const st of this.settlements) if (st.tribe === tribe.id) territory += st.territoryTiles;3634 let nWarbands = 0;3635 for (const w of this.warbands) {3636 if (w.tribe === tribe.id) {3637 nWarbands++;3638 soldiers += w.size;3639 }3640 }3641 const popByProfession = {} as Record<Profession, number>;3642 let working = 0;3643 for (let p = 0; p < P_N; p++) {3644 popByProfession[PROFESSIONS[p]!] = workerTotals[p]!;3645 working += workerTotals[p]!;3646 }3647 const production = {} as Record<Resource, number>;3648 const consumption = {} as Record<Resource, number>;3649 for (let r = 0; r < R_N; r++) {3650 production[RESOURCES[r]!] = tribe.prodI[r]!;3651 consumption[RESOURCES[r]!] = tribe.consI[r]!;3652 }3653 let techs = 0;3654 let m = tribe.techMask;3655 while (m !== 0) {3656 techs += m & 1;3657 m >>>= 1;3658 }3659 const breakdown = this.scoreBreakdownOf(tribe.id);3660 frame.tribes.push({3661 tribeId: tribe.id,3662 alive: tribe.alive,3663 pop,3664 popByProfession,3665 idle: Math.max(0, pop - working),3666 soldiers,3667 moraleAvg: pop > 0 ? Math.trunc(moraleWeighted / pop) : 0,3668 births: tribe.birthsI,3669 deaths: tribe.deathsI,3670 starvationDeaths: tribe.starvI,3671 stores,3672 production,3673 consumption,3674 toolCoverage:3675 tribe.totalExtractors > 03676 ? Math.trunc((tribe.coveredExtractors * 1000) / tribe.totalExtractors)3677 : 0,3678 warbands: nWarbands,3679 warbandPower: this.warbandPowerOf(tribe.id),3680 battlesWon: tribe.battlesWon,3681 battlesLost: tribe.battlesLost,3682 casualtiesDealt: tribe.casDealt,3683 casualtiesTaken: tribe.casTaken,3684 campsCleared: tribe.campsCleared,3685 territoryTiles: territory,3686 settlementsByTier: tiers,3687 buildingsCompleted: tribe.buildingsCompleted,3688 techsUnlocked: techs,3689 age: tribe.age,3690 knowledge: tribe.knowledge,3691 faith: tribe.faith,3692 coin,3693 tradeVolume: tribe.tradeVolume,3694 caravansLost: tribe.caravansLost,3695 score: breakdown.pop + breakdown.settlements + breakdown.techs + breakdown.military + breakdown.coin + breakdown.buildings,3696 scoreBreakdown: breakdown,3697 });3698 }3699 return frame;3700 }37013702 latestMetrics(): MetricsFrame | null {3703 return this.metrics;3704 }37053706 isOver(): boolean {3707 return this.over;3708 }37093710 result(): SimResult {3711 const tribes: TribeResult[] = this.tribes.map((t) => {3712 let pop = 0;3713 let nSettlements = 0;3714 for (const s of this.settlements) {3715 if (s.tribe === t.id) {3716 pop += s.pop;3717 nSettlements++;3718 }3719 }3720 return {3721 tribeId: t.id,3722 alive: t.alive,3723 score: this.scoreOf(t.id),3724 scoreBreakdown: this.scoreBreakdownOf(t.id),3725 pop,3726 settlements: nSettlements,3727 casualtiesDealt: t.casDealt,3728 diedAt: t.diedAt === -1 ? null : t.diedAt,3729 };3730 });3731 const alive = tribes.filter((t) => t.alive);3732 let winner: number | null = null;3733 if (this.endReason === 'last_standing') {3734 winner = alive.length === 1 ? alive[0]!.tribeId : null;3735 } else {3736 let best = -1;3737 let tie = false;3738 for (const t of alive) {3739 if (t.score > best) {3740 best = t.score;3741 winner = t.tribeId;3742 tie = false;3743 } else if (t.score === best) {3744 tie = true;3745 }3746 }3747 if (tie || alive.length === 0) winner = null;3748 }3749 return { endedAt: this.tick, reason: this.endReason, winner, tribes };3750 }37513752 // -- hash -----------------------------------------------------------------37533754 stateHash(): string {3755 const h = new Hasher();3756 h.int(this.tick);3757 h.int(this.rng.state);3758 h.int(this.over ? 1 : 0);3759 h.int(this.nextSettlementId);3760 h.int(this.nextWarbandId);3761 h.int(this.nextCaravanId);3762 h.int(this.nextCampId);3763 h.int(this.nextOfferId);3764 h.bytes(this.terrain);3765 for (const t of this.tribes) {3766 h.int(t.alive ? 1 : 0);3767 h.int(t.diedAt);3768 for (let p = 0; p < P_N; p++) h.int(t.alloc[p]!);3769 for (let i = 0; i < 6; i++) h.int(t.craft[i]!);3770 for (let i = 0; i < DECREE_KEYS.length; i++) h.int(t.decrees[i]!);3771 for (let i = 0; i < t.stances.length; i++) h.byte(t.stances[i]!);3772 h.int(t.techMask);3773 h.int(t.researching);3774 h.int(t.knowledge);3775 h.int(t.age);3776 h.int(t.faith);3777 h.int(t.knowAcc);3778 h.int(t.faithAcc);3779 h.int(t.buildingsCompleted);3780 h.int(t.coinEarned);3781 h.int(t.routes.length);3782 for (const r of t.routes) {3783 h.int(r.fromId);3784 h.int(r.toId);3785 h.int(r.res);3786 h.int(r.permille);3787 }3788 h.bytes(t.explored);3789 h.bytes(t.glimpsed);3790 }3791 for (const s of this.settlements) {3792 h.int(s.id);3793 h.int(s.tribe);3794 h.int(s.x);3795 h.int(s.y);3796 h.int(s.tier);3797 h.int(s.pop);3798 h.int(s.morale);3799 h.int(s.radius);3800 h.int(s.herds);3801 h.int(s.horses);3802 h.int(s.wallDamage);3803 h.int(s.kindsEaten);3804 h.int(s.flags);3805 for (let r = 0; r < R_N; r++) h.int(s.stores[r]!);3806 for (let b = 0; b < B_N; b++) h.int(s.buildings[b]!);3807 for (let a = 0; a < ACC_N; a++) h.int(s.acc[a]!);3808 for (let p = 0; p < P_N; p++) h.int(s.allocOverride[p]!);3809 h.int(s.queue.length);3810 for (const q of s.queue) {3811 h.int(q.b);3812 h.int(q.progress);3813 h.int(q.req);3814 }3815 }3816 for (const w of this.warbands) {3817 h.int(w.id);3818 h.int(w.tribe);3819 h.int(w.home);3820 h.int(w.size);3821 h.int(w.kind);3822 h.int(w.weapons);3823 h.int(w.armor);3824 h.int(w.horses);3825 h.int(w.vet);3826 h.int(w.morale);3827 h.int(w.x);3828 h.int(w.y);3829 h.int(w.supply);3830 h.int(w.siege ? 1 : 0);3831 h.int(w.order.type);3832 h.int(w.order.targetId);3833 h.int(w.order.tx);3834 h.int(w.order.ty);3835 h.int(w.desertAcc);3836 h.int(w.drill);3837 }3838 for (const c of this.caravans) {3839 h.int(c.id);3840 h.int(c.tribe);3841 h.int(c.x);3842 h.int(c.y);3843 h.int(c.res);3844 h.int(c.amount);3845 h.int(c.destId);3846 }3847 for (const wg of this.wagons) {3848 h.int(wg.id);3849 h.int(wg.tribe);3850 h.int(wg.x);3851 h.int(wg.y);3852 h.int(wg.tx);3853 h.int(wg.ty);3854 h.int(wg.settlers);3855 for (let r = 0; r < R_N; r++) h.int(wg.supplies[r]!);3856 }3857 for (const camp of this.camps) {3858 h.int(camp.id);3859 h.int(camp.kind);3860 h.int(camp.x);3861 h.int(camp.y);3862 h.int(camp.strength);3863 h.int(camp.nextRaidAt);3864 }3865 for (const e of this.effects) {3866 h.int(e.kind);3867 h.int(e.tribe);3868 h.int(e.target);3869 h.int(e.expiresAt);3870 }3871 for (const o of this.offers) {3872 h.int(o.id);3873 h.int(o.tribe);3874 h.int(o.give);3875 h.int(o.giveAmount);3876 h.int(o.want);3877 h.int(o.wantAmount);3878 h.int(o.counterparty);3879 h.int(o.repeat ? 1 : 0);3880 }3881 return h.digest();3882 }3883}38843885export const createWorld: CreateWorld = (config: SimConfig) => new World(config);