Spaces:
Paused
Add an AI bench for Odyssey, and a measured review of the opponent
Browse filesThe 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 +101 -0
- CHANGELOG.md +13 -0
- README.md +3 -0
- docs/odyssey-ai-review.md +353 -0
- test/ailab.test.js +104 -0
- tools/ailab.js +619 -0
|
@@ -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.
|
|
@@ -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
|
|
@@ -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 |
|
|
@@ -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 |
|
|
@@ -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 |
+
});
|
|
@@ -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 };
|