Claude Claude Opus 5 commited on
Commit
953e69e
·
unverified ·
1 Parent(s): 4dfc2df

Add an AI bench for Odyssey, and a measured review of the opponent

Browse files

The engine suite guards invariants (determinism, purity, "a skirmish resolves
to a winner") and says nothing about AI QUALITY, which in a play-forever mode
is a curve over 40+ minutes rather than an assertion. So several real defects
were sitting in a fully green suite.

tools/ailab.js is a headless bench: it runs the real sim (a 40-sim-minute world
costs ~7s), drives the player side with scripted sparring bots, samples an
AI-quality metric set, and prints a scoreboard plus a named-defect list.
Subcommands: probe / sweep / compare / search / check.

The key seam is that ARCHETYPES, STRATEGIES and DIFFICULTY_OPTIONS are plain
exported tables read through defensive `|| 1` accessors, so a candidate AI is
JSON injected via --overrides, not an engine patch — the search space is already
a data document. Nothing under engine/ changed here.

docs/odyssey-ai-review.md carries the review and the tuning loop, with a
reproduction command per finding. A full-roster soak (11 worlds x 4 strategies,
40 sim-minutes, 44 runs) scores mean 0.328 and fires every detector:
production-stall 43/44, hoarding 23/44, hostile-but-idle 22/44, dev-flatline
19/44, supply-deadlock 5/44. Headline findings:

- Half a galaxy's neighbours can never attack. economic and matching both
carry neverInitiates, and neighbourAiProfile samples strategies uniformly:
0 waves in all 22 such runs while hostility sat at maximum.
- A permanent supply deadlock. The mix cycle retries one entry until it
succeeds, but the Habitat trigger fires at `used >= cap - 2` — a margin
sized for a 2-supply unit, while the Odyssey roster has 4- and 8-supply
ones. Measured on kybernet: army frozen at 66 from minute 40 to 60, both
Barracks queues empty, ore climbing past 30,000.
- The build order terminates, in a mode that doesn't.
- The Rusher never develops (9 of 12 runs at dev 1 after 40 minutes); its
Hard-only graduation lands ~30 minutes late because production runs before
industry and holds no ore reserve.
- A wiped neighbour can never re-found, so a background world dies for good.

.claude/skills/ai-lab/SKILL.md packages the loop — baseline, one hypothesis,
sweep, compare per-configuration deltas, then npm test + ailab check as
guardrails — along with the composition traps specific to this codebase.

test/ailab.test.js guards the bench itself: runs must be deterministic (or a
"+0.04 improvement" is noise) and the override seam must actually reach the sim
(or every search measures the baseline against itself). Full suite green at
1172 tests; typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6aK9g6A9dYtyinJSVXHqQ

.claude/skills/ai-lab/SKILL.md ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: ai-lab
3
+ description: Search for and test better Odyssey AI strategies using tools/ailab.js. Use when asked to tune, improve, evaluate, or compare the AI opponent — its archetypes (engine/aiArchetypes.js), strategies (engine/aiStrategy.js), or difficulty dials (engine/aiDifficulty.js) — or when asked why the AI in Odyssey feels passive, stalls, hoards, or plays the same on every world.
4
+ ---
5
+
6
+ # Tuning the Odyssey AI
7
+
8
+ `tools/ailab.js` is a headless bench: it runs the real sim for 30–60 sim-minutes per world
9
+ (a few seconds of wall clock each), samples AI-quality metrics, and prints a scoreboard.
10
+ Use it instead of reasoning about the AI from source — the AI's behaviour is three data
11
+ tables composed multiplicatively across a 40-minute curve, and reading the code will not
12
+ tell you what that curve does.
13
+
14
+ ## The one thing that makes this cheap
15
+
16
+ A candidate AI is **JSON, not a patch**. `ARCHETYPES`, `STRATEGIES` and `DIFFICULTY_OPTIONS`
17
+ are plain exported objects read through `|| 1`-style accessors, so `--overrides` writes new
18
+ rows into them before a run. Explore the whole space without touching `engine/`.
19
+
20
+ ```json
21
+ {
22
+ "strategies": { "swarm": { "armyAttackSizeMult": 0.5, "garrisonMult": 0.2 } },
23
+ "archetypes": { "rusher": { "odyssey": { "workerTarget": 9, "expandWhenNodesBelow": 0.45 } } },
24
+ "difficulties": { "hard": { "workerTargetMult": 1.4 } }
25
+ }
26
+ ```
27
+
28
+ Only promote a winner into `engine/` **after** it survives a multi-seed sweep on the full roster.
29
+
30
+ ## The loop
31
+
32
+ 1. **Baseline.** `node tools/ailab.js sweep --seeds 2 --json /tmp/base.json`
33
+ Never skip this. The objective is noisy and the roster is uneven; a candidate compared
34
+ against remembered numbers is a candidate compared against nothing.
35
+ 2. **Read the components, not the total.** Every row prints `develop / keepGrowing /
36
+ pressure / thrift / liveness / survive`. A total that moved without a component moving is
37
+ noise. Name which component you intend to move before you change anything.
38
+ 3. **One hypothesis at a time**, written as an overrides file. Two dials at once and you
39
+ cannot attribute the result.
40
+ 4. **Measure.** `node tools/ailab.js sweep --overrides cand.json --seeds 2 --json /tmp/cand.json`
41
+ then `node tools/ailab.js compare --baseline /tmp/base.json --candidate /tmp/cand.json`.
42
+ `compare` prints per-configuration deltas — read them. A mean that improved because one
43
+ world improved a lot and three got worse is a regression wearing a disguise.
44
+ 5. **Guardrails before you believe it** (all three, every time):
45
+ - `npm test` — determinism, engine purity, and "a skirmish resolves to a winner" on all
46
+ nine worlds. AI changes break the resolve guarantee more often than anything else.
47
+ - `node tools/ailab.js check` — the named defect list. It must not grow.
48
+ - Confirm the skirmish path is untouched if you only meant to change Odyssey (Odyssey-only
49
+ behaviour lives behind `state.diplomacy` / `state.endless` guards).
50
+ 6. **Record the result in the ledger** (`docs/odyssey-ai-review.md`, "Search ledger"), including
51
+ the failures. An unrecorded negative result gets re-run by the next session.
52
+
53
+ ## Picking the objective before you optimise
54
+
55
+ `WEIGHTS` in `tools/ailab.js` encodes what "better AI" means and is deliberately editable.
56
+ Odyssey is a **play-forever sandbox with no win condition**, so the default weights reward a
57
+ neighbour that keeps developing, keeps spending, and applies pressure proportional to how
58
+ hostile it has actually become — not one that wins fast. If the user wants something else
59
+ (a harder opponent, a more passive galaxy), change the weights *first* and say so, rather
60
+ than optimising the default and describing the result as an improvement.
61
+
62
+ ## Choosing an opponent — this decides what you are measuring
63
+
64
+ - `--opponent none` — no player at all. What 10 of 11 worlds actually are for most of an
65
+ Odyssey. Use for development-curve work. Pressure is dropped from the score here.
66
+ - `--opponent passive` (default) — a seated base that never acts. Use to answer "does
67
+ pressure ever arrive at all".
68
+ - `--opponent turtle` — a real economy behind turrets that never attacks. Use for
69
+ "can the AI crack a defended base", which is the question that decides whether an
70
+ Odyssey neighbour is threatening.
71
+
72
+ A result from one opponent says nothing about the others. State which one you used.
73
+
74
+ ## Search
75
+
76
+ `node tools/ailab.js search --strategy aggressive --dials 'attackTimeoutMult=0.3:1,garrisonMult=0:1'`
77
+
78
+ A deterministic coordinate scan — the simplest thing that works. The judgement that matters
79
+ is *which dials to open*, and that is yours: prefer dials whose mechanism you can state in a
80
+ sentence ("commit sooner, with less"), and open at most two or three. Re-run any winner
81
+ through `sweep --seeds 3` on the full roster before believing it — the search optimises a
82
+ noisy mean and will happily overfit three worlds.
83
+
84
+ ## Traps specific to this codebase
85
+
86
+ - **Layers compose multiplicatively.** Archetype × strategy × difficulty all multiply
87
+ (`graceMult`, `workerTargetMult`, …). A dial that looks mild in isolation can be a 3×
88
+ swing on Hard + Aggressive + Rusher. Always sweep across difficulties when touching one.
89
+ - **`neverInitiates` is absolute**, including the skirmish desperation timeout. It was made
90
+ absolute on purpose (see the comment in `engine/aiStrategy.js`) — a passive player was
91
+ eating unexplained all-in waves. Do not "fix" the Odyssey side of it by removing the flag;
92
+ add a separate Odyssey-only provocation path if that's the goal.
93
+ - **Neighbour worlds pick their strategy uniformly at random** (`neighbourAiProfile` in
94
+ `engine/galaxy.js`), so any change to the STRATEGIES table changes roughly a quarter of the
95
+ galaxy. Weight the sweep accordingly.
96
+ - **Phase order in `engine/ai.js` `runAI` is load-bearing** — phases share an APM budget and
97
+ pass ore reserves forward. Production runs before industry, which is why industry starves
98
+ on a fast-spending archetype. Reordering is a real change; measure it, don't assume it.
99
+ - **The engine must stay pure and deterministic.** No `Math.random`/`Date.now` under
100
+ `engine/`, ever (`test/engine-purity.test.js`). Sparring bots and metrics belong in
101
+ `tools/`, never in the engine.
CHANGELOG.md CHANGED
@@ -6,6 +6,19 @@ All notable changes to this project are documented here. The format follows
6
 
7
  ## [Unreleased]
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  ### Changed
10
 
11
  - The Refinery, Foundry, and Arsenal no longer double as forward resource drop-offs — they're
 
6
 
7
  ## [Unreleased]
8
 
9
+ ### Added
10
+
11
+ - `tools/ailab.js` — a headless bench for the Odyssey AI. Runs the real sim for 30–60 sim-minutes
12
+ per world (seconds of wall clock), drives the player side with scripted sparring bots, and
13
+ scores the opponent on development / growth / pressure / thrift / liveness / survival. Candidate
14
+ AIs are injected as JSON into the archetype, strategy and difficulty tables (`--overrides`), so
15
+ the whole space can be searched without touching `engine/`. Subcommands: `probe`, `sweep`,
16
+ `compare`, `search`, `check`. No engine behaviour changed.
17
+ - `docs/odyssey-ai-review.md` — a measured review of how the AI plays in Odyssey, with the
18
+ findings the bench turned up (a supply deadlock that permanently freezes a developed AI, a
19
+ terminating build order, a Rusher that never develops, and a non-initiating half of the galaxy),
20
+ plus the tuning loop to run against them.
21
+
22
  ### Changed
23
 
24
  - The Refinery, Foundry, and Arsenal no longer double as forward resource drop-offs — they're
README.md CHANGED
@@ -178,6 +178,9 @@ which is what that "whether it keeps your saves" note above is warning you about
178
  - `data.js` — pure content (factions, the commodity catalog, production recipes, the charted
179
  worlds).
180
  - `tools/serve.js` — the zero-dependency dev server behind `npm start`.
 
 
 
181
 
182
  ## Background: why a separate repo
183
 
 
178
  - `data.js` — pure content (factions, the commodity catalog, production recipes, the charted
179
  worlds).
180
  - `tools/serve.js` — the zero-dependency dev server behind `npm start`.
181
+ - `tools/ailab.js` — a headless bench for measuring and tuning the Odyssey AI: it runs the sim
182
+ without a browser, scores the opponent over a long session, and A/B-tests candidate AIs supplied
183
+ as JSON. See [docs/odyssey-ai-review.md](docs/odyssey-ai-review.md).
184
 
185
  ## Background: why a separate repo
186
 
docs/odyssey-ai-review.md ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # The Odyssey AI — review, and a bench for tuning it
2
+
3
+ Two things live in this document:
4
+
5
+ 1. **A review of the AI as it plays in Odyssey**, backed by measurements rather than by
6
+ reading the source. Every claim below has a command that reproduces it.
7
+ 2. **A proposal for searching and testing better AI strategies** with Claude Code, and the
8
+ tool that makes it runnable — `tools/ailab.js`.
9
+
10
+ Nothing under `engine/` changed to produce this. The findings are reported, not fixed;
11
+ fixing them is the first job the bench is for.
12
+
13
+ ---
14
+
15
+ ## 1. How the Odyssey AI is actually built
16
+
17
+ The opponent is one scripted controller (`engine/ai.js` `runAI`) whose behaviour comes from
18
+ **three plain-data tables composed multiplicatively**, plus a diplomacy ramp that gates
19
+ whether it may attack at all:
20
+
21
+ | Layer | File | What it sets | Chosen by |
22
+ |---|---|---|---|
23
+ | **Archetype** | `aiArchetypes.js` | temperament: worker target, army size, attack timeout, unit mix, turrets, barracks, expansion, doctrine, faction — plus an `odyssey:` overlay that only applies when `state.diplomacy` exists | the world (`PLANET_ARCHETYPE`) |
24
+ | **Strategy** | `aiStrategy.js` | aggression: whether/when it voluntarily attacks, standing-army cap, war-footing response | the player at setup — or, for neighbour worlds, uniformly at random |
25
+ | **Difficulty** | `aiDifficulty.js` | APM, micro, worker multiplier, grace/grievance, research pace, market access, rusher graduation | the player at setup — or, for neighbour worlds, uniformly at random |
26
+ | **Diplomacy** | `diplomacy.js` | a stance from +1 to −1 driven by scarcity, your kills, the neighbour's own development, late-game creep, tributes, and your Gate. `hostility()` scales muster size, committed fraction and wave cadence | emergent |
27
+
28
+ `runAI` threads one world snapshot through its decision phases in a load-bearing order
29
+ (scout → workers → found/survive → expand → base/tech → produce/fortify → research → barter →
30
+ industry → military → superweapon), all sharing an APM action budget and forward-passed ore
31
+ reserves. That order matters more than it looks — see §2.4.
32
+
33
+ **This architecture is genuinely good, and it is what makes the rest of this document
34
+ cheap.** Every dial is read defensively (`|| 1` for multipliers, falsy for flags), so an
35
+ absent layer is a no-op; the tables are plain exported objects; the sim is pure,
36
+ deterministic and headless. Adding a candidate AI is therefore *writing a row into a table*,
37
+ not editing the engine — which is exactly what a search loop needs.
38
+
39
+ Other things the review found worth stating plainly:
40
+
41
+ - The AI plays under its own fog (`state.fogAI`) and earns its intel with a scout. Its
42
+ counter-picking, expansion targets and threat response are all honestly gated on what it
43
+ has seen.
44
+ - Odyssey offence is a hostility-paced *probe ramp*, not a doomstack: muster, committed
45
+ fraction and cadence all scale with `hostility()`, and there's a `playerHasPresence` guard
46
+ so background worlds don't send waves at nobody.
47
+ - The Odyssey-only paths (`state.endless` / `state.diplomacy`) are cleanly separated from the
48
+ skirmish path, which is why the skirmish stayed byte-identical through all of it.
49
+
50
+ ---
51
+
52
+ ## 2. What the bench measured
53
+
54
+ Reproduce any row with the command under it. All runs are seeded and deterministic.
55
+
56
+ The headline: a full-roster soak — **11 worlds × 4 strategies, Medium, 40 sim-minutes each,
57
+ against a player that seats a base and then does nothing** (`node tools/ailab.js check
58
+ --minutes 40`, 44 runs, ~3½ minutes of wall clock) — fires every one of the bench's five
59
+ named defects:
60
+
61
+ | detector | fires on | what it means |
62
+ |---|---|---|
63
+ | `production-stall` | **43 / 44** | a completed Barracks stood idle while the AI held real money |
64
+ | `hoarding` | **23 / 44** | banked resources peaked above 4,000 with nothing to spend them on |
65
+ | `hostile-but-idle` | **22 / 44** | the HUD reads *Hostile* and no wave ever comes |
66
+ | `dev-flatline` | **19 / 44** | the industry/tech climb stopped and never resumed |
67
+ | `supply-deadlock` | **5 / 44** | production wedged on a unit that can't fit under the supply cap |
68
+
69
+ Mean quality score across the roster: **0.33 / 1.00**. The sections below take each finding
70
+ apart.
71
+
72
+ ### 2.1 Half the galaxy's neighbours can never attack you
73
+
74
+ `STRATEGIES` has four rows; two of them (`economic`, `matching`) carry `neverInitiates: true`,
75
+ which blocks **every** voluntary commit. `neighbourAiProfile` (`engine/galaxy.js`) picks a
76
+ neighbour's strategy uniformly from `Object.keys(STRATEGIES)`, so in expectation **half the
77
+ worlds in a galaxy hold a strategy that cannot attack you, ever**, regardless of stance.
78
+
79
+ Measured across the full roster (11 worlds × 4 strategies, 40 sim-minutes, passive player):
80
+
81
+ - `economic` and `matching`: **0 waves in all 22 runs**, and the passive player's Command
82
+ Center still standing in all 22 — with **every** sample where `hostility() >= 0.5` showing
83
+ zero waves committed. That's the `hostile-but-idle` detector firing 22/44, i.e. on *exactly*
84
+ the non-initiating half and nothing else.
85
+ - `default` and `aggressive`: 1–3 waves on most worlds (8 on helix), player base razed in the
86
+ majority.
87
+
88
+ So the HUD reads *Hostile*, the stance is pinned at −1, and nothing comes. That is the
89
+ single most player-visible AI failure in the mode, and it is a distribution choice
90
+ (`neighbourAiProfile`) meeting an absolute flag (`neverInitiates`), not a bug in either.
91
+
92
+ The secondary effect is just as costly: a `neverInitiates` neighbour also never *spends*.
93
+ The four highest banked-resource runs in the roster are all `economic`/`matching`
94
+ (helix/matching 36,207 · helix/economic 30,785 · vesper/economic 30,679 · ferros/economic
95
+ 28,678), because the strategy caps the standing army at 3–8 units and nothing else in the AI
96
+ can absorb the income.
97
+
98
+ ```
99
+ node tools/ailab.js sweep --strategies economic,matching --minutes 40
100
+ ```
101
+
102
+ `neverInitiates` is absolute *on purpose* — see the comment in `aiStrategy.js`; it was made
103
+ absolute to stop a passive player eating an unexplained all-in wave on the skirmish
104
+ desperation timeout. The fix is therefore not to remove the flag but to give Odyssey its own
105
+ provocation path, or to stop `neighbourAiProfile` sampling the non-initiating strategies
106
+ uniformly.
107
+
108
+ ### 2.2 A hard production deadlock that freezes a developed AI permanently
109
+
110
+ The unit-mix cycle retries the same entry until it succeeds (`pickNextUnitType` indexes on
111
+ `unitsBuilt`, which only increments on success). The Habitat trigger in `aiBaseAndTech` fires
112
+ at `used >= cap - 2` — a margin sized for a 2-supply unit. The Odyssey roster contains
113
+ 4-supply (Dreadnought, Aegis, Colossus) and 8-supply (Leviathan) units.
114
+
115
+ When the free supply lands in the gap — at least 2 but less than the next mix entry needs —
116
+ neither side moves: production can't queue, and the Habitat that would unblock it is never
117
+ triggered. Measured on kybernet at Medium, 50 sim-minutes, no player:
118
+
119
+ ```
120
+ unitsBuilt 63 -> next mix entry: dreadnought supplyCost 4
121
+ supply 156 / 158.6 free 2.6
122
+ habitat trigger (used >= cap-2)? false
123
+ can queue next? false
124
+ ore 8031 radioactives 1994
125
+ ```
126
+
127
+ The result is permanent: army frozen at 66 units from minute 40 to minute 60, both Barracks
128
+ queues empty, banked ore climbing 2,797 → 8,031 → 11,476 → 30,000+ with nothing to spend it
129
+ on. The world is finished for the rest of what is an unbounded session.
130
+
131
+ ```
132
+ node tools/ailab.js probe --world kybernet --opponent none --minutes 60 --sample 10
133
+ ```
134
+
135
+ ### 2.3 The build order terminates — and this is a play-forever mode
136
+
137
+ Even without the deadlock, the AI's plan has an end: the factory chain, the tech order, one
138
+ Star Dock, one Plasma Rig, one Helium Bomb. Once it's climbed them, there is nothing left in
139
+ the script to buy. `INDUSTRY_CHAIN` and `RESEARCH_ORDER` are finite lists; `maxBarracks` is 1
140
+ or 2; there is no "spend surplus on more of what works" rule at all.
141
+
142
+ That shows up as the same signature as the deadlock — banked resources rising, army flat —
143
+ and it is why `thrift` scores 0.00 on every long developed run. A neighbour in a mode with no
144
+ end needs a terminal loop, not a terminal list.
145
+
146
+ It is also the most widespread finding by far: `production-stall` fires on **43 of 44** runs,
147
+ across every world and every strategy. The single exception (glacius/aggressive) missed the
148
+ detector's 0.25 threshold by scoring 0.11 — it stalled too, just less often.
149
+
150
+ ### 2.4 The Rusher never develops, and its Hard-only rescue lands ~30 minutes late
151
+
152
+ `rusherGraduates` (Hard) is meant to turn a Rusher into a developer after 20 minutes. On a
153
+ korrath background world it does not visibly land until ~50:
154
+
155
+ | sim time | dev (medium) | dev (hard) |
156
+ |---|---|---|
157
+ | 10m | 1 | 1 |
158
+ | 20m | 1 | 1 |
159
+ | 30m | 1 | 1 |
160
+ | 40m | 1 | 1 |
161
+ | 50m | 1 | 3 |
162
+ | 60m | 1 | 18 |
163
+
164
+ At Medium the Rusher is still on 6 workers, dev 1 and four distinct building types at
165
+ **60 minutes**. The mechanism is phase order, not the graduation gate: `aiProduceAndFortify`
166
+ runs *before* `aiIndustry` and holds no reserve, so a cheap-unit archetype spends ore down
167
+ below a Smelter's 160 every think cycle. The Foundry and Refinery already solve exactly this
168
+ with `ctx.foundryReserve` / `ctx.refineryReserve`; the industry chain has no equivalent.
169
+
170
+ It's all three Rusher worlds, not one: across korrath, nimbus and oort, **9 of 12 runs end at
171
+ dev 1** after 40 minutes. The three exceptions are the `economic` strategy, whose
172
+ `wantsIndustryAlways` flag bypasses the archetype gate entirely and takes them to dev 20 — so
173
+ the capability is there and reachable; it's the archetype path that can't get to it.
174
+
175
+ ```
176
+ node tools/ailab.js probe --world korrath --difficulty hard --opponent none --minutes 60 --sample 10
177
+ ```
178
+
179
+ ### 2.5 A wiped neighbour is gone forever
180
+
181
+ `aiFoundOrSurvive` can re-seat a razed base — but only from a colony ship in hand, and
182
+ `aiExpand`'s Odyssey branch only *produces* one when a standing Command Center exists
183
+ (`!colonyShip && cc && workers.length > 0`). Lose the CC without a ship in flight and the AI
184
+ is permanently dead: no workers, no production, no recovery.
185
+
186
+ Against the `turtle` sparring bot (a plain economy behind four turrets that never attacks),
187
+ an Aggressive Rusher on korrath threw itself away: base gone by minute 10, last worker gone by
188
+ minute 20, and for the remaining five minutes of the run — and every minute after it — no
189
+ Command Center, no production, and 1,143 ore banked forever.
190
+
191
+ For the *player's* conquest that's correct and intended (`galaxy.pacified` treats it as a
192
+ milestone). For a world the player has never visited, it means a background world can quietly
193
+ die and stay dead.
194
+
195
+ ```
196
+ node tools/ailab.js probe --world korrath --strategy aggressive --opponent turtle --minutes 25
197
+ ```
198
+
199
+ ### 2.6 The multiplicative layers have no clamp
200
+
201
+ `graceMult` and `grievanceMult` multiply across all three layers with no bound:
202
+
203
+ ```
204
+ grace = 420s × archetype × strategy × difficulty
205
+ = 420 × 0.5 (Rusher) × 0.2 (Aggressive) × 0.9 (Hard) = 37.8s
206
+ ```
207
+
208
+ The 7-minute opening grace — documented as the window that lets a player establish before a
209
+ world can turn — collapses to under 40 seconds on a reachable setup-screen combination
210
+ (Aggressive + Hard on a Rusher world). Grievance goes the other way: 0.04 × 2 × 1.6 × 1.15 =
211
+ 0.147 per kill, so about ten kills drag a neighbour from its opening stance (+0.35) to fully
212
+ hostile. Neither is necessarily wrong, but both are *unintended* consequences of composition,
213
+ and nothing in the tests notices.
214
+
215
+ ### 2.7 The AI does not play the Odyssey
216
+
217
+ The neighbour plays a skirmish with extra buildings. It never builds a Spaceport, never
218
+ jumps, never settles another world, never trades for credits, never charges a Gate, never
219
+ offers or demands tribute. `checkExpansion` gives the *appearance* of faction spread, but it
220
+ is a starmap bookkeeping pass — it sets `galaxy.claims` and relabels the target world's AI
221
+ faction; no AI actually goes anywhere.
222
+
223
+ This is a design gap rather than a defect, and it's the largest single lever on how alive the
224
+ galaxy feels.
225
+
226
+ ### 2.8 There is no AI-vs-AI, but there nearly is
227
+
228
+ `state.ai` is a single controller slot bound to owner `"ai"`. Across the AI modules
229
+ there are only ~21 owner-literal references, and `engine/state.js` already keeps the
230
+ owner-generic scaffold (`state.owners`, per-owner fog, owner-keyed bases). Making the
231
+ controller owner-parametric — `state.ais[owner]` — is a contained refactor, and it would
232
+ unlock true self-play: the strongest possible evaluation signal, replacing the scripted
233
+ sparring bots below.
234
+
235
+ ---
236
+
237
+ ## 3. The proposal — searching for better AI with Claude Code
238
+
239
+ ### 3.1 Why the usual approach doesn't work here
240
+
241
+ The existing suite is excellent at invariants (determinism, purity, "a skirmish resolves to a
242
+ winner on all nine worlds") and useless for quality. AI quality in Odyssey is a *curve over
243
+ 40+ minutes* — does it keep developing, does it spend what it earns, does pressure arrive in
244
+ proportion to hostility — and no assertion expresses that. Every finding in section 2 sat in
245
+ a suite of ~90 green test files.
246
+
247
+ So the missing piece is not more tests. It's a **measurement loop**.
248
+
249
+ ### 3.2 The bench: `tools/ailab.js`
250
+
251
+ Zero dependencies, Node stdlib only, in `tools/` (never `engine/`, which stays pure). It runs
252
+ the real sim headlessly — a 40-sim-minute world costs ~7 seconds — samples a metric set on a
253
+ fixed cadence, and prints a scoreboard.
254
+
255
+ ```
256
+ node tools/ailab.js probe # one configuration, full time series
257
+ node tools/ailab.js sweep # a matrix of worlds × strategies × difficulties → scoreboard + JSON
258
+ node tools/ailab.js compare # A/B two saved sweeps, per-configuration deltas
259
+ node tools/ailab.js search # deterministic coordinate scan over a strategy's dials
260
+ node tools/ailab.js check # the named-defect list from section 2, across the roster
261
+ ```
262
+
263
+ **A candidate AI is JSON, not a patch.** Because the three tables are plain objects read
264
+ through defensive accessors, `--overrides` writes rows into them before a run:
265
+
266
+ ```json
267
+ {
268
+ "strategies": { "swarm": { "armyAttackSizeMult": 0.5, "garrisonMult": 0.2 } },
269
+ "archetypes": { "rusher": { "odyssey": { "workerTarget": 9 } } },
270
+ "difficulties": { "hard": { "workerTargetMult": 1.4 } }
271
+ }
272
+ ```
273
+
274
+ That is the whole reason this is a day of work and not a project: **the search space is
275
+ already a JSON document.**
276
+
277
+ **Sparring opponents.** An AI with nobody to react to is playing solitaire, so the bench
278
+ drives the player side with scripted bots through the engine's public command API:
279
+
280
+ | bot | what it is | the question it answers |
281
+ |---|---|---|
282
+ | `none` | no player at all | how does the development curve look on a background world? |
283
+ | `passive` | seats a base, never acts | does pressure ever arrive at all? |
284
+ | `turtle` | economy behind turrets, never attacks | can the AI crack a defended base? |
285
+
286
+ **Metrics and score.** Six components, weighted in `WEIGHTS` and printed individually:
287
+ `develop` (climbs the chain), `keepGrowing` (still growing in the last third — the
288
+ play-forever requirement), `pressure` (commits waves once actually hostile), `thrift`
289
+ (spends what it earns), `liveness` (production never wedges), `survive`. The weights are
290
+ the definition of "better AI" and are meant to be argued with and edited; the components are
291
+ printed because a total nobody can decompose is a number nobody should trust.
292
+
293
+ **Health checks.** Each finding in section 2 is encoded as a named detector
294
+ (`supply-deadlock`, `hoarding`, `dev-flatline`, `hostile-but-idle`, `production-stall`) so it
295
+ is a reproducible list that shrinks as the AI improves, rather than a paragraph that rots.
296
+
297
+ `test/ailab.test.js` guards the bench itself: that runs are deterministic (or a "+0.04
298
+ improvement" is indistinguishable from noise) and that the override seam actually reaches the
299
+ sim (or every search measures the baseline against itself).
300
+
301
+ ### 3.3 The loop to run with Claude Code
302
+
303
+ Packaged as a skill at `.claude/skills/ai-lab/SKILL.md`, so Claude picks it up whenever the
304
+ work is about tuning the opponent. The loop it encodes:
305
+
306
+ 1. **Baseline** a sweep to JSON. Never compare against remembered numbers.
307
+ 2. **Name the component you intend to move** before changing anything.
308
+ 3. **One hypothesis**, expressed as an overrides file.
309
+ 4. **Sweep the candidate, then `compare`** — read the per-configuration deltas, not the mean.
310
+ A mean that improved because one world improved a lot is a regression in disguise.
311
+ 5. **Guardrails, every time:** `npm test` (determinism, purity, the resolve guarantee),
312
+ `ailab check` (the defect list must not grow), and a check that the skirmish path is
313
+ untouched if only Odyssey was meant to change.
314
+ 6. **Record it in the ledger below — including the failures**, so the next session doesn't
315
+ re-run a dead end.
316
+
317
+ The division of labour that makes this worth doing: the bench supplies reproducibility and
318
+ measurement; Claude supplies the part that's expensive for a human — reading a 40-row
319
+ scoreboard against the code that produced it, forming a mechanism-level hypothesis, and
320
+ turning it into the next overrides file. The judgement about *what the AI should feel like*
321
+ stays with you, encoded in `WEIGHTS`.
322
+
323
+ ### 3.4 Suggested order of work
324
+
325
+ Sequenced so each step makes the next one measurable:
326
+
327
+ 1. **Fix the supply deadlock** (§2.2) — a one-line-shaped fix (size the Habitat trigger to
328
+ the largest supply cost in the effective mix, or skip a mix entry that can't fit rather
329
+ than retrying it). It's the cheapest, and it currently corrupts every long-run
330
+ measurement.
331
+ 2. **Give the industry chain an ore reserve** like the Foundry's (§2.4), so development isn't
332
+ starved by unit production. Then re-measure whether `rusherGraduates` needs to exist.
333
+ 3. **Add a surplus sink** (§2.3): more Barracks / more expansions / a repeatable late-game
334
+ purchase, so a developed economy has somewhere to put its income. This is the change that
335
+ makes "play forever" true for the AI as well as the player.
336
+ 4. **Decide what a non-initiating neighbour means in Odyssey** (§2.1) — either weight
337
+ `neighbourAiProfile` away from them, or give Odyssey a provocation path that a
338
+ `neverInitiates` strategy still answers.
339
+ 5. **Then** search the dials (§3.3). Tuning multipliers on top of a deadlocked, starving,
340
+ hoarding AI is fitting noise.
341
+ 6. **Optional, high leverage:** make the controller owner-parametric (§2.8) and replace the
342
+ sparring bots with self-play.
343
+
344
+ ---
345
+
346
+ ## 4. Search ledger
347
+
348
+ One row per experiment, including the ones that didn't work. Unrecorded negative results get
349
+ re-run.
350
+
351
+ | date | hypothesis | overrides | result | kept? |
352
+ |---|---|---|---|---|
353
+ | 2026-07-30 | — (baseline) | — | `check --minutes 40`, 44 runs: mean score **0.328**. production-stall 43/44 · hoarding 23/44 · hostile-but-idle 22/44 · dev-flatline 19/44 · supply-deadlock 5/44 | n/a |
test/ailab.test.js ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ Guards for tools/ailab.js — the headless AI bench (see docs/odyssey-ai-review.md).
3
+
4
+ The bench exists to answer "did this AI change help?", so the bench itself has to be
5
+ trustworthy in exactly two ways, and these tests pin both:
6
+
7
+ 1. DETERMINISM. Two runs of the same configuration must produce identical numbers,
8
+ or a "+0.04 improvement" is indistinguishable from noise and the whole loop is
9
+ theatre. The engine is already deterministic; what this guards is that the lab
10
+ (its sparring bots, its sampling cadence, its seed derivation) doesn't smuggle in
11
+ a wall-clock or an unseeded pick.
12
+ 2. THE OVERRIDE SEAM. A candidate AI is injected as data into the ARCHETYPES /
13
+ STRATEGIES / DIFFICULTY_OPTIONS tables. If that injection silently no-ops, every
14
+ search result is a measurement of the baseline against itself.
15
+
16
+ Runs are short (a few sim-minutes) — this suite guards the harness, not the AI. The
17
+ long soak lives behind `node tools/ailab.js check`, which is a tool you run, not a test
18
+ that runs you: several of its findings fire today on purpose.
19
+ ============================================================ */
20
+
21
+ import { test } from "node:test";
22
+ import assert from "node:assert/strict";
23
+ import { run, summarise, score, applyOverrides, CHECKS, OPPONENTS, WORLDS, WEIGHTS } from "../tools/ailab.js";
24
+ import { STRATEGIES } from "../engine/aiStrategy.js";
25
+
26
+ const short = extra => ({ world: "ferros", strategy: "default", difficulty: "medium",
27
+ opponent: "passive", minutes: 4, sample: 2, seed: 7, ...extra });
28
+
29
+ // Compare only the summary fields — the curve is a superset of them, so a divergence
30
+ // anywhere in the run shows up here too, without a 40-field diff to read.
31
+ const fingerprint = r => JSON.stringify(summarise(r.curve));
32
+
33
+ test("a lab run is deterministic — same configuration, byte-identical metrics", () => {
34
+ assert.equal(fingerprint(run(short())), fingerprint(run(short())),
35
+ "two identical lab runs diverged: something in the harness reads a clock or an unseeded pick");
36
+ });
37
+
38
+ test("the seed genuinely varies the run (the lab isn't pinned to one world roll)", () => {
39
+ const a = run(short({ seed: 7 })), b = run(short({ seed: 99 }));
40
+ assert.notEqual(fingerprint(a), fingerprint(b), "two different seeds produced identical metrics");
41
+ });
42
+
43
+ test("each sparring opponent sets up the player side it advertises", () => {
44
+ const presence = opponent => {
45
+ const r = run(short({ opponent, minutes: 2 }));
46
+ return r.curve[r.curve.length - 1].playerBuildings;
47
+ };
48
+ assert.equal(presence("none"), 0, "the background-world opponent leaves no player presence at all");
49
+ assert.ok(presence("passive") >= 1, "the passive opponent seats a Command Center");
50
+ assert.ok(presence("turtle") >= 1, "the turtle opponent seats a Command Center");
51
+ for (const [id, bot] of Object.entries(OPPONENTS))
52
+ assert.equal(typeof bot.desc, "string", `opponent ${id} needs a one-line description for the scoreboard`);
53
+ });
54
+
55
+ test("the turtle bot actually builds an economy — it's a yardstick, not a statue", () => {
56
+ const r = run(short({ opponent: "turtle", minutes: 8, world: "ferros" }));
57
+ const last = r.curve[r.curve.length - 1];
58
+ assert.ok(last.playerBuildings > 1, `the turtle should raise more than its Command Center (got ${last.playerBuildings})`);
59
+ });
60
+
61
+ test("an overrides row reaches the sim — a strategy that never initiates commits no waves", () => {
62
+ // The seam the whole search loop rests on: STRATEGIES is a plain object read through
63
+ // strategyFor(), so writing a row into it before a run IS the experiment.
64
+ applyOverrides({ strategies: { labPacifist: { neverInitiates: true } } });
65
+ assert.ok(STRATEGIES.labPacifist, "applyOverrides must add the row to the live table");
66
+ const pacifist = run(short({ strategy: "labPacifist", world: "korrath", minutes: 12 }));
67
+ const baseline = run(short({ strategy: "default", world: "korrath", minutes: 12 }));
68
+ assert.equal(pacifist.waves, 0, "a neverInitiates strategy must commit zero waves");
69
+ assert.ok(baseline.waves > 0, "the korrath baseline should commit at least one wave in 12 minutes");
70
+ });
71
+
72
+ test("applyOverrides merges into an existing row rather than replacing it", () => {
73
+ applyOverrides({ strategies: { aggressive: { garrisonMult: 0.9 } } });
74
+ assert.equal(STRATEGIES.aggressive.garrisonMult, 0.9, "the overridden field is applied");
75
+ assert.equal(STRATEGIES.aggressive.attackTimeoutMult, 0.55, "…and the untouched fields survive");
76
+ });
77
+
78
+ test("score components stay in 0..1 and the total is their weighted mean", () => {
79
+ const r = run(short({ minutes: 3 }));
80
+ const s = score(r);
81
+ for (const [k, v] of Object.entries(s.parts))
82
+ assert.ok(v >= 0 && v <= 1, `component ${k} out of range: ${v}`);
83
+ const wsum = Object.keys(s.parts).reduce((a, k) => a + WEIGHTS[k], 0);
84
+ const expected = Object.entries(s.parts).reduce((a, [k, v]) => a + (WEIGHTS[k] / wsum) * v, 0);
85
+ assert.ok(Math.abs(s.total - expected) < 1e-3, `total ${s.total} isn't the weighted mean ${expected}`);
86
+ });
87
+
88
+ test("scoring an opponent-less run drops the pressure component instead of scoring zero for it", () => {
89
+ // Nobody to attack means "did it apply pressure?" is unanswerable, not answered badly —
90
+ // scoring it 0 would make the right setting for development work look like a bad AI.
91
+ const s = score(run(short({ opponent: "none", minutes: 3 })));
92
+ assert.ok(!("pressure" in s.parts), "pressure must be dropped when there is no player at all");
93
+ assert.ok(s.total > 0, "the remaining components still produce a usable score");
94
+ });
95
+
96
+ test("every health check is well-formed and covers the whole Odyssey roster", () => {
97
+ const ids = CHECKS.map(c => c.id);
98
+ assert.equal(new Set(ids).size, ids.length, "check ids must be unique — they name findings in reports");
99
+ for (const c of CHECKS) {
100
+ assert.equal(typeof c.hit, "function");
101
+ assert.ok(c.why && c.why.length > 20, `check ${c.id} needs a why: line explaining the mechanism`);
102
+ }
103
+ assert.equal(WORLDS.length, 11, "the lab sweeps the full Odyssey roster (nine skirmish worlds + two extras)");
104
+ });
tools/ailab.js ADDED
@@ -0,0 +1,619 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ AI LAB — a headless bench for the Odyssey opponent, so "is this AI any good?"
3
+ becomes a measurement instead of an opinion.
4
+
5
+ The engine test suite already guards INVARIANTS (determinism, purity, "a skirmish
6
+ resolves to a winner"). Nothing measures QUALITY: whether the neighbour on an
7
+ Odyssey world develops, spends what it earns, and applies pressure in proportion to
8
+ how hostile it has become. Those are curves over 30–60 minutes of sim, not
9
+ assertions, and they're exactly what breaks silently when an archetype number moves.
10
+
11
+ This tool runs the sim headlessly (it's already pure and deterministic — that's the
12
+ whole reason this is cheap), samples an AI-quality metric set on a fixed cadence, and
13
+ prints a scoreboard. Because the AI's whole behaviour space is three plain-data
14
+ tables — ARCHETYPES × STRATEGIES × DIFFICULTY_OPTIONS, all read through defensive
15
+ `|| 1` accessors — a candidate strategy is a JSON object, not a code change: pass
16
+ --overrides and the lab writes new rows into those tables before it runs. So
17
+ searching for a better AI needs no engine edit at all.
18
+
19
+ Usage
20
+ node tools/ailab.js probe [--world ferros] [--strategy default] [--difficulty medium]
21
+ [--opponent passive] [--minutes 40] [--sample 5]
22
+ node tools/ailab.js sweep [--worlds a,b] [--strategies a,b] [--difficulties a,b]
23
+ [--seeds 3] [--minutes 40] [--json out.json] [--csv out.csv]
24
+ node tools/ailab.js compare --baseline base.json --candidate cand.json
25
+ node tools/ailab.js search --strategy aggressive --dials 'attackTimeoutMult=0.3:1,garrisonMult=0:1'
26
+ [--trials 24] [--worlds a,b]
27
+ node tools/ailab.js check [--worlds a,b] [--minutes 60] # health checks (stall/hoard/flatline)
28
+
29
+ Common flags
30
+ --overrides f.json inject rows into the AI tables before running, e.g.
31
+ { "strategies": { "swarm": { "armyAttackSizeMult": 0.5 } },
32
+ "archetypes": { "rusher": { "odyssey": { "workerTarget": 9 } } },
33
+ "difficulties": { "hard": { "workerTargetMult": 1.4 } } }
34
+ --seed N base seed (default 1) — every run is reproducible from it
35
+
36
+ Deterministic by construction: every world seeds from mulberry32, every sparring
37
+ opponent is scripted off the sim clock, and no wall-clock or Math.random is read. Two
38
+ runs of the same command produce byte-identical numbers.
39
+
40
+ Lives in tools/ (a dev bench, like tools/serve.js) — NOT in engine/, which stays the
41
+ pure shipped simulation. It only ever READS the engine's public API.
42
+ ============================================================ */
43
+
44
+ "use strict";
45
+
46
+ import { readFileSync, writeFileSync } from "node:fs";
47
+ import { createGameState } from "../engine/state.js";
48
+ import { tick } from "../engine/sim.js";
49
+ import { createDiplomacy, hostility, aiDevelopment } from "../engine/diplomacy.js";
50
+ import { createMarket } from "../engine/market.js";
51
+ import { deployColonyShip } from "../engine/colony.js";
52
+ import { findPlacement } from "../engine/colliders.js";
53
+ import { issueBuild } from "../engine/commands.js";
54
+ import { queueProduction } from "../engine/production.js";
55
+ import { supplyUsed, supplyCap } from "../engine/supply.js";
56
+ import { BUILDINGS, UNITS, canAfford } from "../engine/entities.js";
57
+ import { pickNextUnitType } from "../engine/aiMilitary.js";
58
+ import { ARCHETYPES, archetypeFor, PLANET_ARCHETYPE, ODYSSEY_EXTRA_ARCHETYPE } from "../engine/aiArchetypes.js";
59
+ import { STRATEGIES } from "../engine/aiStrategy.js";
60
+ import { DIFFICULTY_OPTIONS } from "../engine/aiDifficulty.js";
61
+ import { mulberry32, hashStr } from "../engine/rng.js";
62
+
63
+ const WORLDS = [...Object.keys(PLANET_ARCHETYPE), ...Object.keys(ODYSSEY_EXTRA_ARCHETYPE)];
64
+ const DT = 0.1; // the sim's fixed step, same as the game loop
65
+ const THINK = 1.5; // sparring-bot decision cadence, matching the AI's own think interval
66
+
67
+ /* ============================================================
68
+ THE LAB WORLD
69
+ ============================================================ */
70
+
71
+ // One Odyssey world, built exactly the way engine/galaxy.js addPlanet builds a neighbour:
72
+ // endless, with its own market and diplomacy, the archetype's faction, and the AI dials under
73
+ // test. The `opponent` decides what the player side is, which is what the AI is actually being
74
+ // measured against — see the sparring bots below.
75
+ function labWorld({ world, strategy, difficulty, opponent, seed }) {
76
+ const s = createGameState({
77
+ planetId: world, seed, rng: mulberry32(seed), endless: true,
78
+ aiStrategy: strategy, difficulty,
79
+ aiApm: null, aiMicro: !!DIFFICULTY_OPTIONS.find(o => o.mult === difficulty)?.aiMicro,
80
+ aiFaction: archetypeFor(world).faction,
81
+ });
82
+ s.diplomacy = createDiplomacy();
83
+ s.market = createMarket(s);
84
+ s.inGalaxy = true; // galaxy states never resolve per-world (engine/victory.js)
85
+ const bot = OPPONENTS[opponent];
86
+ if (!bot) throw new Error(`unknown opponent "${opponent}" — one of: ${Object.keys(OPPONENTS).join(", ")}`);
87
+ bot.setup(s);
88
+ return { state: s, bot };
89
+ }
90
+
91
+ /* ============================================================
92
+ SPARRING OPPONENTS — what the AI is measured against
93
+
94
+ An Odyssey neighbour with nobody to react to is playing solitaire, so a metric taken
95
+ with no opponent only measures its build order. Each bot below drives the "player"
96
+ side through the engine's PUBLIC command API (the same calls input.js makes), off the
97
+ sim clock, with no randomness — so a measurement is repeatable and the difference
98
+ between two AI configs is the only thing that moves.
99
+
100
+ These are deliberately dumb and legible. A sparring partner you can't describe in a
101
+ sentence makes a scoreboard you can't interpret.
102
+ ============================================================ */
103
+
104
+ const playerUnitsOf = (s, type) => [...s.units.values()].filter(u => u.owner === "player" && u.type === type);
105
+ const playerBuildingsOf = (s, type) =>
106
+ [...s.buildings.values()].filter(b => b.owner === "player" && (!type || b.type === type));
107
+
108
+ // Deploy the opening colony ship into a real Command Center — every bot but `none` starts here,
109
+ // because an undeployed ship is a foothold the AI's wave logic reads but can't meaningfully fight.
110
+ function seatBase(s) {
111
+ const ship = playerUnitsOf(s, "colonyship")[0];
112
+ if (ship) deployColonyShip(s, ship.id);
113
+ }
114
+
115
+ // Keep idle player workers mining, using the PLAYER's own fog for discovery (the AI's
116
+ // assignIdleWorkers reads state.fogAI, so it can't be reused here without handing the bot
117
+ // the AI's intel). Nearest live discovered node, ore first — deliberately simpler than the
118
+ // AI's saturation-aware steering, since the bot is a yardstick, not a contender.
119
+ function botGather(s) {
120
+ const live = s.map.nodes.filter(n => n.amount > 0 && !n.hidden);
121
+ if (!live.length) return;
122
+ const ore = live.filter(n => n.com === "ore");
123
+ for (const w of playerUnitsOf(s, "worker")) {
124
+ if (w.order) continue;
125
+ const pool = ore.length ? ore : live;
126
+ let best = null, bestD = Infinity;
127
+ for (const n of pool) {
128
+ const d = Math.hypot(n.x - w.x, n.y - w.y);
129
+ if (d < bestD) { bestD = d; best = n; }
130
+ }
131
+ if (best) w.order = { type: "gather", nodeId: best.id };
132
+ }
133
+ }
134
+
135
+ // Put `type` down near the player's CC, sliding off collisions the same way the AI does.
136
+ function botBuild(s, type, dx, dy) {
137
+ const cc = playerBuildingsOf(s, "command").find(b => !b.constructing);
138
+ const worker = playerUnitsOf(s, "worker").find(w => !w.order || w.order.type === "gather");
139
+ if (!cc || !worker || !canAfford(s.players.player.resources, BUILDINGS[type].cost)) return false;
140
+ const spot = findPlacement(s, type, cc.x + dx, cc.y + dy);
141
+ return !!spot && issueBuild(s, worker.id, type, spot.x, spot.y);
142
+ }
143
+
144
+ const OPPONENTS = {
145
+ // A living-galaxy BACKGROUND world: no player at all. This is what 10 of the 11 worlds
146
+ // actually are for most of an Odyssey, so it's the honest setting for measuring the
147
+ // development curve — and the one where a frozen build order shows up bare.
148
+ none: {
149
+ desc: "no player at all (a living-galaxy background world)",
150
+ setup(s) {
151
+ for (const [id, u] of [...s.units]) if (u.owner === "player") s.units.delete(id);
152
+ for (const [id, b] of [...s.buildings]) if (b.owner === "player") s.buildings.delete(id);
153
+ s.background = true;
154
+ },
155
+ think() {},
156
+ },
157
+
158
+ // Seats a base and then does NOTHING — the "player who wandered off". Measures whether
159
+ // pressure ever actually arrives: an AI that can't beat this one will never threaten anyone.
160
+ passive: {
161
+ desc: "seats a Command Center, then never acts again",
162
+ setup: seatBase,
163
+ think() {},
164
+ },
165
+
166
+ // A real economy that never attacks: workers to target, Habitats ahead of the cap, a
167
+ // Barracks, defenders, and turrets. The yardstick for "can the AI crack a defended base",
168
+ // which is the question that actually matters for an Odyssey neighbour.
169
+ turtle: {
170
+ desc: "economy + static defence, never attacks (the base-cracking yardstick)",
171
+ setup: seatBase,
172
+ think(s) {
173
+ botGather(s);
174
+ const cc = playerBuildingsOf(s, "command").find(b => !b.constructing);
175
+ if (!cc) return;
176
+ const res = s.players.player.resources;
177
+ const workers = playerUnitsOf(s, "worker").length;
178
+ if (workers < 12 && cc.queue.length === 0) { queueProduction(s, cc.id, "worker"); return; }
179
+ if (supplyUsed(s, "player") >= supplyCap(s, "player") - 4
180
+ && !playerBuildingsOf(s, "habitat").some(b => b.constructing)) { botBuild(s, "habitat", 0, 90); return; }
181
+ const rax = playerBuildingsOf(s, "barracks").filter(b => !b.constructing);
182
+ if (!playerBuildingsOf(s, "barracks").length) { botBuild(s, "barracks", 90, -90); return; }
183
+ if (playerBuildingsOf(s, "turret").length < 4 && canAfford(res, BUILDINGS.turret.cost)) {
184
+ const i = playerBuildingsOf(s, "turret").length;
185
+ if (botBuild(s, "turret", 120 - 60 * (i % 2), 120 * (i < 2 ? 1 : -1))) return;
186
+ }
187
+ // A standing guard, cheapest-first so the bot's composition never depends on tech it
188
+ // hasn't built. Units hold at the rally point; the bot never issues an attack order.
189
+ const idle = rax.find(b => b.queue.length === 0);
190
+ if (idle && canAfford(res, UNITS.skiff.cost)) queueProduction(s, idle.id, "skiff");
191
+ },
192
+ },
193
+ };
194
+
195
+ /* ============================================================
196
+ METRICS
197
+
198
+ Everything here is read off sim state, never off the AI's own bookkeeping where a
199
+ cheaper read exists — the point is to measure what the AI DID, not what it intended.
200
+ ============================================================ */
201
+
202
+ const oreValue = cost => (cost.ore || 0) + 2 * (cost.crystals || 0) + 2 * (cost.radioactives || 0);
203
+
204
+ function sample(s) {
205
+ const ai = [...s.units.values()].filter(u => u.owner === "ai");
206
+ const army = ai.filter(u => UNITS[u.type].role === "combat");
207
+ const done = [...s.buildings.values()].filter(b => b.owner === "ai" && !b.constructing);
208
+ const rax = done.filter(b => b.type === "barracks");
209
+ const res = s.players.ai.resources;
210
+ const banked = oreValue(res);
211
+ const next = rax.length ? pickNextUnitType(s, s.ai.archetype) : null;
212
+ return {
213
+ t: Math.round(s.time),
214
+ workers: ai.filter(u => u.type === "worker").length,
215
+ army: army.length,
216
+ armyValue: army.reduce((v, u) => v + oreValue(UNITS[u.type].cost), 0),
217
+ dev: aiDevelopment(s),
218
+ buildings: done.length,
219
+ banked: Math.round(banked),
220
+ stance: +s.diplomacy.stance.toFixed(3),
221
+ hostility: +hostility(s).toFixed(3),
222
+ waves: s.ai.waveCount || 0,
223
+ // IDLE PRODUCTION: a completed Barracks with an empty queue while the AI is sitting on
224
+ // real money. Not "it's between jobs" — it's the signature of an AI that has run out of
225
+ // things it knows how to buy, which in a play-forever mode is the failure that matters.
226
+ idleRax: rax.filter(b => b.queue.length === 0).length,
227
+ rax: rax.length,
228
+ // Is the next mix entry unaffordable in SUPPLY specifically? The mix cycle retries the same
229
+ // entry until it succeeds, so a unit that doesn't fit under the cap wedges production —
230
+ // and the Habitat trigger only fires within 2 supply of the cap, which doesn't cover the
231
+ // 4- and 8-supply Odyssey units. That combination is a hard deadlock, so it's measured.
232
+ supplyBlocked: !!next && supplyUsed(s, "ai") + (UNITS[next].supplyCost || 0) > supplyCap(s, "ai"),
233
+ supplyFree: +(supplyCap(s, "ai") - supplyUsed(s, "ai")).toFixed(1),
234
+ playerBuildings: playerBuildingsOf(s).length,
235
+ aiAlive: done.some(b => b.type === "command"),
236
+ };
237
+ }
238
+
239
+ // Run one configuration and fold its samples into a result row. `minutes` is SIM time; a
240
+ // 40-minute world costs a few seconds of wall clock, which is what makes a sweep practical.
241
+ function run(cfg) {
242
+ const { state, bot } = labWorld(cfg);
243
+ const stepsPerSample = Math.round(cfg.sample * 60 / DT);
244
+ const total = Math.round(cfg.minutes * 60 / DT);
245
+ const curve = [];
246
+ let sinceThink = 0;
247
+ for (let i = 0; i < total; i++) {
248
+ tick(state, DT);
249
+ sinceThink += DT;
250
+ if (sinceThink >= THINK) { sinceThink = 0; bot.think(state); }
251
+ if (i % stepsPerSample === 0) curve.push(sample(state));
252
+ }
253
+ curve.push(sample(state));
254
+ return { ...cfg, curve, ...summarise(curve) };
255
+ }
256
+
257
+ // Collapse a curve into the handful of numbers a scoreboard can rank. Deliberately few: a
258
+ // metric nobody can act on is noise.
259
+ function summarise(curve) {
260
+ const last = curve[curve.length - 1];
261
+ const peak = key => curve.reduce((m, c) => Math.max(m, c[key]), 0);
262
+ // The tail is the last third of the run — the play-forever regime, where an AI whose plan
263
+ // has run out looks identical to one that's still going unless you look at the derivative.
264
+ const tail = curve.slice(Math.floor(curve.length * 2 / 3));
265
+ const devGrowthTail = tail.length > 1 ? tail[tail.length - 1].dev - tail[0].dev : 0;
266
+ const armyGrowthTail = tail.length > 1 ? tail[tail.length - 1].army - tail[0].army : 0;
267
+ const firstWave = curve.find(c => c.waves > 0);
268
+ const hostileSamples = curve.filter(c => c.hostility >= 0.5 && c.playerBuildings > 0);
269
+ return {
270
+ devFinal: last.dev,
271
+ devGrowthTail,
272
+ armyFinal: last.army,
273
+ armyValueFinal: last.armyValue,
274
+ armyGrowthTail,
275
+ workersFinal: last.workers,
276
+ buildingsFinal: last.buildings,
277
+ bankedFinal: last.banked,
278
+ bankedPeak: peak("banked"),
279
+ waves: last.waves,
280
+ firstWaveAt: firstWave ? firstWave.t : null,
281
+ // Fraction of the run spent hostile-but-idle: the neighbour reads "Hostile" in the HUD
282
+ // while never committing a wave. The single most player-visible AI failure in Odyssey.
283
+ hostileIdleFrac: hostileSamples.length
284
+ ? +(hostileSamples.filter(c => c.waves === 0).length / hostileSamples.length).toFixed(2) : 0,
285
+ // Fraction of samples where a Barracks stood idle on 400+ banked value.
286
+ idleRichFrac: +(curve.filter(c => c.rax > 0 && c.idleRax > 0 && c.banked > 400).length / curve.length).toFixed(2),
287
+ supplyDeadlockFrac: +(curve.filter(c => c.supplyBlocked && c.banked > 400).length / curve.length).toFixed(2),
288
+ stanceFinal: last.stance,
289
+ playerBuildingsFinal: last.playerBuildings,
290
+ aiAlive: last.aiAlive,
291
+ };
292
+ }
293
+
294
+ /* ============================================================
295
+ SCORING — one number to rank candidates, and the components that explain it
296
+
297
+ A scalar is what a search loop needs; the components are what a HUMAN needs to accept
298
+ or reject the result. Both are printed, always. Weights are here in the open so an
299
+ argument about what "better AI" means is an argument about these six lines, not about
300
+ the harness.
301
+ ============================================================ */
302
+
303
+ const clamp01 = v => v < 0 ? 0 : v > 1 ? 1 : v;
304
+
305
+ export const WEIGHTS = {
306
+ develop: 0.25, // climbs the industry/tech chain rather than flatlining on a 2-building base
307
+ keepGrowing: 0.2, // and is STILL growing in the last third — the play-forever requirement
308
+ pressure: 0.25, // commits waves when it has actually turned hostile
309
+ thrift: 0.15, // spends what it earns instead of banking an unusable pile
310
+ liveness: 0.1, // production never wedges (idle Barracks on real money)
311
+ survive: 0.05, // still holds a Command Center at the end
312
+ };
313
+
314
+ export function score(r) {
315
+ const parts = {
316
+ develop: clamp01(r.devFinal / 20), // ~20 = the full chain + most of the tree
317
+ keepGrowing: clamp01((r.devGrowthTail + Math.max(0, r.armyGrowthTail) / 5) / 4),
318
+ pressure: r.hostileIdleFrac > 0 ? clamp01(1 - r.hostileIdleFrac) : clamp01(r.waves / 6),
319
+ thrift: clamp01(1 - r.bankedPeak / 4000),
320
+ liveness: clamp01(1 - Math.max(r.idleRichFrac, r.supplyDeadlockFrac)),
321
+ survive: r.aiAlive ? 1 : 0,
322
+ };
323
+ // Against the `none` opponent there is nobody to attack, so scoring pressure would just
324
+ // punish the setting that's RIGHT for measuring development. Drop that component and
325
+ // renormalise the remaining weights, rather than quietly scoring an unanswerable question.
326
+ const applicable = Object.keys(WEIGHTS).filter(k => !(k === "pressure" && r.opponent === "none"));
327
+ const wsum = applicable.reduce((a, k) => a + WEIGHTS[k], 0);
328
+ const total = applicable.reduce((sum, k) => sum + (WEIGHTS[k] / wsum) * parts[k], 0);
329
+ if (r.opponent === "none") delete parts.pressure;
330
+ return { total: +total.toFixed(3), parts };
331
+ }
332
+
333
+ /* ============================================================
334
+ OVERRIDES — a candidate AI is DATA, not a patch
335
+
336
+ ARCHETYPES / STRATEGIES / DIFFICULTY_OPTIONS are plain exported objects read through
337
+ `|| 1`-style accessors, so writing a row into them before a run is the whole mechanism
338
+ for trying a new AI. That's why searching this space needs no engine change: the search
339
+ space is already a JSON document.
340
+ ============================================================ */
341
+
342
+ export function applyOverrides(ov = {}) {
343
+ for (const [name, row] of Object.entries(ov.strategies || {}))
344
+ STRATEGIES[name] = { name, desc: "(lab candidate)", ...(STRATEGIES[name] || {}), ...row };
345
+ for (const [name, row] of Object.entries(ov.archetypes || {})) {
346
+ const base = ARCHETYPES[name] || ARCHETYPES.balanced;
347
+ ARCHETYPES[name] = { ...base, ...row, odyssey: { ...(base.odyssey || {}), ...(row.odyssey || {}) } };
348
+ }
349
+ for (const [key, row] of Object.entries(ov.difficulties || {})) {
350
+ const i = DIFFICULTY_OPTIONS.findIndex(o => o.mult === key);
351
+ if (i >= 0) DIFFICULTY_OPTIONS[i] = { ...DIFFICULTY_OPTIONS[i], ...row };
352
+ }
353
+ }
354
+
355
+ /* ============================================================
356
+ HEALTH CHECKS — the findings this bench already turned up, encoded
357
+
358
+ Each is a named, reproducible defect rather than a paragraph in a review doc: run the
359
+ check, get the list of worlds it fires on. Fix the AI and the list shrinks. These are
360
+ REPORTED, not asserted — the tool's job is to measure, and today several of them fire.
361
+ ============================================================ */
362
+
363
+ export const CHECKS = [
364
+ { id: "supply-deadlock",
365
+ why: "the mix cycle retries one unit forever because it doesn't fit under the supply cap, "
366
+ + "while the Habitat trigger (used >= cap - 2) never fires for a 4- or 8-supply unit",
367
+ hit: r => r.supplyDeadlockFrac > 0.1 },
368
+ { id: "hoarding",
369
+ why: "banked resources the AI has no way to spend — income outruns its build order",
370
+ hit: r => r.bankedPeak > 4000 },
371
+ { id: "dev-flatline",
372
+ why: "the industry/tech climb stopped: no development gained in the last third of the run",
373
+ hit: r => r.devGrowthTail === 0 && r.devFinal < 12 },
374
+ { id: "hostile-but-idle",
375
+ why: "the HUD reads Hostile and the neighbour never commits a wave",
376
+ hit: r => r.hostileIdleFrac > 0.5 },
377
+ { id: "production-stall",
378
+ why: "a completed Barracks stood idle while the AI held real money",
379
+ hit: r => r.idleRichFrac > 0.25 },
380
+ ];
381
+
382
+ /* ============================================================
383
+ CLI
384
+ ============================================================ */
385
+
386
+ function parseArgs(argv) {
387
+ const out = { _: [] };
388
+ for (let i = 0; i < argv.length; i++) {
389
+ const a = argv[i];
390
+ if (a.startsWith("--")) out[a.slice(2)] = (argv[i + 1] && !argv[i + 1].startsWith("--")) ? argv[++i] : "true";
391
+ else out._.push(a);
392
+ }
393
+ return out;
394
+ }
395
+
396
+ const list = (v, fallback) => (v ? String(v).split(",").map(x => x.trim()).filter(Boolean) : fallback);
397
+ const num = (v, d) => (v === undefined ? d : Number(v));
398
+
399
+ // Just the dials under search, so a search log shows what moved rather than the whole row.
400
+ const pickDials = (row, dials) => Object.fromEntries(dials.map(d => [d.k, row[d.k]]));
401
+
402
+ // Every run's seed is derived from (base seed, world, strategy, difficulty, replicate) so a
403
+ // row reproduces on its own — you can re-probe exactly one line of a sweep.
404
+ const runSeed = (base, world, strategy, difficulty, rep) =>
405
+ hashStr(`${base}:${world}:${strategy}:${difficulty}:${rep}`);
406
+
407
+ function baseConfig(args) {
408
+ return {
409
+ minutes: num(args.minutes, 40),
410
+ sample: num(args.sample, 5),
411
+ opponent: args.opponent || "passive",
412
+ seedBase: num(args.seed, 1),
413
+ };
414
+ }
415
+
416
+ function runMatrix(args, { worlds, strategies, difficulties, seeds }) {
417
+ const base = baseConfig(args);
418
+ const rows = [];
419
+ for (const world of worlds)
420
+ for (const strategy of strategies)
421
+ for (const difficulty of difficulties)
422
+ for (let rep = 0; rep < seeds; rep++)
423
+ rows.push(run({ ...base, world, strategy, difficulty,
424
+ seed: runSeed(base.seedBase, world, strategy, difficulty, rep) }));
425
+ return rows;
426
+ }
427
+
428
+ const pad = (v, w) => String(v).padEnd(w);
429
+ const padL = (v, w) => String(v).padStart(w);
430
+
431
+ function printTable(rows) {
432
+ const cols = [
433
+ ["world", 10, r => r.world], ["strategy", 11, r => r.strategy], ["diff", 7, r => r.difficulty],
434
+ ["score", 6, r => score(r).total], ["dev", 4, r => r.devFinal], ["+dev", 5, r => r.devGrowthTail],
435
+ ["army", 5, r => r.armyFinal], ["wrk", 4, r => r.workersFinal], ["bank", 7, r => r.bankedPeak],
436
+ ["waves", 6, r => r.waves], ["idle%", 6, r => r.idleRichFrac], ["hostIdle", 9, r => r.hostileIdleFrac],
437
+ ];
438
+ console.log(cols.map(([h, w]) => pad(h, w)).join(""));
439
+ console.log(cols.map(([, w]) => "-".repeat(w - 1) + " ").join(""));
440
+ for (const r of rows) console.log(cols.map(([, w, f]) => pad(f(r), w)).join(""));
441
+ }
442
+
443
+ function printFindings(rows) {
444
+ console.log("\nHealth checks (a row here is a reproducible defect, not a style note):");
445
+ let any = false;
446
+ for (const c of CHECKS) {
447
+ const hits = rows.filter(c.hit);
448
+ if (!hits.length) { console.log(` ok ${pad(c.id, 20)} —`); continue; }
449
+ any = true;
450
+ console.log(` FIRES ${pad(c.id, 20)} ${hits.length}/${rows.length} runs`);
451
+ console.log(` ${c.why}`);
452
+ console.log(` ${hits.slice(0, 6).map(r => `${r.world}/${r.strategy}/${r.difficulty}`).join(" ")}`
453
+ + (hits.length > 6 ? ` …+${hits.length - 6}` : ""));
454
+ }
455
+ if (!any) console.log(" (clean)");
456
+ }
457
+
458
+ const CMDS = {
459
+ probe(args) {
460
+ const base = baseConfig(args);
461
+ const cfg = {
462
+ ...base,
463
+ world: args.world || "ferros",
464
+ strategy: args.strategy || "default",
465
+ difficulty: args.difficulty || "medium",
466
+ };
467
+ const r = run({ ...cfg, seed: runSeed(base.seedBase, cfg.world, cfg.strategy, cfg.difficulty, 0) });
468
+ console.log(`${cfg.world} · ${cfg.strategy} · ${cfg.difficulty} · vs ${OPPONENTS[cfg.opponent].desc}`);
469
+ console.log("\ntime wrk army dev bldg banked waves stance supplyFree idleRax");
470
+ for (const c of r.curve)
471
+ console.log(`${padL(Math.round(c.t / 60) + "m", 4)} ${padL(c.workers, 4)} ${padL(c.army, 3)} `
472
+ + `${padL(c.dev, 4)} ${padL(c.buildings, 4)} ${padL(c.banked, 8)} ${padL(c.waves, 6)} `
473
+ + `${padL(c.stance, 6)} ${padL(c.supplyFree, 11)} ${padL(c.idleRax, 8)}`);
474
+ const sc = score(r);
475
+ console.log(`\nscore ${sc.total} ` + Object.entries(sc.parts).map(([k, v]) => `${k} ${v.toFixed(2)}`).join(" "));
476
+ printFindings([r]);
477
+ },
478
+
479
+ sweep(args) {
480
+ const rows = runMatrix(args, {
481
+ worlds: list(args.worlds, ["korrath", "ferros", "vesper", "kybernet"]),
482
+ strategies: list(args.strategies, Object.keys(STRATEGIES)),
483
+ difficulties: list(args.difficulties, ["medium"]),
484
+ seeds: num(args.seeds, 1),
485
+ });
486
+ printTable(rows);
487
+ const mean = rows.reduce((a, r) => a + score(r).total, 0) / rows.length;
488
+ console.log(`\nmean score ${mean.toFixed(3)} over ${rows.length} runs`);
489
+ printFindings(rows);
490
+ if (args.json) {
491
+ // Summary rows only unless --full: a saved sweep is meant to be read (by you or by
492
+ // Claude) and diffed against the next one, and the per-sample curve buries the
493
+ // handful of numbers a decision actually turns on.
494
+ const out = rows.map(({ curve, ...r }) => ({ ...r, score: score(r), ...(args.full === "true" ? { curve } : {}) }));
495
+ writeFileSync(args.json, JSON.stringify(out, null, 1));
496
+ console.log(`\nwrote ${args.json}`);
497
+ }
498
+ if (args.csv) {
499
+ const keys = Object.keys(summarise(rows[0].curve));
500
+ const head = ["world", "strategy", "difficulty", "seed", "score", ...keys];
501
+ const body = rows.map(r => [r.world, r.strategy, r.difficulty, r.seed, score(r).total, ...keys.map(k => r[k])].join(","));
502
+ writeFileSync(args.csv, [head.join(","), ...body].join("\n") + "\n");
503
+ console.log(`wrote ${args.csv}`);
504
+ }
505
+ },
506
+
507
+ // A/B two saved sweeps (or a sweep against an overrides file run inline). Prints the
508
+ // per-configuration delta, because a mean that moved could be one world moving a lot.
509
+ compare(args) {
510
+ const load = p => JSON.parse(readFileSync(p, "utf8"));
511
+ const a = load(args.baseline), b = load(args.candidate);
512
+ const key = r => `${r.world}/${r.strategy}/${r.difficulty}/${r.seed}`;
513
+ const bm = new Map(b.map(r => [key(r), r]));
514
+ console.log(pad("configuration", 34) + pad("base", 8) + pad("cand", 8) + "delta");
515
+ let sa = 0, sb = 0, n = 0;
516
+ for (const r of a) {
517
+ const o = bm.get(key(r));
518
+ if (!o) continue;
519
+ const x = r.score.total, y = o.score.total;
520
+ sa += x; sb += y; n++;
521
+ const d = +(y - x).toFixed(3);
522
+ console.log(pad(key(r), 34) + pad(x.toFixed(3), 8) + pad(y.toFixed(3), 8) + (d > 0 ? "+" : "") + d);
523
+ }
524
+ if (!n) { console.log("no matching configurations — the two sweeps must cover the same matrix"); return; }
525
+ console.log(`\nmean ${(sa / n).toFixed(3)} -> ${(sb / n).toFixed(3)} `
526
+ + `(${sb > sa ? "+" : ""}${((sb - sa) / n).toFixed(3)} per configuration, n=${n})`);
527
+ },
528
+
529
+ // Coordinate search over a strategy's numeric dials. Deliberately the simplest thing that
530
+ // works: a deterministic scan of each dial in turn, keeping an improvement. It's here to
531
+ // make the search REPRODUCIBLE and cheap to reason about — the interesting judgement is in
532
+ // which dials to open and what the objective is, and both of those are yours (or Claude's).
533
+ search(args) {
534
+ const name = args.strategy || "default";
535
+ const dials = list(args.dials, []).map(d => {
536
+ const [k, range] = d.split("=");
537
+ const [lo, hi] = String(range).split(":").map(Number);
538
+ return { k, lo, hi };
539
+ });
540
+ if (!dials.length) { console.log("--dials 'attackTimeoutMult=0.3:1,garrisonMult=0:1'"); return; }
541
+ const worlds = list(args.worlds, ["korrath", "ferros", "vesper"]);
542
+ const difficulties = list(args.difficulties, ["medium"]);
543
+ const steps = num(args.steps, 4);
544
+ const base = { ...(STRATEGIES[name] || {}) };
545
+ const evaluate = row => {
546
+ applyOverrides({ strategies: { [`${name}__lab`]: row } });
547
+ const rows = runMatrix(args, { worlds, strategies: [`${name}__lab`], difficulties, seeds: num(args.seeds, 1) });
548
+ return { mean: rows.reduce((a, r) => a + score(r).total, 0) / rows.length, rows };
549
+ };
550
+ let best = { ...base }, bestScore = evaluate(best).mean;
551
+ console.log(`start ${name} -> ${bestScore.toFixed(3)} ${JSON.stringify(pickDials(best, dials))}`);
552
+ for (const d of dials) {
553
+ for (let i = 0; i <= steps; i++) {
554
+ const v = +(d.lo + (d.hi - d.lo) * (i / steps)).toFixed(3);
555
+ const cand = { ...best, [d.k]: v };
556
+ const { mean } = evaluate(cand);
557
+ const better = mean > bestScore + 1e-9;
558
+ console.log(` ${pad(d.k + "=" + v, 32)} ${mean.toFixed(3)}${better ? " *" : ""}`);
559
+ if (better) { best = cand; bestScore = mean; }
560
+ }
561
+ }
562
+ console.log(`\nbest ${bestScore.toFixed(3)} ${JSON.stringify(pickDials(best, dials), null, 1)}`);
563
+ console.log("\nThis is a LOCAL search on a noisy objective — re-run the winner through "
564
+ + "`sweep --seeds 3` on the full roster before believing it.");
565
+ },
566
+
567
+ check(args) {
568
+ const rows = runMatrix(args, {
569
+ worlds: list(args.worlds, WORLDS),
570
+ strategies: list(args.strategies, Object.keys(STRATEGIES)),
571
+ difficulties: list(args.difficulties, ["medium"]),
572
+ seeds: 1,
573
+ });
574
+ printTable(rows);
575
+ const mean = rows.reduce((a, r) => a + score(r).total, 0) / rows.length;
576
+ console.log(`\nmean score ${mean.toFixed(3)} over ${rows.length} runs`);
577
+ printFindings(rows);
578
+ if (args["exit-code"] === "true" && CHECKS.some(c => rows.some(c.hit))) process.exitCode = 1;
579
+ },
580
+ };
581
+
582
+ const USAGE = `AI LAB — a headless bench for the Odyssey opponent.
583
+
584
+ node tools/ailab.js probe [--world ferros] [--strategy default] [--difficulty medium]
585
+ [--opponent passive] [--minutes 40] [--sample 5]
586
+ node tools/ailab.js sweep [--worlds a,b] [--strategies a,b] [--difficulties a,b]
587
+ [--seeds 3] [--minutes 40] [--json out.json] [--csv out.csv] [--full]
588
+ node tools/ailab.js compare --baseline base.json --candidate cand.json
589
+ node tools/ailab.js search --strategy aggressive --dials 'attackTimeoutMult=0.3:1,garrisonMult=0:1'
590
+ [--steps 4] [--worlds a,b]
591
+ node tools/ailab.js check [--worlds a,b] [--minutes 60] [--exit-code]
592
+
593
+ Common flags
594
+ --overrides f.json inject candidate rows into the AI tables before running:
595
+ { "strategies": { "swarm": { "armyAttackSizeMult": 0.5 } },
596
+ "archetypes": { "rusher": { "odyssey": { "workerTarget": 9 } } },
597
+ "difficulties": { "hard": { "workerTargetMult": 1.4 } } }
598
+ --seed N base seed (default 1) — every row is reproducible from it`;
599
+
600
+ function main(argv) {
601
+ const args = parseArgs(argv);
602
+ const cmd = args._[0];
603
+ if (!cmd || !CMDS[cmd]) {
604
+ console.log(USAGE);
605
+ console.log(`\nworlds: ${WORLDS.join(", ")}`);
606
+ console.log(`strategies: ${Object.keys(STRATEGIES).join(", ")}`);
607
+ console.log(`opponents: ${Object.entries(OPPONENTS).map(([k, v]) => `${k} (${v.desc})`).join("\n ")}`);
608
+ return;
609
+ }
610
+ if (args.overrides) applyOverrides(JSON.parse(readFileSync(args.overrides, "utf8")));
611
+ const t0 = process.hrtime.bigint(); // deterministic-exempt: wall clock is for the operator's benefit only, never read by the sim
612
+ CMDS[cmd](args);
613
+ console.log(`\n(${Number(process.hrtime.bigint() - t0) / 1e9 | 0}s)`);
614
+ }
615
+
616
+ // Only run the CLI when invoked directly, so a test can import run/score/CHECKS.
617
+ if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) main(process.argv.slice(2));
618
+
619
+ export { run, labWorld, sample, summarise, OPPONENTS, WORLDS };