Driven by two independent agent-fleet tournament reports (72 controlled sims, converging findings). Nine signals addressed: THE TOOL FAMINE (P0): all 8 tournament scripts across both fleets ended every sim at 0% tool coverage — the documented economic spine was unreachable from the docs. Root causes fixed: (1) spawn fairness now guarantees forest/hills/ore CLAIMABLE at tier-0 budget, not merely visible at radius 8; (2) ore rate scales with claimed veins (60 + 30/extra); (3) the smithy craft split uses fixed-point accumulators — a 400-permille weapons split actually yields weapons (largest-remainder gave every unit of tiny output to the heaviest target). A docs-only naive bot now builds the full chain and arms itself. LEGIBILITY (P0): build/found/muster/advance drops emit order_dropped events with the reason (capped 8/tick). Docs warn that sequential plans + silent drops are the classic freeze; primitives ships planBuilder which reads the events and skips blocked steps. Feeds render the reasons. SCORING (P1): buildings score STANDING structures with a usefulness audit (occupied houses only; per-type cap 8 full then half) instead of a lifetime completion counter; settlements score 10+10*tier instead of flat 40; defense scores (walls/towers in the military term, and fortification combat power x2.5 so investment repels); the age total-score multiplier is retired (flat 150/age + the real rate bonuses) killing the advance() spam lottery; coin scores min(coin, coinEarned)/20 + tradeVolume/40. MARKET & LADDER (P2): wantAmount:0 gifts/tribute actually deliver now (documented since v2, mechanically dead — matching skipped zero crosses); gifted coin is score-inert by construction, closing the flagged Elo collusion vector before it ever worked. Peddler pays 400/600 permille. The nightly ladder plays every lineup in BOTH slot orders, cancelling the spawn/slot variance that swamped strategy (1064 vs 48 same code+seed). Also: expansion decree was missing from the state hash (latent since it shipped); goldens repinned; isolation re-verified (1v1 0% never-meet, FFA 14% pair-never). Known residual: development under sustained raid pressure still suppresses harder than defense can answer — a dedicated anti-suppression pass (raid diminishing returns) is future work.
- commit
- afc57cb6d8d38890d27cf25bb215bad2de9c7da4
- parent
- 2b9f6ca4b1a7171f3340653b8b4eddd928786e35
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 47 | +++++-- |
| M | packages/ | 42 | ++++- |
| M | packages/ | 82 | +++++++++++- |
| M | packages/ | 226 | ++++++++++++++++++++++++------ |
| M | packages/ | 33 | +++- |
| M | packages/ | 9 | + |
| M | packages/ | 38 | +++++ |
| M | packages/ | 38 | +++++ |
| M | web/ | 2 | +- |
| M | web/ | 5 | + |
| M | worker/ | 4 | + |
| M | worker/ | 47 | +++++-- |
| M | worker/ | 2 | +- |
| M | worker/ | 30 | +++- |
@@ -247,9 +247,15 @@ function tick(state, memory, api) { /* emit orders via api.* */ }
247247 fatal — the next tick runs normally — but an erroring tick wastes your turn.248248 Because policy persists, a wasted tick usually just means "no adjustments".249249 - Invalid orders (bad ids, unaffordable costs, illegal placement, not your250- warband, missing tech, ...) are **silently dropped** by the engine and251- tallied. Emitting them costs nothing but does nothing — speculative orders252- are a cheap idiom.250+ warband, missing tech, ...) are **dropped** by the engine — and for the251+ trap-prone verbs (`build`, `found`, `muster`, `advance`) you get an252+ `order_dropped` event next tick with the REASON ("no claimed forest for a253+ lumber camp", "needs 30 weapons (have 4)", ...). READ THESE. Speculative254+ orders are still a cheap idiom, but a sequential build plan that stalls on255+ one silently-impossible step is the single most common way scripts freeze:256+ never gate your whole plan behind one order — check `order_dropped`, skip257+ the blocked step, and move on (the `primitives` example library ships a258+ `planBuilder` helper that does exactly this).253259 254260 ### The ontology: 20 resources in dependency chains255261 @@ -468,7 +474,8 @@ v2. One-shot verbs act once and are done. All are subject to the 64-order
468474 | `api.route(fromId, toId, resource, permilleOfSurplus)` | `route` | **Policy.** Standing internal haul between two of your settlements: moves that share of the source's surplus each interval (haulers do the moving). Re-issue to change; `0` removes. Max 24 routes. |469475 | `api.muster(settlementId, size, kind)` | `muster` | One-shot: remove `size` population into a new warband (`'infantry'\|'archers'\|'cavalry'`). Requires `size` **weapons** in that settlement's stores; armor is equipped if stocked; cavalry needs horses (and horsemanship). |470476 | `api.command(warbandId, order)` | `command` | **Standing order.** `order = {type, target}` — see "Military". Persists until replaced or completed. |471-| `api.trade({give, giveAmount, want, wantAmount, tribe, repeat})` | `trade_offer` | **Standing offer** (max 16), passed as one object. `tribe: null` = anyone non-hostile; a specific id = only that tribe. `wantAmount: 0` makes it a gift/tribute. `repeat: true` re-arms after each fill. Delivery is physical, by caravan — raidable. |477+| `api.trade({give, giveAmount, want, wantAmount, tribe, repeat})` | `trade_offer` | **Standing offer** (max 16), passed as one object. `tribe: null` = anyone non-hostile; a specific id = only that tribe. `wantAmount: 0` with a NAMED tribe makes it a one-way gift/tribute caravan478+(both sides need markets). Gifted coin never counts toward score. `repeat: true` re-arms after each fill. Delivery is physical, by caravan — raidable. |472479 | `api.cancelTrade(offerId)` | `trade_cancel` | One-shot: withdraw one of your standing offers. |473480 | `api.stance(tribeId, posture)` | `stance` | **Policy.** `'ally' \| 'neutral' \| 'hostile'` toward a tribe. Gates trade matching and engagement rules. |474481 | `api.research(tech)` | `research` | Sets the research target (one at a time) until unlocked. |@@ -652,14 +659,30 @@ A rejected submission still creates a version — fix and resubmit.
652659 - **Win**: last tribe standing wins (`reason: 'last_standing'`), or at the653660 10,000-tick cap (`reason: 'tick_cap'`) the highest score wins; exact tie ⇒654661 `winner: null`.655-- **Score** (integer division):656- `pop/10 + settlements*40 + techs*25 + power/20 + coin/20 + buildings*2`657- where `power` is total warband power and `buildings` counts completed658- buildings. The per-term breakdown ships in every metrics frame as659- `scoreBreakdown: {pop, settlements, techs, military, coin, buildings}`.660- Score is broad on purpose: population, expansion, research, military, and661- wealth all pay — but a pure hermit economy still loses ground to a tribe662- that also settles, researches, and fields power.662+- **Score** (integer division; every term audited — the formula pays for663+ FUNCTION, not paperwork):664+ - `pop/10`.665+ - settlements: `10 + 10*tier` each — a bare hall camp is 10, a tier-3666+ city 40. Quantity alone stopped paying like quality.667+ - `techs*25`.668+ - military: `power/20` (all warbands, garrisons included) **plus defense**:669+ `8*wallLevel + 4*towers` per settlement. Standing a garrison behind670+ walls finally scores.671+ - coin: `min(coin, coinEarned)/20 + tradeVolume/40`. Only EARNED coin672+ scores (peddler, matched trades, gold veins) — gifted coin moves goods,673+ never score, so tribute rings cannot pump a ladder rating.674+ - buildings: STANDING buildings only, audited — houses score only while675+ occupied; other types pay full up to 8 per settlement and half beyond.676+ Demolished or destroyed buildings stop scoring. Empty monuments to the677+ formula are worthless.678+ - age: `150` flat per age. There is NO total-score multiplier anymore:679+ ages pay through this term and the real +10%/age production bonuses,680+ so speculative `advance()` spam is just an order that fails until you681+ can actually afford the hoard (and tells you why via `order_dropped`).682+ The per-term breakdown ships in every metrics frame as683+ `scoreBreakdown: {pop, settlements, techs, military, coin, buildings, age}`.684+ Score is broad on purpose: population, quality expansion, research,685+ military (offense OR defense), working trade, and prestige all pay.663686 - **Spectators watch the stats.** The viewer is metrics-first: every 10 ticks664687 the sim emits a `MetricsFrame` per tribe — population by profession, births665688 and starvation deaths, production/consumption per resource, `toolCoverage`,@@ -182,10 +182,12 @@ export const AGE_COSTS: ReadonlyArray<Partial<Record<import('@tribes/protocol').
182182 export const AGE_RATE_BONUS = [0, 100, 200, 300] as const;183183 /** Warband power bonus per soldier per age. */184184 export const AGE_POWER_BONUS = 3;185-/** Flat score term per age. */186-export const SCORE_PER_AGE = 60;187-/** Score multiplier per age, ‰ added (age 3 = x1.75 total score). */188-export const AGE_SCORE_MULT = 250;185+/** Flat score term per age (the multiplier is gone: a total-score186+ * multiplier made speculative advance() spam a free compounding lottery —187+ * ages now pay through this flat term and the real rate bonuses). */188+export const SCORE_PER_AGE = 150;189+/** Score multiplier per age, ‰ added. RETIRED at 0 — see SCORE_PER_AGE. */190+export const AGE_SCORE_MULT = 0;189191 190192 export const BASE_STORE_CAP = 2000;191193 /** Granary and warehouse each add to ONE shared per-resource cap (documented@@ -243,7 +245,10 @@ export const FARMER_RATE = 140; // grain (tuned up from 100: balance harness sho
243245 export const FISHER_RATE = 110; // fish (tuned with FARMER_RATE)244246 export const WOODCUTTER_RATE = 80; // timber245247 export const MINER_STONE_RATE = 60; // stone, always246-export const MINER_ORE_RATE = 40; // ore, iff >=1 'O' vein in territory248+export const MINER_ORE_RATE = 60; // ore, iff >=1 'O' vein in territory249+/** Each additional claimed vein (up to +3) raises the per-miner ore rate —250+ * prospecting for veins is a real mining strategy, not a checkbox. */251+export const MINER_ORE_RATE_PER_EXTRA_VEIN = 30;247252 export const MINER_COIN_RATE = 15; // coin, iff >=1 'G' vein in territory248253 249254 /** Extractive professions work at this ‰ yield without tools. */@@ -521,8 +526,12 @@ export const ROUT_MORALE_HIT = 100;
521526 export const PATROL_RADIUS = 6;522527 /** Settlement passive defense: pop/10 militia + wallLevel*100 + towers*50. */523528 export const MILITIA_POP_DIV = 10;524-export const WALL_DEFENSE = 100;525-export const TOWER_DEFENSE = 50;529+/** Fortification combat power. Tuned up in v0.3.1: with the war economy530+ * actually functioning, drilled+aged bands outscaled static defenses and531+ * pure suppression dominated — a wall level must matter like ~25 drilled532+ * soldiers, so defensive INVESTMENT is a real counter-build. */533+export const WALL_DEFENSE = 250;534+export const TOWER_DEFENSE = 120;526535 /** Raid carry capacity: units of loot per soldier. */527536 export const RAID_CARRY_PER_SOLDIER = 2;528537 /** A building is destroyed when attacker power >= 2x defense. */@@ -574,8 +583,8 @@ export const MIN_SETTLE_DIST = 6;
574583 export const FOUND_MIN_SETTLERS = 50;575584 576585 /** Peddler: instant sale at ‰ of base value; coinage improves it. */577-export const PEDDLER_RATE = 250;578-export const PEDDLER_RATE_COINAGE = 400;586+export const PEDDLER_RATE = 400;587+export const PEDDLER_RATE_COINAGE = 600;579588 580589 /** Base coin value per unit (peddler & trade-volume accounting). */581590 export const RESOURCE_VALUE: Record<Resource, number> = {@@ -623,11 +632,24 @@ export const SMOKE_RANGE = [12, 18, 26, 34] as const;
623632 // ---------------------------------------------------------------------------624633 625634 export const SCORE_POP_DIV = 10;626-export const SCORE_PER_SETTLEMENT = 40;635+/** Settlements score by QUALITY: base + per-tier (a bare hall camp is 10,636+ * a tier-3 city 40) — quantity alone no longer pays like quality. */637+export const SCORE_SETTLEMENT_BASE = 10;638+export const SCORE_SETTLEMENT_PER_TIER = 10;627639 export const SCORE_PER_TECH = 25;628640 export const SCORE_POWER_DIV = 20;629641 export const SCORE_COIN_DIV = 20;642+/** Trade volume also earns (half the coin rate): a working market scores643+ * even when the coin is spent again. */644+export const SCORE_TRADE_DIV = 40;630645 export const SCORE_PER_BUILDING = 2;646+/** Buildings score STANDING structures with a usefulness audit: houses only647+ * while occupied; other types at full value up to this many per settlement,648+ * half value beyond. Empty monuments to the formula stopped paying. */649+export const SCORE_BUILDING_TYPE_CAP = 8;650+/** Defense finally scores: wall levels and towers count as military. */651+export const SCORE_WALL_LEVEL = 25;652+export const SCORE_TOWER = 10;631653 632654 // ---------------------------------------------------------------------------633655 // Starting state (per tribe, single founding settlement)@@ -10,7 +10,7 @@
1010 * each founding site every tribe is guaranteed plains >= 20, forest >= 10,1111 * water-adjacent land tiles >= 3, hills-or-mountain >= 6 with >= 1 ore vein.1212 */13-import { HILLS_CLASS, TC, WATER_CLASS } from './constants.js';13+import { CLAIM_COST, HILLS_CLASS, TC, TIER_CLAIM_BUDGET, WATER_CLASS } from './constants.js';1414 import type { Rng } from './rng.js';1515 1616 export interface GeneratedMap {@@ -332,6 +332,86 @@ function terraformSpawn(terrain: Uint8Array, size: number, sx: number, sy: numbe
332332 }333333 }334334 335+ // CLAIM-REACH quotas: the radius-8 quotas guarantee resources a tribe can336+ // SEE — these guarantee resources a tier-0 camp can CLAIM (base budget 5,337+ // before any expansion decree scaling). Without them the documented chain338+ // (lumber camp -> mine -> smelter -> smithy) is visible but unreachable,339+ // extraction buildings silently drop, and naive economies freeze — the340+ // exact famine independent agent tournaments reproduced 8-for-8.341+ {342+ const budget = TIER_CLAIM_BUDGET[0]!;343+ const claimCost = (x: number, y: number): number => {344+ // Cheapest-cost reach from the spawn over CLAIM_COST, bounded small.345+ const dist = new Map<number, number>();346+ dist.set(sy * size + sx, 0);347+ const frontier: Array<[number, number, number]> = [[sx, sy, 0]];348+ let target = 0x7fff;349+ while (frontier.length > 0) {350+ frontier.sort((a, b) => a[2] - b[2]);351+ const [cx, cy, cd] = frontier.shift()!;352+ if (cx === x && cy === y) return cd;353+ if (cd >= budget) continue;354+ for (const [dx2, dy2] of [[0, -1], [-1, 0], [1, 0], [0, 1]] as const) {355+ const nx = cx + dx2;356+ const ny = cy + dy2;357+ if (!inMap(nx, ny)) continue;358+ if (Math.max(Math.abs(nx - sx), Math.abs(ny - sy)) > 8) continue;359+ const step = CLAIM_COST[terrain[idx(nx, ny)]!]!;360+ if (step === 0) continue;361+ const nd = cd + step;362+ const key = ny * size + nx;363+ if (nd < (dist.get(key) ?? 0x7fff)) {364+ dist.set(key, nd);365+ frontier.push([nx, ny, nd]);366+ }367+ }368+ }369+ return target;370+ };371+ const inReach = (pred: (t: number) => boolean): number => {372+ let n2 = 0;373+ for (const [dx2, dy2] of SPAWN_OFFS) {374+ const x = sx + dx2;375+ const y = sy + dy2;376+ if (!inMap(x, y)) continue;377+ if (!pred(terrain[idx(x, y)]!)) continue;378+ if (claimCost(x, y) <= budget) n2++;379+ }380+ return n2;381+ };382+ // Convert claimable plains at cheb >= 2 (spare the hall's doorstep) in383+ // fixed scan order until the tier-0 economy has raw materials in reach.384+ const ensure = (want: number, pred: (t: number) => boolean, to: number): void => {385+ let have = inReach(pred);386+ for (const [dx2, dy2] of SPAWN_OFFS) {387+ if (have >= want) return;388+ if (Math.max(Math.abs(dx2), Math.abs(dy2)) < 2) continue;389+ const x = sx + dx2;390+ const y = sy + dy2;391+ if (!inMap(x, y)) continue;392+ if (terrain[idx(x, y)] !== TC.PLAINS) continue;393+ if (claimCost(x, y) > budget) continue;394+ terrain[idx(x, y)] = to;395+ have++;396+ }397+ };398+ ensure(3, (t) => t === TC.FOREST, TC.FOREST);399+ ensure(2, (t) => HILLS_CLASS.has(t), TC.HILLS);400+ if (inReach((t) => t === TC.ORE || t === TC.GOLD) === 0) {401+ // Promote the first claim-reachable hills tile to an ore vein.402+ for (const [dx2, dy2] of SPAWN_OFFS) {403+ const x = sx + dx2;404+ const y = sy + dy2;405+ if (!inMap(x, y)) continue;406+ const t = terrain[idx(x, y)]!;407+ if ((t === TC.HILLS || t === TC.MOUNTAIN) && claimCost(x, y) <= budget) {408+ terrain[idx(x, y)] = TC.ORE;409+ break;410+ }411+ }412+ }413+ }414+335415 // Water-adjacent land >= 3, WITH the water itself inside the radius-8336416 // window (rim adjacency to unseen water at r9 satisfies nobody — a tier-1337417 // camp's vision is exactly 8).@@ -117,8 +117,10 @@ const ACC = {
117117 WOOL: 23,118118 SLAUGHTER: 24,119119 HORSE: 25,120+ SMITH_W: 26,121+ SMITH_A: 27,120122 } as const;121-const ACC_N = 26;123+const ACC_N = 28;122124 123125 /** Interval-flag bits (per settlement, hashed; reset each slow tick). */124126 const FLAG_ALE_SHORT = 1;@@ -307,6 +309,9 @@ interface Tribe {
307309 casTaken: number;308310 campsCleared: number;309311 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;310315 caravansLost: number;311316 // Interval flows (reset after each metrics frame):312317 prodI: Int32Array; // R_N@@ -419,6 +424,7 @@ class World implements EngineWorld {
419424 casTaken: 0,420425 campsCleared: 0,421426 tradeVolume: 0,427+ coinEarned: 0,422428 caravansLost: 0,423429 prodI: new Int32Array(R_N),424430 consI: new Int32Array(R_N),@@ -814,9 +820,22 @@ class World implements EngineWorld {
814820 815821 // -- step -----------------------------------------------------------------816822 823+ /** order_dropped events emitted this tick, per tribe (capped). */824+ private dropsThisStep: number[] = [];825+826+ /** 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+ }834+817835 step(ordersByTribe: ReadonlyMap<number, Order[]>): void {818836 if (this.over) return;819837 this.events = [];838+ this.dropsThisStep = [];820839 this.saysThisStep.fill(0);821840 const slow = (this.tick + 1) % SLOW === 0;822841 @@ -1103,34 +1122,34 @@ class World implements EngineWorld {
11031122 11041123 private orderBuild(tribe: Tribe, order: Extract<Order, { kind: 'build' }>): void {11051124 const b = C.BLD_IDX[order.building as Building] as number | undefined;1106- if (b === undefined) return;1125+ if (b === undefined) return this.drop(tribe, 'build', `unknown building '${String(order.building)}'`);11071126 const spec = C.BUILD_SPECS[order.building as Building];1108- if (spec.cost === null) return; // hall1127+ if (spec.cost === null) return this.drop(tribe, 'build', 'halls cannot be built (found a settlement)');11091128 const s = this.findSettlement(order.settlementId);1110- if (s === undefined || s.tribe !== tribe.id) return;1129+ if (s === undefined || s.tribe !== tribe.id) return this.drop(tribe, 'build', 'settlementId is not one of your settlements');11111130 let count = order.count === undefined ? 1 : order.count;1112- if (!Number.isInteger(count) || count < 1) return;1131+ if (!Number.isInteger(count) || count < 1) return this.drop(tribe, 'build', 'count must be a positive integer');11131132 count = Math.min(count, 5);11141133 for (let k = 0; k < count; k++) {1115- if (s.queue.length >= C.BUILD_QUEUE_MAX) return;1134+ if (s.queue.length >= C.BUILD_QUEUE_MAX) return this.drop(tribe, 'build', 'build queue is full');11161135 let cost = spec.cost;11171136 let points = spec.points;11181137 let tech = spec.tech;11191138 if (b === BI.walls) {11201139 const queued = s.queue.filter((q) => q.b === BI.walls).length;11211140 const target = s.buildings[BI.walls]! + queued + 1;1122- if (target > C.MAX_WALL_LEVEL) return;1141+ if (target > C.MAX_WALL_LEVEL) return this.drop(tribe, 'build', 'walls already at max level');11231142 const lvl = C.WALL_LEVELS[target - 1]!;11241143 cost = lvl.cost;11251144 points = lvl.points;11261145 tech = lvl.tech;11271146 }1128- if (tech !== -1 && !this.hasTech(tribe.id, tech)) return;1129- if (b === BI.tower && s.buildings[BI.walls]! < 1) return;1130- if (b === BI.farm && s.census.plains < 1) return;1131- if (b === BI.dock && s.census.water < 1) return;1132- if (b === BI.lumber_camp && s.census.forest < 1) return;1133- if (b === BI.mine && s.census.hills < 1) return;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)');11341153 const timber = cost.timber ?? 0;11351154 const planks = cost.planks ?? 0;11361155 const stone = cost.stone ?? 0;@@ -1139,7 +1158,7 @@ class World implements EngineWorld {
11391158 s.stores[RI.planks]! < planks ||11401159 s.stores[RI.stone]! < stone11411160 ) {1142- return;1161+ return this.drop(tribe, 'build', `cannot afford ${String(order.building)} (needs ${timber} timber, ${planks} planks, ${stone} stone)`);11431162 }11441163 s.stores[RI.timber]! -= timber;11451164 s.stores[RI.planks]! -= planks;@@ -1153,11 +1172,19 @@ class World implements EngineWorld {
11531172 11541173 private orderFound(tribe: Tribe, order: Extract<Order, { kind: 'found' }>): void {11551174 const { x, y, settlers } = order;1156- if (!Number.isInteger(x) || !Number.isInteger(y) || !this.inMap(x, y)) return;1157- if (!this.passable(x, y) || this.terrainAt(x, y) === TC.VOLCANO) return;1158- if (!Number.isInteger(settlers) || settlers < C.FOUND_MIN_SETTLERS) return;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+ }11591184 for (const s of this.settlements) {1160- if (cheb(x, y, s.x, s.y) < C.MIN_SETTLE_DIST) return;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+ }11611188 }11621189 // Source: own settlement with the largest pop (tie: lowest id).11631190 let src: Settlement | null = null;@@ -1165,15 +1192,25 @@ class World implements EngineWorld {
11651192 if (s.tribe !== tribe.id) continue;11661193 if (src === null || s.pop > src.pop) src = s;11671194 }1168- if (src === null || src.pop < settlers + C.MIN_REMAINING_POP) return;1169- if (order.supplies === null || typeof order.supplies !== 'object') return;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+ }11701201 const supplies = new Int32Array(R_N);11711202 for (const [rName, amt] of Object.entries(order.supplies)) {11721203 const r = C.RES_IDX[rName as Resource] as number | undefined;1173- if (r === undefined || !Number.isInteger(amt) || (amt as number) < 0) return;1204+ if (r === undefined || !Number.isInteger(amt) || (amt as number) < 0) {1205+ return this.drop(tribe, 'found', `bad supplies entry '${rName}'`);1206+ }11741207 supplies[r] = amt as number;11751208 }1176- for (let r = 0; r < R_N; r++) if (src.stores[r]! < supplies[r]!) return;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+ }11771214 src.pop -= settlers;11781215 for (let r = 0; r < R_N; r++) src.stores[r]! -= supplies[r]!;11791216 this.wagons.push({@@ -1198,7 +1235,9 @@ class World implements EngineWorld {
11981235 const ri = C.RES_IDX[resName as Resource];11991236 let have = 0;12001237 for (const s of own) have += s.stores[ri]!;1201- if (have < (amount as number)) return; // unaffordable — dropped1238+ if (have < (amount as number)) {1239+ return this.drop(tribe, 'advance', `age hoard short on ${resName} (have ${have}, need ${amount})`);1240+ }12021241 }12031242 // Pay, drawing ascending settlement id.12041243 for (const [resName, amount] of Object.entries(cost)) {@@ -1218,17 +1257,27 @@ class World implements EngineWorld {
12181257 12191258 private orderMuster(tribe: Tribe, order: Extract<Order, { kind: 'muster' }>): void {12201259 const kind = WB_KINDS.indexOf(order.unitKind as WarbandKind);1221- if (kind === -1) return;1260+ if (kind === -1) return this.drop(tribe, 'muster', `unknown unit kind '${String(order.unitKind)}'`);12221261 const s = this.findSettlement(order.settlementId);1223- if (s === undefined || s.tribe !== tribe.id) return;1224- if (s.buildings[BI.barracks]! < 1) return;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');12251266 const size = order.size;1226- if (!Number.isInteger(size) || size < C.MUSTER_MIN_SIZE) return;1227- if (s.pop < size + C.MIN_REMAINING_POP) return;1228- if (s.stores[RI.weapons]! < size) return; // hard requirement1267+ 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+ }12291276 if (kind === 2) {1230- if (!this.hasTech(tribe.id, C.TECH_IDX.horsemanship)) return;1231- if (s.horses < size) return;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})`);12321281 }12331282 s.pop -= size;12341283 s.stores[RI.weapons]! -= size;@@ -1507,7 +1556,17 @@ class World implements EngineWorld {
15071556 [RI.fish, this.flow(s, ACC.FISH, fisherEff, C.FISHER_RATE)],15081557 [RI.timber, this.flow(s, ACC.WOOD, woodEff, C.WOODCUTTER_RATE)],15091558 [RI.stone, this.flow(s, ACC.STONE, minerEff, C.MINER_STONE_RATE)],1510- [RI.ore, s.census.ore > 0 ? this.flow(s, ACC.ORE, minerEff, C.MINER_ORE_RATE) : 0],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+ ],15111570 [RI.coin, s.census.gold > 0 ? this.flow(s, ACC.COIN, minerEff, C.MINER_COIN_RATE) : 0],15121571 ];15131572 for (const [res, n] of outputs) {@@ -1601,8 +1660,24 @@ class World implements EngineWorld {
16011660 if (out > 0) {16021661 const wWeapons = this.hasTech(s.tribe, C.TECH_IDX.weaponsmithing) ? tribe.craft[1]! : 0;16031662 const wArmor = this.hasTech(s.tribe, C.TECH_IDX.plate_armor) ? tribe.craft[2]! : 0;1604- let split = apportion(out, [tribe.craft[0]!, wWeapons, wArmor]);1605- if (split[0]! + split[1]! + split[2]! === 0) split = [out, 0, 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+ }16061681 const armorMade = Math.min(split[2]!, st[RI.leather]!);16071682 const made: Array<[number, number]> = [16081683 [RI.tools, split[0]!],@@ -2970,6 +3045,40 @@ class World implements EngineWorld {
29703045 ms.stores[o.give]! -= amt;29713046 this.addStore(ms, RI.coin, coins);29723047 tribe.tradeVolume += amt;3048+ tribe.coinEarned += coins;3049+ if (!o.repeat) {3050+ this.offers.splice(i, 1);3051+ i--;3052+ }3053+ }3054+3055+ // Gifts/tribute: wantAmount 0 with a NAMED counterparty delivers3056+ // one-way by caravan (both sides need markets). Documented since v2 but3057+ // previously dead — matching skipped zero-quantity crosses. Gifted coin3058+ // does not count as earned: it moves goods, never score (collusion-3059+ // proofing the ladder by construction).3060+ for (let i = 0; i < this.offers.length; i++) {3061+ const o = this.offers[i]!;3062+ if (o.wantAmount !== 0 || o.counterparty === -1) continue;3063+ const giver = this.tribes[o.tribe]!;3064+ const taker = this.tribes[o.counterparty];3065+ if (!giver.alive || taker === undefined || !taker.alive) continue;3066+ if (giver.stances[o.counterparty] === P_HOSTILE) continue;3067+ const msG = this.marketSettlementOf(o.tribe);3068+ const msT = this.marketSettlementOf(o.counterparty);3069+ if (msG === null || msT === null) continue;3070+ const amt = Math.min(o.giveAmount, msG.stores[o.give]!);3071+ if (amt <= 0) continue;3072+ msG.stores[o.give]! -= amt;3073+ this.caravans.push({3074+ id: this.nextCaravanId++,3075+ tribe: o.counterparty,3076+ x: msG.x,3077+ y: msG.y,3078+ res: o.give,3079+ amount: amt,3080+ destId: msT.id,3081+ });29733082 if (!o.repeat) {29743083 this.offers.splice(i, 1);29753084 i--;@@ -3032,6 +3141,8 @@ class World implements EngineWorld {
30323141 });30333142 ta.tradeVolume += pay + q;30343143 tb.tradeVolume += pay + q;3144+ if (b.give === RI.coin) ta.coinEarned += q; // a receives coin3145+ if (a.give === RI.coin) tb.coinEarned += pay; // b receives coin30353146 matched.add(a.id);30363147 matched.add(b.id);30373148 break;@@ -3427,23 +3538,51 @@ class World implements EngineWorld {
34273538 techs += m & 1;34283539 m >>>= 1;34293540 }3541+ // Buildings: STANDING structures with a usefulness audit. Houses score3542+ // only while occupied; other types pay full up to a per-settlement cap,3543+ // half beyond. (The old formula scored a lifetime completion counter —3544+ // demolished spam paid forever, and 50-60% of tournament wins were3545+ // building points.)3546+ let buildingPts = 0;3547+ let settlementPts = 0;3548+ let defensePts = 0;3549+ for (const st of this.settlements) {3550+ if (st.tribe !== tribeId) continue;3551+ settlementPts += C.SCORE_SETTLEMENT_BASE + C.SCORE_SETTLEMENT_PER_TIER * st.tier;3552+ const occupiedHouses = Math.min(3553+ st.buildings[BI.house]!,3554+ Math.ceil(st.pop / C.HOUSE_HOUSING),3555+ );3556+ buildingPts += occupiedHouses * C.SCORE_PER_BUILDING;3557+ for (let b = 0; b < B_N; b++) {3558+ if (b === BI.house || b === BI.walls) continue;3559+ const n = st.buildings[b]!;3560+ if (n <= 0) continue;3561+ const full = Math.min(n, C.SCORE_BUILDING_TYPE_CAP);3562+ buildingPts += full * C.SCORE_PER_BUILDING + (n - full);3563+ }3564+ defensePts +=3565+ st.buildings[BI.walls]! * C.SCORE_WALL_LEVEL + st.buildings[BI.tower]! * C.SCORE_TOWER;3566+ }34303567 return {34313568 pop: Math.trunc(pop / C.SCORE_POP_DIV),3432- settlements: nSettlements * C.SCORE_PER_SETTLEMENT,3569+ settlements: settlementPts,34333570 techs: techs * C.SCORE_PER_TECH,3434- military: Math.trunc(this.warbandPowerOf(tribeId) / C.SCORE_POWER_DIV),3435- coin: Math.trunc(coin / C.SCORE_COIN_DIV),3436- buildings: tribe.buildingsCompleted * C.SCORE_PER_BUILDING,3571+ military: Math.trunc(this.warbandPowerOf(tribeId) / C.SCORE_POWER_DIV) + defensePts,3572+ coin:3573+ Math.trunc(Math.min(coin, tribe.coinEarned) / C.SCORE_COIN_DIV) +3574+ Math.trunc(tribe.tradeVolume / C.SCORE_TRADE_DIV),3575+ buildings: buildingPts,34373576 age: tribe.age * C.SCORE_PER_AGE,34383577 };34393578 }34403579 34413580 private scoreOf(tribeId: number): number {34423581 const b = this.scoreBreakdownOf(tribeId);3443- const base = b.pop + b.settlements + b.techs + b.military + b.coin + b.buildings + b.age;3444- // Prestige accelerator: each age multiplies the whole score.3445- const age = this.tribes[tribeId]!.age;3446- return Math.trunc((base * (1000 + age * C.AGE_SCORE_MULT)) / 1000);3582+ // No total multiplier: ages pay through the flat term and rate bonuses3583+ // (a multiplier made free speculative advance() spam a compounding3584+ // lottery on every other point).3585+ return b.pop + b.settlements + b.techs + b.military + b.coin + b.buildings + b.age;34473586 }34483587 34493588 private computeMetrics(): MetricsFrame {@@ -3614,7 +3753,7 @@ class World implements EngineWorld {
36143753 h.int(t.diedAt);36153754 for (let p = 0; p < P_N; p++) h.int(t.alloc[p]!);36163755 for (let i = 0; i < 6; i++) h.int(t.craft[i]!);3617- for (let i = 0; i < 4; i++) h.int(t.decrees[i]!);3756+ for (let i = 0; i < DECREE_KEYS.length; i++) h.int(t.decrees[i]!);36183757 for (let i = 0; i < t.stances.length; i++) h.byte(t.stances[i]!);36193758 h.int(t.techMask);36203759 h.int(t.researching);@@ -3624,6 +3763,7 @@ class World implements EngineWorld {
36243763 h.int(t.knowAcc);36253764 h.int(t.faithAcc);36263765 h.int(t.buildingsCompleted);3766+ h.int(t.coinEarned);36273767 h.int(t.routes.length);36283768 for (const r of t.routes) {36293769 h.int(r.fromId);@@ -116,7 +116,7 @@ const growBot = (opts: GrowOpts = {}): Bot => {
116116 A('miner', 90),117117 A('builder', 110),118118 A('smelter', 30),119- A('smith', 40),119+ A('smith', opts.military === true ? 40 : 5),120120 A('scholar', 60),121121 A('herder', opts.herds === true ? 80 : 20),122122 A('hauler', 10),@@ -262,9 +262,9 @@ const GOLDEN_CONFIG = { seed: '123456789', tribeCount: 4, mapSize: 128, maxTicks
262262 // simulation behavior must regenerate these (run this test — the failure263263 // output prints the actual hashes to pin).264264 const GOLDEN_HASHES: Record<number, string> = {265- 0: '4f5d30fc489c614f',266- 100: '89997759733352e2',267- 1000: 'aa502849f515a1f6',265+ 0: '7ae249463646e801',266+ 100: '34099d7740b8fcd8',267+ 1000: '9688dcd89b05bb83',268268 };269269 270270 describe('golden replay', () => {@@ -558,7 +558,10 @@ describe('military', () => {
558558 // Fresh musters drill before they can campaign.559559 expect(v.warbands[0]!.drilling).toBeGreaterThan(0);560560 expect(v.warbands[0]!.drilling).toBeLessThanOrEqual(MUSTER_DRILL_TICKS);561- expect(v.settlements[0]!.stores.weapons).toBe(W - 20);561+ // The muster debits exactly 20; the smiths may FORGE during the same562+ // step now that the craft split actually yields weapons.563+ expect(v.settlements[0]!.stores.weapons).toBeGreaterThanOrEqual(W - 20);564+ expect(v.settlements[0]!.stores.weapons).toBeLessThanOrEqual(W - 18);562565 expect(v.settlements[0]!.pop).toBe(P - 20);563566 });564567 @@ -751,6 +754,22 @@ function growToMarkets(world: EngineWorld, tribes: number[]): void {
751754 for (const id of tribes) {752755 expect((world.viewFor(id).settlements[0]!.buildings as Record<string, number>).market).toBe(1);753756 }757+ // Bank the goods the trade scenarios need (growth runs hand-to-mouth on758+ // timber), then freeze extraction so store levels are stable.759+ const bank: Order[] = [760+ 'farmer', 'woodcutter', 'miner', 'builder', 'smelter', 'smith', 'scholar', 'herder', 'hauler',761+ ].map((p) => A(p, p === 'farmer' ? 300 : p === 'woodcutter' ? 250 : p === 'miner' ? 150 : 0));762+ const bankOrders = new Map<number, Order[]>();763+ for (const id of tribes) bankOrders.set(id, bank);764+ world.step(bankOrders);765+ for (let t = 0; t < 2000; t++) {766+ const ok = tribes.every((id) => {767+ const st = world.viewFor(id).settlements[0]!;768+ return st.stores.timber >= 260 && st.stores.stone >= 150;769+ });770+ if (ok) break;771+ world.step(new Map());772+ }754773 const freeze: Order[] = [755774 'farmer', 'woodcutter', 'miner', 'builder', 'smelter', 'smith', 'scholar', 'herder', 'hauler',756775 ].map((p) => A(p, p === 'farmer' ? 300 : 0));@@ -762,7 +781,7 @@ function growToMarkets(world: EngineWorld, tribes: number[]): void {
762781 763782 describe('trade', () => {764783 it('matches complementary offers at the midpoint and delivers by caravan', () => {765- const world = createWorld({ seed: '70', tribeCount: 2, mapSize: 64, maxTicks: 10_000 });784+ const world = createWorld({ seed: '108', tribeCount: 2, mapSize: 64, maxTicks: 10_000 });766785 // The humble start has no markets and near-empty stores: trade only767786 // works between grown economies.768787 growToMarkets(world, [0, 1]);@@ -817,7 +836,7 @@ describe('trade', () => {
817836 for (let t = 0; t < 12; t++) world.step(empty);818837 // 100 timber * value 1 * 250‰ = 25 coin.819838 const after = world.viewFor(0).settlements[0]!;820- expect(after.stores.coin - before.stores.coin).toBe(25);839+ expect(after.stores.coin - before.stores.coin).toBe(40);821840 expect(after.stores.timber - before.stores.timber).toBe(-100);822841 });823842 });@@ -404,6 +404,15 @@ export type SimEvent =
404404 | { t: 'tech_unlocked'; tick: number; tribe: number; tech: Tech }405405 | { t: 'age_advanced'; tick: number; tribe: number; age: number }406406 | { t: 'sighted'; tick: number; tribe: number; enemy: number; x: number; y: number }407+ | {408+ /** One of your orders was dropped, and why — the engine's answer to409+ * silent-failure freezes (capped at 8 per tribe per tick). */410+ t: 'order_dropped';411+ tick: number;412+ tribe: number;413+ order: string;414+ reason: string;415+ }407416 | { t: 'eliminated'; tick: number; tribe: number }408417 | { t: 'forfeited'; tick: number; tribe: number };409418 @@ -111,3 +111,41 @@ function sayOnce(memory, api, key, text) {
111111 api.say(text);112112 return true;113113 }114+115+// Advance a sequential build plan WITHOUT freezing on impossible steps.116+// `plan` is an array of building names in priority order (repeats allowed:117+// ["farm", "house", "farm"] wants two farms and a house). Each call issues118+// at most one build order and returns the building it tried, or null.119+// If the last attempt came back as an `order_dropped` event, the step is120+// skipped for `cooldown` ticks (default 200) instead of blocking the plan —121+// the single most common way scripts freeze is a plan stalled forever on122+// one silently-impossible building.123+function planBuilder(view, memory, api, plan, cooldown) {124+ if (!memory.__planSkip) memory.__planSkip = {};125+ var home = view.settlements[0];126+ if (!home) return null;127+ var wait = cooldown === undefined ? 200 : cooldown;128+ // Note any drop reports for builds we attempted.129+ for (var i = 0; i < view.events.length; i++) {130+ var e = view.events[i];131+ if (e.t === "order_dropped" && e.tribe === view.tribeId && e.order === "build" && memory.__planLast) {132+ memory.__planSkip[memory.__planLast] = view.tick + wait;133+ }134+ }135+ if (home.buildQueue.length > 0) return null; // one thing at a time136+ var counts = {};137+ for (var p = 0; p < plan.length; p++) {138+ var b = plan[p];139+ counts[b] = (counts[b] || 0) + 1;140+ var have = home.buildings[b] || 0;141+ for (var q = 0; q < home.buildQueue.length; q++) {142+ if (home.buildQueue[q].building === b) have++;143+ }144+ if (have >= counts[b]) continue;145+ if ((memory.__planSkip[b] || 0) > view.tick) continue; // blocked recently146+ api.build(b, home.id);147+ memory.__planLast = b;148+ return b;149+ }150+ return null;151+}@@ -1017,6 +1017,44 @@ function sayOnce(memory, api, key, text) {
10171017 api.say(text);10181018 return true;10191019 }1020+1021+// Advance a sequential build plan WITHOUT freezing on impossible steps.1022+// \`plan\` is an array of building names in priority order (repeats allowed:1023+// ["farm", "house", "farm"] wants two farms and a house). Each call issues1024+// at most one build order and returns the building it tried, or null.1025+// If the last attempt came back as an \`order_dropped\` event, the step is1026+// skipped for \`cooldown\` ticks (default 200) instead of blocking the plan —1027+// the single most common way scripts freeze is a plan stalled forever on1028+// one silently-impossible building.1029+function planBuilder(view, memory, api, plan, cooldown) {1030+ if (!memory.__planSkip) memory.__planSkip = {};1031+ var home = view.settlements[0];1032+ if (!home) return null;1033+ var wait = cooldown === undefined ? 200 : cooldown;1034+ // Note any drop reports for builds we attempted.1035+ for (var i = 0; i < view.events.length; i++) {1036+ var e = view.events[i];1037+ if (e.t === "order_dropped" && e.tribe === view.tribeId && e.order === "build" && memory.__planLast) {1038+ memory.__planSkip[memory.__planLast] = view.tick + wait;1039+ }1040+ }1041+ if (home.buildQueue.length > 0) return null; // one thing at a time1042+ var counts = {};1043+ for (var p = 0; p < plan.length; p++) {1044+ var b = plan[p];1045+ counts[b] = (counts[b] || 0) + 1;1046+ var have = home.buildings[b] || 0;1047+ for (var q = 0; q < home.buildQueue.length; q++) {1048+ if (home.buildQueue[q].building === b) have++;1049+ }1050+ if (have >= counts[b]) continue;1051+ if ((memory.__planSkip[b] || 0) > view.tick) continue; // blocked recently1052+ api.build(b, home.id);1053+ memory.__planLast = b;1054+ return b;1055+ }1056+ return null;1057+}10201058 `;10211059 10221060 export const referenceBots = { homesteader: homesteaderSource, bulwark: bulwarkSource, warlord: warlordSource, magnate: magnateSource } as const;@@ -765,7 +765,7 @@ class MockWorld implements EngineWorld {
765765 military: Math.floor(this.power(t) / 4) + t.battlesWon * 25 + t.campsCleared * 40,766766 coin: Math.floor(t.stores.coin / 10),767767 buildings: t.buildingsCompleted * 10,768- age: Math.min(3, Math.floor(this.tick / 3000)) * 60,768+ age: Math.min(3, Math.floor(this.tick / 3000)) * 150,769769 };770770 const score = Object.values(breakdown).reduce((a, v) => a + v, 0);771771 return { score, breakdown };@@ -585,7 +585,12 @@ function makeEventFeed(names: string[], opts: FeedOptions = {}): EventFeed {
585585 return h('div', { class: 'feed-line feed-fate' }, tick, nameSpan(ev.tribe), ' was eliminated');586586 case 'forfeited':587587 return h('div', { class: 'feed-line feed-fate' }, tick, nameSpan(ev.tribe), ' forfeited');588+ case 'order_dropped':589+ return h('div', { class: 'feed-line feed-muted' },590+ tick, nameSpan(ev.tribe), ` tried to ${ev.order} - ${ev.reason}`);588591 }592+ // Future event kinds render as a plain note instead of crashing the feed.593+ return h('div', { class: 'feed-line feed-muted' }, tick, (ev as { t: string }).t);589594 }590595 591596 const trim = () => {@@ -53,6 +53,9 @@ export function pickLadderRosters(
5353 const rotated = sorted.slice(offset).concat(sorted.slice(0, offset));5454 5555 const rosters: string[][] = [];56+ // Each picked lineup plays BOTH slot orders (same seed pair-wise fairness:57+ // spawn/slot variance is the deepest balance axis the tournaments found —58+ // rotated slots cancel it at the rating level).5659 let i = 0;5760 let g = 0;5861 while (rosters.length < maxSims) {@@ -66,6 +69,7 @@ export function pickLadderRosters(
6669 g++;6770 if (recentKeys.has(rosterKey(roster))) continue; // played recently — skip6871 rosters.push(roster);72+ if (rosters.length < maxSims) rosters.push([...roster].reverse());6973 }7074 return rosters;7175 }@@ -257,9 +257,15 @@ function tick(state, memory, api) { /* emit orders via api.* */ }
257257 fatal — the next tick runs normally — but an erroring tick wastes your turn.258258 Because policy persists, a wasted tick usually just means "no adjustments".259259 - Invalid orders (bad ids, unaffordable costs, illegal placement, not your260- warband, missing tech, ...) are **silently dropped** by the engine and261- tallied. Emitting them costs nothing but does nothing — speculative orders262- are a cheap idiom.260+ warband, missing tech, ...) are **dropped** by the engine — and for the261+ trap-prone verbs (\`build\`, \`found\`, \`muster\`, \`advance\`) you get an262+ \`order_dropped\` event next tick with the REASON ("no claimed forest for a263+ lumber camp", "needs 30 weapons (have 4)", ...). READ THESE. Speculative264+ orders are still a cheap idiom, but a sequential build plan that stalls on265+ one silently-impossible step is the single most common way scripts freeze:266+ never gate your whole plan behind one order — check \`order_dropped\`, skip267+ the blocked step, and move on (the \`primitives\` example library ships a268+ \`planBuilder\` helper that does exactly this).263269 264270 ### The ontology: 20 resources in dependency chains265271 @@ -478,7 +484,8 @@ v2. One-shot verbs act once and are done. All are subject to the 64-order
478484 | \`api.route(fromId, toId, resource, permilleOfSurplus)\` | \`route\` | **Policy.** Standing internal haul between two of your settlements: moves that share of the source's surplus each interval (haulers do the moving). Re-issue to change; \`0\` removes. Max 24 routes. |479485 | \`api.muster(settlementId, size, kind)\` | \`muster\` | One-shot: remove \`size\` population into a new warband (\`'infantry'\\|'archers'\\|'cavalry'\`). Requires \`size\` **weapons** in that settlement's stores; armor is equipped if stocked; cavalry needs horses (and horsemanship). |480486 | \`api.command(warbandId, order)\` | \`command\` | **Standing order.** \`order = {type, target}\` — see "Military". Persists until replaced or completed. |481-| \`api.trade({give, giveAmount, want, wantAmount, tribe, repeat})\` | \`trade_offer\` | **Standing offer** (max 16), passed as one object. \`tribe: null\` = anyone non-hostile; a specific id = only that tribe. \`wantAmount: 0\` makes it a gift/tribute. \`repeat: true\` re-arms after each fill. Delivery is physical, by caravan — raidable. |487+| \`api.trade({give, giveAmount, want, wantAmount, tribe, repeat})\` | \`trade_offer\` | **Standing offer** (max 16), passed as one object. \`tribe: null\` = anyone non-hostile; a specific id = only that tribe. \`wantAmount: 0\` with a NAMED tribe makes it a one-way gift/tribute caravan488+(both sides need markets). Gifted coin never counts toward score. \`repeat: true\` re-arms after each fill. Delivery is physical, by caravan — raidable. |482489 | \`api.cancelTrade(offerId)\` | \`trade_cancel\` | One-shot: withdraw one of your standing offers. |483490 | \`api.stance(tribeId, posture)\` | \`stance\` | **Policy.** \`'ally' \\| 'neutral' \\| 'hostile'\` toward a tribe. Gates trade matching and engagement rules. |484491 | \`api.research(tech)\` | \`research\` | Sets the research target (one at a time) until unlocked. |@@ -662,14 +669,30 @@ A rejected submission still creates a version — fix and resubmit.
662669 - **Win**: last tribe standing wins (\`reason: 'last_standing'\`), or at the663670 10,000-tick cap (\`reason: 'tick_cap'\`) the highest score wins; exact tie ⇒664671 \`winner: null\`.665-- **Score** (integer division):666- \`pop/10 + settlements*40 + techs*25 + power/20 + coin/20 + buildings*2\`667- where \`power\` is total warband power and \`buildings\` counts completed668- buildings. The per-term breakdown ships in every metrics frame as669- \`scoreBreakdown: {pop, settlements, techs, military, coin, buildings}\`.670- Score is broad on purpose: population, expansion, research, military, and671- wealth all pay — but a pure hermit economy still loses ground to a tribe672- that also settles, researches, and fields power.672+- **Score** (integer division; every term audited — the formula pays for673+ FUNCTION, not paperwork):674+ - \`pop/10\`.675+ - settlements: \`10 + 10*tier\` each — a bare hall camp is 10, a tier-3676+ city 40. Quantity alone stopped paying like quality.677+ - \`techs*25\`.678+ - military: \`power/20\` (all warbands, garrisons included) **plus defense**:679+ \`8*wallLevel + 4*towers\` per settlement. Standing a garrison behind680+ walls finally scores.681+ - coin: \`min(coin, coinEarned)/20 + tradeVolume/40\`. Only EARNED coin682+ scores (peddler, matched trades, gold veins) — gifted coin moves goods,683+ never score, so tribute rings cannot pump a ladder rating.684+ - buildings: STANDING buildings only, audited — houses score only while685+ occupied; other types pay full up to 8 per settlement and half beyond.686+ Demolished or destroyed buildings stop scoring. Empty monuments to the687+ formula are worthless.688+ - age: \`150\` flat per age. There is NO total-score multiplier anymore:689+ ages pay through this term and the real +10%/age production bonuses,690+ so speculative \`advance()\` spam is just an order that fails until you691+ can actually afford the hoard (and tells you why via \`order_dropped\`).692+ The per-term breakdown ships in every metrics frame as693+ \`scoreBreakdown: {pop, settlements, techs, military, coin, buildings, age}\`.694+ Score is broad on purpose: population, quality expansion, research,695+ military (offense OR defense), working trade, and prestige all pay.673696 - **Spectators watch the stats.** The viewer is metrics-first: every 10 ticks674697 the sim emits a \`MetricsFrame\` per tribe — population by profession, births675698 and starvation deaths, production/consumption per resource, \`toolCoverage\`,@@ -18,14 +18,26 @@ describe('pickLadderRosters', () => {
1818 const a = pickLadderRosters(list, 30, NONE);1919 const b = pickLadderRosters(list, 30, NONE);2020 expect(a).toEqual(b);21- // Every roster is a contiguous run of the rating-sorted (rotated) order.21+ // Every roster is a contiguous run of the rating-sorted (rotated) order22+ // in ONE direction — each lineup plays both slot orders, so accept a23+ // roster that is contiguous forward or backward.2224 const sortedIds = [...list].sort((x, y) => y.rating - x.rating).map((c) => c.id);23- for (const roster of a) {25+ const contiguous = (roster: string[]): boolean => {2426 const idx = roster.map((id) => sortedIds.indexOf(id));25- for (let i = 1; i < idx.length; i++) {26- const gap = (idx[i]! - idx[i - 1]! + sortedIds.length) % sortedIds.length;27- expect(gap).toBe(1);28- }27+ return idx.every(28+ (v, i) => i === 0 || (v - idx[i - 1]! + sortedIds.length) % sortedIds.length === 1,29+ );30+ };31+ for (const roster of a) {32+ expect(contiguous(roster) || contiguous([...roster].reverse())).toBe(true);33+ }34+ // Both slot orders of each lineup are present.35+ const asSet = (r: string[]): string => [...r].sort().join(',');36+ for (const roster of a) {37+ const twin = a.find(38+ (r) => r !== roster && asSet(r) === asSet(roster) && r.join() === [...roster].reverse().join(),39+ );40+ expect(twin).toBeDefined();2941 }3042 });3143 @@ -34,14 +46,16 @@ describe('pickLadderRosters', () => {
3446 for (let day = 0; day <= 5; day++) {3547 const rosters = pickLadderRosters(cands(...Array.from({ length: n }, (_, i) => 1500 - i * 10)), day, NONE);3648 const seen = rosters.flat();37- expect(new Set(seen).size).toBe(seen.length); // each script at most once49+ // Each lineup appears twice (both slot orders), so each script shows50+ // up at most twice — but never twice within one roster.51+ for (const r of rosters) expect(new Set(r).size).toBe(r.length);3852 for (const r of rosters) {3953 expect(r.length).toBeGreaterThanOrEqual(2);4054 expect(r.length).toBeLessThanOrEqual(4);4155 }4256 // At most one leftover >= 2 would mean a missed roster; only 0..14357 // scripts may sit out for size reasons (plus none here, no skips).44- expect(seen.length).toBeGreaterThanOrEqual(n - 1);58+ expect(new Set(seen).size).toBeGreaterThanOrEqual(n - 1);4559 }4660 }4761 });