SpaceCities / docs /code-improvement-tiers.md
Claude
Document the main-branch gate, and fix the docs my own changes falsified
2b4cf02 unverified
|
Raw History Blame
54.5 kB
# Code Improvement Tiers β€” a TDD-first plan from a full-codebase review
*This is the **code-quality** companion to [improvement-proposals.md](./improvement-proposals.md) (which
proposes changes to the **game**) and [improvement-roadmap.md](./improvement-roadmap.md) (which sequences
them). Nothing here changes a rule, a number, or a mechanic. Every entry is about the code that
implements them: a defect, a missing guard, a type gap, or a structure that made one of those possible.*
## Status
**Tier 1 is delivered.** All 42 items shipped across seven commits, each fix preceded by a test
verified red against the real behaviour, and the mutation-proven gaps re-checked by re-introducing
the exact defect. Suite went from **1,897 β†’ 1,964 tests, still 0 failures**; `npm run typecheck`
stays clean; `// @ts-check` coverage went from **10 files to 20**.
Three things the work itself turned up, beyond the findings as written:
- The NET round-trip tests rejected a first cut of the save hardening that stamped `pacified: false`
and `rally.nodeId: null` unconditionally. They were right: a hardening pass must be the identity on
a valid save, byte for byte. Both now adopt only fields the save actually carries.
- `test/_helpers.js`'s header claims its fingerprints are "immune to Map-iteration / JSON-key order".
True of `entitySnapshot`, whose tuples are joined in a fixed field order β€” but `galaxySnapshot`
stringified diplomacy and market pressure raw, so identical values in a different insertion order
read as a divergence. Another false comment, found the same way as the six in the table below.
- Adopting `// @ts-check` on `persist.js` surfaced a real asymmetry: `deserializeGalaxy`'s object
literal omitted `lanes` while `createGalaxy`'s includes it, so a `Galaxy` was briefly missing a
field its own typedef requires. Three further typedef drifts (`Building.rally.nodeId`,
`Building.queue[].alt`, `Building.rivalAscended`) came out of the same pass.
**D5 (`strictNullChecks`) was evaluated and deliberately deferred**, as the item itself proposed:
turning it on today yields **198 errors** across the 20 checked files. That is a genuine project with
its own fallout, not a Tier-1 slice β€” it belongs after the Tier-2 structural work, when fewer of
those errors are in code that is about to be rewritten anyway.
The **silhouette-collision half of C6** also moves to Tier 2 as the plan intended: the test is cheap,
but it goes red until the Antimatter Gate and Torpedo Battery get distinct art, and that is an art
decision, not a code one.
**Tier 2 is delivered.** All 20 items shipped across five commits. Suite **1,964 β†’ 2,002 tests**, 0
failures throughout; typecheck clean. Full-suite wall clock **2m17s β†’ 1m34s**.
Three outcomes worth recording, because they changed the plan rather than following it:
- **The per-frame allocation item did not survive measurement.** Memoizing `shade`/`hexA` on
`(hex, percent)` looks like an obvious win β€” tiny fixed key space, called per building per frame.
Benchmarked on a 120-building, 306-unit frame it is a *loss*: garbage 51.5 β†’ 62.2 KB/frame, frame
time 0.22 β†’ 0.24 ms, because the cache key is itself a fresh string per call. Reverted, with the
numbers written at the function so the next attempt starts from a baseline instead of the same
intuition. Only the loop-invariant hoist survived.
- **`tickSelfPlay`'s ordering was corrected in the comment, not the code** β€” the option the finding
explicitly sanctioned. Making the claim true needs either a frozen pre-tick read or moving the
`"ai"` call out of `sim.js`'s hardcoded pipeline; both restructure the tick contract, so they sit
in Tier 3. The measured bound (one frame, one direction) is now pinned by a characterisation test
so it cannot widen unnoticed.
- **Two fixes changed behaviour a test fixture depended on**, and both were retargeted rather than
weakened: consolidating `radiusOf` broke `formation.js`, whose callers pass unit-shaped literals
with no `kind` field (the helper resolves by type first, with a comment on why); and the
name-based bye tie-break moved which Swiss candidate draws the bye, so a fixture's winner stopped
fighting.
The **rebuild-signature co-location** is done to the extent Tier 2 allows: the 141-line expression is
now a named `panelSignature()` function rather than an inline block, which is the seam the Tier-3
panel registry moves into. Genuine per-panel co-location needs the decomposition itself.
**Tier 3 is delivered.** All seven refactors resolved across eight commits. Suite **2,002 β†’ 2,051
tests**, 0 failures throughout; typecheck clean.
Every design question in the table below now has an answer, and three of them changed what got built:
- **The AI decision seam is "export in place", not a new `aiDecisions.js` leaf.** The helpers read
`state` and `ctx` and belong with their phase; a module existing only for testability would have to
import back into the layer it came from. Nine predicates are now exported and directly drivable.
The 13-site build idiom did collapse β€” into `tryBuild`/`tryBuildAt` β€” once it was clear the two
steps that fail *silently* are `canAct` and `spend`: omit the first and the AI ignores its APM
budget, omit the second and it founds buildings for free. Neither shows up on the default
archetype, whose `apm` is `null`, which is why five sites had already drifted to relying on a
`canAct` three lines up. The builder stays a parameter, because two sites have always founded with
`workers[0]` rather than the nearest free worker, and collapsing that would move the determinism
fingerprint β€” a behaviour change, not a refactor.
- **The declarative save schema was evaluated and rejected, and replaced with the guard it was for.**
Every field's sanitizer in `persist.js` is deliberately different (`popCap`/`matchTimeLimit` must
*not* go through `num()`, since `Number(null) === 0` turns "no override" into "end the match at
time 0"), and the comments explaining each choice are the most valuable thing in the file. A table
would wrap that same bespoke logic in per-entry closures while the payload has to stay
byte-identical: more risk, less clarity, no new safety. What the table was *for* β€” catching a field
written by `serPlanet` and never read by `rehydratePlanet` β€” is now a payload-level round-trip
comparison for the game save and the galaxy save alike, so the next such field fails immediately
instead of resetting silently on load.
- **`hudSelection.js` split by "derive vs render", not by panel.** The registry the table proposes is
still the destination, but the seam that mattered first was `panelSignature` β€” the panel's entire
reactivity model, which decides whether it rebuilds *at all*. It and the derivations it shares with
the renderers now live in `hudPanelSignature.js`, a genuine leaf outside the `hud β†’ hudSelection`
cycle: 2,242 β†’ 1,915 lines, with the panel's output byte-identical, verified rather than assumed by
the golden lock the table asked for (25 fixtures, committed one commit ahead of the first
extraction).
Four things the work turned up that were not in the plan:
- **Three guards written during Tier 3 were vacuous on their first cut, and each was caught by
mutation rather than by review.** The dead-import guard located the import block by walking lines
until one stopped looking like an import β€” and since every file opens with a prose header comment,
it broke out on the first sentence containing a bracket and reported a file with *twenty* dead
imports as clean. The save round-trip guard's first mutation (a field defaulting identically on
both passes) proved nothing, because both payloads agreed. The panel-fixture guard used a node
count as a proxy for "the setup took" and flagged the Habitat, whose whole panel is legitimately
two rows. The lesson is the same one Tier 1's mutation pass taught: a green guard is evidence of
nothing until you have watched it go red.
- **25 dead imports in `hudSelection.js` alone**, twenty of them predating this work β€” `repairBtn`,
`departBtn`, `saveBtn`, `pauseBtn`, `starmapBtn`, `resourcesEl`, `clockEl`, `scenarioStatusEl` and
more, left behind by code that moved to `hud.js`. A dead import advertises a dependency that does
not exist, so the import block misleads anyone reading it, and the module-reachability guard counts
an edge nothing walks. The new guard found three more elsewhere (`aiWorkers`'s `UPGRADES`,
`formation`'s `UNITS`, `input`'s `dragCamera`).
- **The DOM test double had grown to nine hand-rolled copies**, and the differences between them were
not considered decisions β€” they were whichever subset of the DOM the file that copied it happened
to need. `overlays`' copy tracked parents so `.remove()` really spliced and `hudSelection`'s did
not; `hud`'s `querySelector` walked the tree and `update`'s was hardcoded to `null`; `observer`'s
`getBoundingClientRect` returned a real 800Γ—600 box and `saveload`'s returned zeros; `saveload`'s
`appendChild` discarded its argument outright. A test written against a weak copy silently asserts
less than the identical test written against a strong one, and nothing told you which you had.
Consolidated into `test/_dom.js` with no assertion weakened β€” the two axes that genuinely change
what the code under test does (the 2D context, the viewport size) stay configurable.
- **The render extraction found its own targets by measurement rather than by the plan's list.** Of
the proposed primitives, the ones that earned their place were the three where copying had already
caused or hidden a bug: `centeredText` (whose *restore* is the point β€” `textAlign` is canvas-wide
state, and two sites shipped without it, invisible only because `drawFrame`'s outer save/restore
swallowed the damage once per frame), `drawLabelChip` (two sites had independently hand-rolled the
same plate arithmetic), and `hiddenByFog` (four copies of a rule whose failure is silent in the
direction that matters β€” it paints the enemy's army through the fog). A blanket `fillStroked`
collapse was measured and skipped: the 26 candidate sites set their fill/stroke state in enough
different orders that a helper would have moved the assignments, not removed them.
## How this was produced
Eight reviewers worked the codebase in parallel, one per domain β€” engine core sim, AI + `tools/ailab.js`,
economy/logistics, persistence/galaxy, UI/HUD/input, render, the test suite itself, and the cross-cutting
seams (types, import graph, duplication, tooling). Each was given `CONTRIBUTING.md`'s hard rules as
constraints and told that a finding without a citation and a reproduction is not a finding.
The evidence bar matters for how you read what follows. Findings were not inferred from reading code β€”
they were **executed**. The reviewers ran real games, mutated real source in scratch clones and re-ran the
full suite, instrumented canvas contexts to count `save`/`restore` depth, ran `tsc` probes against
`engine/types.js`, and built the import graph from actual `import` statements across 181 files. Where a
number appears below it was measured. I spot-checked the highest-impact claims against the tree myself and
found no overstatements.
**Baseline at the time of review** (commit `c775143`):
| | |
|---|---|
| Test suite | **1,897 tests, 0 failures**, 4,723 `assert.*` calls across 101 `*.test.js` files (31,711 lines) |
| Wall clock | **145 s** here; measured separately at 150.4 s full vs. **20.5 s without `test/ailab.test.js`** β€” that one file is **87% of the suite's wall clock** for 3.4% of its tests |
| `npm run typecheck` | exits 0, zero output |
| Production code | 58,701 lines; 76 shipped modules; largest is `hudSelection.js` at 2,201 lines / 131 KB |
| Type coverage | `// @ts-check` on **10 of 76 modules (13%)** β€” all in `engine/`, **0 of 29 root modules** |
## The four patterns worth internalising
Individual findings are listed by tier below. But the same four shapes turned up independently in domains
that never talked to each other, and they are the more useful output of this review.
### 1. Comments assert invariants the code does not hold
This codebase explains *why*, which is a real strength β€” and it has quietly become a liability, because
several load-bearing "why" comments are now **factually false**, and each one was believed by a later
reader. Six were found independently:
- `engine/bomb.js:139` says its `radiusOf` is "same as movement.js/formation.js's own" β€” the three bodies
have diverged, and only `bomb.js`'s handles buildings. `movement.js`'s copy is in a `// @ts-check`ed
file whose JSDoc advertises `@param {Unit|Building}` while silently returning the magic constant `9`
for any building.
- `engine/separation.js:100` says a stale `autoTarget` "reads as still-combat-mode for one tick too long
β€” harmless." It is never cleared on a move order at all, so it reads that way permanently.
- `engine/industry.js:300` lists the callers that "never touch this cache" and names the HUD β€” while
`buildingConcern`, 90 lines below in the same file, calls it, and `renderBuildings.js:122` calls that
every frame.
- `engine/persist.js:828` says the lane load path re-runs "exactly like `runLanes`' own per-cycle
validation does at runtime." It skips the de-duplication `assignShipToLane` enforces.
- `engine/aiMilitary.js:294` says its corrective pull-back "keeps the WHOLE parked group inside the
radius." The correction is radial and the farthest slot is an off-axis corner, so it never converges β€”
measured residual grows from 0.1 px at 8 units to 1.7 px at 40.
- `tools/selfplay.js:81` says both controllers "think on the IDENTICAL pre-tick snapshot." Measured over
400 think cycles, the `"player"` seat had already mutated orders on 13% of them and founded buildings on
1% before the `"ai"` seat read the state.
**The lesson is not "write fewer comments."** It is that a prose invariant in this codebase should be
accompanied by the test that makes it executable β€” otherwise it decays into a confident-sounding lie that
the next contributor builds on.
### 2. Guards check the easy direction
Nearly every guard in the repo verifies the direction that was easy to think of, and is structurally blind
to its complement:
| Guard | Checks | Blind to |
|---|---|---|
| `save-hardening` NET round-trip | serialize→load→serialize is stable | a field absent from **both** sides — which is exactly how `galaxy.rivalAscended` shipped unpersisted |
| `render-roster` | a type isn't drawn as the generic fallback | a type drawn identically to **another real type** β€” 3 real collisions today, one of them the Odyssey wonder |
| `static-integrity` | every relative import resolves to a file | a module that resolves fine but **nothing imports** β€” the orphan bug that shipped in `2a07a69` |
| ailab bye tests | a bye never *gains* a candidate credit | a bye never *costs* rank β€” it does, structurally |
| ailab override tests | overrides don't leak *out* of a duel | two candidates colliding *within* one duel β€” silently merged |
| `engine-purity` | every file in `engine/` | anything in an `engine/` **subdirectory**, and `data.js` (19 importers, imported by 9 engine modules) |
### 3. The type system is opt-in *and* declawed
`engine/types.js` is a good idea implemented halfway, and the halfway state is worse than either end.
- **12 fields exist at runtime but are missing from 5 typedefs** (proved with a `tsc` probe returning 12
errors and 0 control failures) β€” including `State.playerAi`, the central field of the flagship self-play
feature, omitted by the very commit that added it.
- Adding `// @ts-check` to 12 of the most load-bearing engine files β€” `sim.js`, `persist.js`, `combat.js`
among them β€” produces **zero errors**, because `strict: false` + `noImplicitAny: false` makes every
un-annotated param `any`. `engine/recycle.js` already carries the pragma and has **0 `@param` tags**
across its 7 exports: nominally covered, checks nothing.
- The drift **actively blocks** the file-by-file expansion `CONTRIBUTING.md` prescribes: annotating one
function in `engine/supply.js` immediately produces three errors *in correct code*, which trains
contributors to distrust the checker.
A pragma-only coverage campaign would take the number to 100% and catch zero bugs. Fix the typedefs first.
### 4. Coverage is example-based and first-cycle
The suite has genuinely good hygiene β€” **zero tests with no assertion, zero assertions on literals**, one
load-bearing `try/catch`, and excellent assertion messages. What it lacks is *properties*. It proves "a
worker fills the battery once", never "and again after that"; it asserts a specific number at a specific
site, never a conserved quantity. Two of this review's sharpest bugs live precisely there β€” a `servers`
tally that only ever climbs, and a wonder that keeps eating the treasury after it is done β€” and both sit
inside green suites.
The reviewer proved the gap by **mutation testing**: 15 realistic regressions injected into a scratch
clone, **6 survived the full suite**, including gutting `drawBuildingBars` (every health bar, build-progress
bar and stall badge in the game) to `return;`, dropping the entire `playerAi` block from saves, and typo'ing
`<script src="main.js">` in `index.html` to a nonexistent file β€” which ships a blank white screen with CI
green on both Node versions.
---
# Tier 1 β€” Ship now
High value, low risk, no design decision required. Every item has a named red-first test and an `S`/`M`
effort. Items marked **⚠ replay** change simulation output and need their own commit with
`determinism*.test.js` and `balance.test.js` re-verified.
## 1A. Live defects
Every one of these was reproduced by running the real code.
| # | Defect | Where | Fix | E |
|---|---|---|---|---|
| A1 | **Escorts strand on a ghost leader forever.** `keepFollowingLeader` decides its leader is gone by `hp <= 0`, but `deployColonyShip` and `updateUnitRecycle` remove units at *full* hp. Escorting a colony ship and deploying it β€” the canonical Odyssey play β€” permanently bricks the escorts, orbiting a unit no longer in `state.units`. | `engine/movement.js:99`; removals at `engine/recycle.js:151`, `engine/colony.js:40` | Ask the state, not the field: `\|\| !state.units.has(leader.id)` | S |
| A2 | **Squad-release loop splices the array it iterates**, so every splice skips the next element. Dropping two adjacent followers releases only the first; the second keeps `squadLeader` pointing at a leader whose own list no longer contains it. | `engine/commands.js:161-165`, `:46-53` | `for (const old of [...leader.squadFollowers])` | S |
| A3 | **A Torpedo Battery is locked out of auto-resupply forever.** `countLogistics` resets `servers` for factories and combust plants but not for the third input-taking kind `inputNeedsOf` grew (`def.ammo`), so the tally only climbs β€” 7 after one run, permanently above `MAX_SERVERS`. *This is verbatim the bug the file already documents as fixed for power stations at `haul.js:161`.* | `engine/haul.js:158-164` | Reset from the single source of truth: `if (inputNeedsOf(b)) b.servers = 0;` | S |
| A4 | **A completed Antimatter Gate eats the Strategic tier forever.** `updateWonder` clamps the *charge* but not the *spend*, and `victory.js:80` deliberately never ends a galaxy game. Measured: 90 AI cores / 135 antimatter / 45 plasmatorp destroyed in 450 sim-seconds *after* the Gate finished. The Leviathan competes for the same three goods. | `engine/wonder.js:43-51` | Early-return at `charge >= 1`; clamp `p` to the remaining charge so the last tick pays only for what it banks | S |
| A5 | **A Rival Gate ascension is undone by save/load.** `galaxy.rivalAscended` β€” the idempotency latch the code calls by that name β€” is never written to the payload (0 hits in `persist.js`, 0 in `test/`). On reload the permanent stance ceiling is lost and the `ascended` event re-fires on every Continue. Autosave runs every 12 s. | `engine/persist.js:706-733` | Additive: persist as an array, restore filtered against `known` exactly like `claims` at `:778`. **No version bump.** | S |
| A6 | **Freight Lane and Colony Policy panels are dead controls.** Their handlers do `mutate(); renderHUD();` but the 141-line rebuild signature contains no lane or policy term, so the panel never redraws. Reproduced by node identity: commodity chips, `+ New Lane`, disband, and every Colony Policy control are inert β€” and clicking again silently toggles the state back. | `hudSelection.js:1043-1092`, `:1129-1174`; root cause `:204-344` | Add the two missing signature terms (serialized lane set, colony policy), gated on `game.galaxy` | S |
| A7 | **One throwing draw permanently kills the view.** `drawFrame` has a bare `save()`/`restore()` pair; `loop.js:44` swallows a throwing render so the loop survives β€” but the camera transform is never popped, so every later frame draws on top of it, including the backdrop clear. Measured save-depth stays at 1 forever. A live trigger exists: `renderEffects.js:579` destructures `building.rally` unguarded, and `cleanEntity` never defaults `rally`. | `render.js:113`/`:146`; `renderEffects.js:579` | `try { … } finally { ctx.restore(); }` + a `rally` guard + restore `textAlign` at `renderBuildings.js:139`/`:723`, `renderNodes.js:38` | S |
| A8 | **An inert Helium Bomb renders as armed**, and idle units snap to another unit's commanded facing. The module-level `_disp` scratch is filled with `Object.assign`, which never deletes stale keys, so one unit's optional fields leak into the next. Measured: the armed-core fill appears twice for one armed bomb. It is a safety cue. | `renderUnits.js:43`, `:69` | `const d2 = { ...u, x: d.x, y: d.y }` (S) β€” or drop the scratch entirely (M) | S |
| A9 | **`runDuel` merges both candidates' overrides into one shared table.** Two variants of the same dial β€” the natural A/B workflow β€” silently degenerate into a mirror match against a Frankenstein row belonging to neither, and report a normal-looking winner. Every tuning decision runs through this. | `tools/ailab.js:672-673`, `:476` | Diff the two candidates' key sets first; throw a named error on any shared key | S |
| A10 | **The barracks hides units the engine would happily build.** The HUD reimplements availability as "every cost commodity has a local node", a rule the engine does not have β€” and the engine's own `tradeables` is the same predicate *one clause longer* (`present.has(c) \|\| (res[c] \|\| 0) > 0`). Ship gas to a gas-free world and the Wraith button is absent, not greyed. | `hudSelection.js:1405-1407` vs `engine/market.js:219-222` | Export `commodityAvailable(state, owner, com)` from `market.js`; both callers use it | S |
| A11 | **`cachedPowerThrottle` never invalidates on a `tick`-less state** (`undefined !== undefined` is false forever) β€” the stub idiom used throughout `industry.test.js` and reachable from any non-`tick()` caller. Also `buildingConcern`'s factory epsilon (`1e-6`) disagrees with `updateProduction`'s (none), so the badge can read "bufferFull" while the factory produces. | `engine/industry.js:302-317`, `:406`, `:422` | Initialise `_powerCacheTick = -1`; align the epsilon; fix the two false comments | S |
| A12 | **Owner-parametric leak:** `plannedMix`/`wantsDeepIndustry` read `state.ai`'s difficulty and strategy regardless of who is asking. Measured on a two-controller Odyssey state, the `"player"` controller's entire deep-industry identity is decided by the *other* controller's difficulty. `rivalGateEligible` is already owner-parametric and already wrong. | `engine/aiWorkers.js:157`, `:181` | Thread `owner` through, defaulting `"ai"` so single-owner behaviour is byte-identical | S |
| A13 | **⚠ replay β€” `autoTarget` is never cleared on a move order.** It is written in exactly one place and read by `isCombatMode`, so a unit pulled out of a fight with Move (the documented way to disengage) uses tight combat packing forever instead of the padded idle spacing `SEPARATION_PAD_MULT` exists to give it. | `engine/combat.js:32-36`, `:53-58` | Clear it in the move branch before returning. **Own commit; re-check `balance.test.js`.** | S |
## 1B. Save-safety β€” untrusted input reaching live state
`cleanEntity` is genuinely well-hardened, with ~20 coerced per-field cases and excellent reasoning. That
discipline **stops at the entity boundary**. Three whole sub-objects reachable from an Odyssey save are
written into live state verbatim.
| # | Hole | Where | Measured consequence |
|---|---|---|---|
| B1 | **Diplomacy block spread verbatim** β€” `{ ...createDiplomacy(), ...P.diplomacy }`, untrusted object wins. `clamp` is the identity on a non-number, so `stance` string-concatenates: `"friendly"` β†’ a 4,801-char string in ~160 sim-seconds β†’ trips `sanitizeSave`'s own `MAX_STRING_LEN` on the game's own output. Autosave rotates both generations, so **both save slots become permanently unloadable and the campaign is gone.** | `engine/persist.js:798` | Data loss. Fix pattern already in the file (`cleanPlayer`, `:329`). |
| B2 | **Market `pressure`/`glut` overlaid unvalidated.** Runtime clamps to Β±0.6 / 0.85; the load path clamps nothing. Measured: `unitPrice` of 5,000,005; a *negative* sell price; 125 M credits from one sell; and a string pressure makes `galaxy.credits` **irreversibly NaN** (`updateMarket` is multiplicative, so it never self-heals). | `engine/persist.js:795-797` | Economy death or infinite credits. The bands already exist in `market.js` β€” export and reuse them. |
| B3 | **AI controller restored verbatim**, 15 fields, no `num()`, no enum check β€” in a file where a unit's `facing` gets a five-line comment justifying its coercion. `nextAttackAt: "later"` makes `state.time >= nextAttackAt` false forever: **the AI's attack timeout is permanently disabled**, with no crash. `state.playerAi` has **zero** save coverage in any test file. | `engine/persist.js:639-670` | A quiet game reads as boring, not broken. |
| B4 | **`updateService` derefs `unit.cargo` unguarded** while every sibling path guards it. `persist.js:122` nulls a freighter's cargo while leaving `aiLogistics: true`, so a corrupt save loads "clean" and then throws on tick 1 **inside the rAF loop, past load's `try/catch`** β€” the player cannot get back in. This is verbatim the failure `production.js:76` already guards against. | `engine/haul.js:476-477` | Unrecoverable. Mint the slot lazily at the consumer. |
| B5 | **Lane load path drops two runtime invariants.** Duplicated `shipIds` survive (measured: 750 ore moved by one 250-hold hauler in one cycle β€” 3Γ— throughput), and `laneSeq` is not lifted past the ids the save carries, so a new lane can collide and become permanently unreachable from the UI. Both fix patterns are already written twice in the same function (`maxOwnEntityId`, `maxGId`). | `engine/persist.js:831-844`, `:789` | Free throughput; an undeletable lane. |
| B6 | **`rally` is the one coordinate pair never clamped.** `order` and `anchor` are both clamped for the stated reason that garbage "flows straight into `stepToward`" β€” `rally` flows to exactly the same place via `production.js:108`. Measured: a `1e9` rally spawns units at 2722, 3084 on a 1600Γ—1000 map, outside every fog and grid bound. | `engine/persist.js:98-269` | Reuse the existing `clampOrderCoords` closure. |
## 1C. Guards that don't guard
The most alarming section, because these are the mechanisms `CONTRIBUTING.md` cites as making its hard
rules executable.
| # | Guard | What's wrong | Fix |
|---|---|---|---|
| C1 | `determinism.test.js` | Fingerprints with a **local, weaker** snapshot than the project's own `_helpers.entitySnapshot` β€” proved blind, field by field, to unit cargo qty and commodity, move-order target coordinates, building `charge`, `tier`, `constructing`, and `state.time`. `_helpers.js:8` exists so "a dozen suites don't each re-roll them slightly differently" β€” and the most load-bearing suite re-rolled it. | Delete the local `snapshot()` and `mulberry32`; import from `_helpers.js`. Add a sensitivity test as the guard-on-the-guard. **S** |
| C2 | `determinism-roster.test.js` | Sweeps all 11 worlds but only **400 ticks = 40 sim-seconds** β€” measured **0 combat orders on every world**, ≀8 units, ≀3 buildings. The deep run uses the weak fingerprint on one world; the broad run replays only the opening. Raising to 2,000 ticks costs **+1.3 s** and buys 6,034 combat-order ticks. | Raise the loop; assert the fixture actually reaches combat. **S** |
| C3 | `engine-purity` + `static-integrity` | Both use non-recursive `readdirSync`. A file placed at `engine/net/lockstep.js` calling `Math.random`, `Date.now`, `window.localStorage`, `document` **and** importing a nonexistent module passed all four guards, 8 pass / 0 fail. The purity test's own rationale mentions netcode β€” the first thing that growth produces is `engine/net/`. Separately, `data.js` (19 importers, 9 of them engine modules) is scanned by neither guard. | One shared recursive `walkJs(dir)`; extend the purity scan to root modules transitively imported by `engine/`. **S** |
| C4 | `index.html` | Never validated. Changing `src="main.js"` to `src="mian.js"` **survives the full suite** β€” a totally dead game, green on two Node versions plus typecheck. `main.js` is also the one root module no test imports. | Resolve every `src`/`href` in `index.html` against disk. **S** |
| C5 | Module reachability | Nothing checks that a shipped module is reachable from the single entry point. This bug **already shipped** (`2a07a69`: the Tech Chart button and T hotkey silently did nothing), and three modules still hang off bare side-effect imports in `main.js`. 7 modules attach top-level listeners and would lose all wiring silently. | Walk the graph from the entry point parsed out of `index.html`; allow-list `engine/types.js`. **S** |
| C6 | `render-roster.test.js` | Proves "not the generic fallback", not "distinct from every other type". Traced pairwise: **`antimatter_gate` is drawn as a slightly larger `antimatterforge`** β€” the Odyssey wonder, a victory condition β€” and `torpedobattery` as a smaller `torpedoworks`. Also never varies entity state, so `constructing`, `capital`, `electrified`, tier pips, armed, laden and veterancy branches are all unreached; `drawUnits` is tested with exactly one unit type (Skiff, 1 of 18). | Radius-normalized silhouette uniqueness with a named `SHARED_HULLS` allowlist; roster Γ— state-variant sweep. **S** (test) |
| C7 | Render entry points | **9 of 14 exported render functions have zero test call sites**, `drawFrame` among them β€” the orchestrator owning draw order, `selSet` threading and the save/restore of A7. A wrong draw order silently reintroduces the "ship paints out a base's health bar" bug that `render.js:135` exists to fix. | Draw-order assertion + `doesNotThrow` smokes with realistic state. **M** |
| C8 | Vacuous render tests | `drawBuildingBars` and `drawBuildGhost` are covered only by `assert.doesNotThrow`. Replacing each body with `return;` **survives the full 1,832-test suite** β€” every health bar, build-progress bar, buffer gauge and stall badge can vanish, green. The same mutation on `drawEffects`/`drawBuildings`/`drawUnits`/`drawTerrain`/`drawNodes` was killed every time: the recording-Proxy pattern works, it just wasn't applied here. | Trace assertions using this file's existing *recording* `fakeCtx`. **S** |
| C9 | Save round-trip | Dropping `aiColonyTarget`, `aiLastThreatAt`, or the **entire `playerAi` block** from `persist.js` survives the full suite. (`aiWaveCount` and a wonder's `charge` were both killed β€” the per-field pattern exists, it just stops short.) | One table-driven controller round-trip test. **S** |
| C10 | UI import-safety | `CONTRIBUTING.md:52` requires UI modules to import cleanly under Node. **8 of 11 throw** β€” `hud.js`, `hudSelection.js`, `overlays.js`, `boot.js`, `observerPanel.js`, `techChart.js`, `setup.js`, `main.js` β€” and the pattern is inconsistent *within* files (`boot.js:101` guards, `boot.js:71` doesn't). All 13 DOM test files install `globalThis.document` before importing, papering over it permanently. | Child-process-per-module import check (a sibling's stub would mask it). **M** |
| C11 | `data-integrity.test.js` | Guards exactly one stringly-typed cross-reference. Three more seams are unguarded and currently clean: commodity keys across all cost/upkeep/consumes/inputs tables and 18 recipes, `BUILDINGS[*].produces` against `UNITS`, and `ODYSSEY_WORLDS` against `PLANETS` + the archetype map. The repo has been bitten by this genre twice already. | Three roster-driven tests, each red-provable with one typo. **S** |
| C12 | Suite integrity | `npm test` passes **no path**, relying on Node's implicit discovery globs (which changed across 18/20/22). Nothing asserts the ten named guard files still exist β€” delete one and CI is green with a smaller number nobody reads. `_helpers.js` is itself spawned as a test file. | `"test": "node --test test/"` + a meta-test parsing the guard names out of `CONTRIBUTING.md`. **S** |
| C13 | Perf-in-determinism | The suite's only two wall-clock assertions live **inside `determinism.test.js`** (measured headroom 4.8Γ—, not the "~6Γ—" claimed, on a two-version CI matrix). If the perf alarm trips, the file that goes red is the *determinism* guard β€” training contributors to dismiss a red determinism file as flaky. | Move both to `test/perf-guard.test.js`, budgets unchanged. **S** |
| C14 | Untested shipped behaviour | Commit `ac44339`'s ore-surplus army-cap feature has **zero tests** β€” proved by mutation: replacing its body with `return 0` leaves all 1,897 green. The commit records "manually probed" in place of tests. Blind because every `standingArmyCap` test runs in skirmish, where the code short-circuits. | Three `endless`-state tests, incl. the skirmish guard. **S** |
| C15 | Hot-path contract | `grid.js`'s shared `_scratch` buffer has a documented aliasing contract ("none makes a second `queryNeighbors` call while a prior result is still being iterated") that no test asserts. Two call sites hold it *across* a loop body. A violation would deterministically skip separation pairs β€” invisible to determinism testing, since both runs would be wrong identically. | Pin the reuse contract by array identity, plus the real guard. **S** |
| C16 | Dead branch | `loop.js:39`'s backlog-drop branch is **unreachable at the shipped `hz`** β€” `0.25 s` clamp Γ· `dtFixed 0.05` = exactly `MAX_SUBSTEPS 5`, an undocumented exact identity coupling three constants. Measured 0 times taken at `hz=20`, 72 at `hz=60`. Bump to 30 Hz and a dead line starts running, handing the renderer an interpolation alpha β‰₯ 1. | Cover it at `hz: 60`; name the coupling at the constant. **S** |
## 1D. Make the type system real
Order matters here β€” **D1 unblocks everything else**.
1. **D1 β€” Fix the 12 drifted fields in `engine/types.js`.** `State.popCap`, `State.playerAi`,
`AiState.strategy`, `AiState.lastThreatAt`, `Galaxy.lanes`, `Galaxy.laneSeq`,
`GalaxySettings.startId`, `GalaxySettings.popCap`, and `Diplomacy.{provokedAt, goodwill, request,
lastFavorBucket}`; plus `Archetype.{wantsRefinery, turretCount, maxBarracks, garrison}` and
`Order.{tx, ty, explore, patrol}`. Also decide whether `Diplomacy.lastAiUnits` β€” declared but never
constructed β€” should be declared or deleted. Guard it with a text-diff test between each typedef block
and its factory literal, asserting on the *sorted array* so the failure names the field. **S, zero
runtime risk** (`types.js` has no runtime code and zero importers).
2. **D2 β€” Make the pragma mean something.** Add a `static-integrity` test asserting every `// @ts-check`
file annotates its exported functions with the shared typedefs. Red today on `engine/recycle.js`
(7 exports, 0 annotations). Without this, expanding coverage raises a number and catches nothing.
3. **D3 β€” Adopt, one file per commit, in this order:** `supply.js` β†’ `aiCommon.js` β†’ `colliders.js` β†’
`victory.js` β†’ `production.js` β†’ the AI leaves (`aiStrategy`, `aiDifficulty`, `aiArchetypes` β€” all
0 errors once D1 lands) β†’ `scout.js` β†’ `persist.js`. `persist.js` is the prize: every save field is a
`State`/`Unit`/`Building` field read by name, so annotating it makes **save-shape drift a check-time
error**. Leave `galaxy.js` and `entities.js` for last.
4. **D4 β€” Add `"tools/**/*.js"` to `jsconfig.json`'s `include`.** `tools/` is not in the program at all
today, so `ailab.js` and `selfplay.js` cannot be checked even if they opted in.
5. **D5 β€” Evaluate `strictNullChecks` separately.** `engine/haul.js` *has* the pragma and `Unit.cargo` is
correctly typed `Cargo|null`, yet B4's crash produces no error because `strict: false` turns off null
checking. This is its own change with its own fallout β€” keep it off the critical path.
## 1E. Documentation drift
- **E1** β€” `docs/improvement-roadmap.md:77` still lists the Technologist archetype as pending Phase 2 work,
and `improvement-proposals.md:491` still describes Kybernet in the present tense as playing "a generic
Economist". It shipped in `17e2aad`. The roadmap's Phase 2 gates Phase 4, so a completed item sitting in
the pending table can block downstream work. **S**
- **E2** β€” `README.md`'s Project layout omits **19 shipped modules**, including `hudSelection.js` (the
largest file in the repo) and `main.js` (the page's only entry point, and the file whose missing import
caused `2a07a69`). The README never mentions `npm run typecheck`, which CI runs as a required step.
`CONTRIBUTING.md:82` lists 6 `// @ts-check` files; the real count is 10 β€” understating existing coverage
by 40%, on the list that is the on-ramp for D3. **S**
---
# Tier 2 β€” Structural
Real value, but each needs care: several move replays and need re-baselining, and a few change a public
surface.
**Correctness with a wider blast radius**
- **Collection-point freighter stalls empire-wide.** `assignShuttle`'s "don't leave yet" guard runs through
`zoneFirst`'s *unbounded global* fallback, so a partly-loaded ship waits on a backlog anywhere in the
empire. Reproduced with two bases 3,600 px apart. It is also the only zone-aware scan that doesn't
forward `unit.homeCC`. Single-CC games are unaffected by construction. `engine/haul.js:623-631`. **M**
- **Yield bonus overfills a hold, and save/load then deletes the excess** (measured 251 β†’ 250 on a 250 cap).
Breaks the hold's capacity *and* "a save round-trips identically" β€” invisible to replay testing because
both runs agree until someone saves. `engine/gather.js:138-143`. **S**
- **`garrisonSlots`' single corrective pass never converges** (residual 0.1 px at 8 units β†’ 1.7 px at 40),
and the branch has **never executed under `npm test`** β€” every dispersal test uses 1 or 5 units around a
bare CC, which doesn't overshoot until ~60. `engine/aiMilitary.js:301-314`. **S**
- **`order.patrol` carries two incompatible meanings** β€” a boolean requeue flag in `sim.js:170` and a
numeric circuit index in `scout.js:54`. Latent today only because `issueScout` bypasses the order queue;
index 0 is falsy, 1–3 requeue forever. **S**
- **`nearestGatherDrop`/`nearestCommandCenter` are the only "nearest" scans with no id tie-break**, while
six siblings pin theirs explicitly. `zoneFirst` resolves zone membership by identity against the result,
so two equidistant CCs make every zone boundary depend on Map insertion order. Correct today; a
different *kind* of guarantee from its neighbours. **S**
- **`radiusOf` triplicated and already divergent** β€” `bomb.js`'s handles buildings, the other two return
`9`, and `movement.js`'s `// @ts-check`ed JSDoc advertises `Unit|Building`. Move the correct body to
`engine/colliders.js`. **S, ⚠ replay-adjacent** (hot path; verify all call sites pass units β€” they do).
- **Galaxy meta sets unfiltered** (`pacified`, `reached`, `discovered`, `wonBy`, `settings`) while `claims`,
`colonyPolicies` and `worlds` are filtered three lines away. Free domination milestones from junk ids,
and a tampered `settings` builds a NaN-sized map on the next jump β€” the galaxy twin of a hazard already
closed on the skirmish path. Careful: `reached` must filter against *milestone* ids, not world ids.
**M**
**Tests that would have caught the above**
- **Conservation-of-resources property test.** No test anywhere sums total holdings across treasury +
building `store`/`input` + freighter `freight` + worker `cargo`. This one property catches A4 and the
overfill bug together, and it documents the *intentional* exceptions (rig overflow spill, wonder feed,
recycle fraction) which today are only prose. The helpers already exist and are exported. **M**
- **"Every state field is persisted or deliberately transient."** The NET round-trip tests cannot express
"forgotten". A denylist test over `Object.keys(state)` / `Object.keys(createGalaxy(...))` goes **red
today on `rivalAscended`** β€” the general net that would have caught A5. Fold `claims`, `discovered`,
`wonBy` and `rivalAscended` into `_helpers.galaxySnapshot` too. **M**
- **Panel-completeness meta-test.** Table-driven, one row per panel family, asserting the panel actually
rebuilds when its own state changes. Red today naming exactly the unwatched panels β€” converts A6's class
of omission from invisible to red. **M**
- **`commandAt` precedence table.** The 10-branch right-click ladder is game policy the engine does not
re-check, and `issueEscort`/`issueFerryFreighter`/`issueSetHomeBase`/`issueAssistBuild`/`issueRepair`
have **zero** occurrences in `input.test.js`. No test pins which branch wins when two match. **M**
- **`ailab.test.js` permanently mutates the shipped `STRATEGIES` table** (`aggressive.garrisonMult`
0.4 β†’ 0.9) with no restore β€” so 21 later tests measure a strategy the game never ships, and the suite's
own three leak-detector tests capture their baseline *after* the leak. `snapshotTables`/`restoreTables`
are already exported and simply not imported. **S**
**Speed and structure**
- **`test/ailab.test.js` is 87% of the suite's wall clock** β€” ~130 s for 65 tests, all guarding a dev
bench. Roughly 51 s is spent simulating real matches to assert *pure combinatorics* (pairing counts, bye
rotation, sort order, `ceil(log2(n))`). The file already demonstrates the fast idiom once, at `:834`, and
its own comment explains why it's better. Dropping the loop from ~150 s to ~20 s is the difference
between "run the full suite every cycle" being followed and being routed around. **M**
- **Swiss standings rank raw win totals across unequal match counts**, so the ranking is literally
"everyone who avoided a bye, then everyone who didn't" β€” and the round-1 bye is decided by **CLI argument
order** (measured: always the last-listed candidate). This is the mirror image of the bug `e9ad1d0` just
fixed. **M**
- **The side-swap runs its two directions on different map seeds** (measured overlap: 0 of 2), so the one
mechanism built to detect seat asymmetry is confounded with seed variance and cannot fire β€” while
`tickSelfPlay` has a real, measured, always-same-direction seat edge. Sort the names into the seed hash;
`swapAsym` already shows what the paired design looks like. **S** (re-baselines duel/Swiss fixtures)
- **`tickSelfPlay`'s ordering claim.** Pick one deliberately: make it true, alternate by tick parity, or
correct the comment and pin the bound with a characterisation test. Do not leave the comment and the
code disagreeing. **M**
- **Rebuild-signature co-location.** 141 lines with 11 inline IIFEs, each re-deriving a lookup that
`rebuildSelectionPanel` derives again further down the file. **M**
*(Partly done, 2026-08-08: `rebuildSelectionPanel` is now a dispatcher over twelve named
`renderX()` families instead of one 871-line body, so the "render" third of the registry exists
already. What is left is moving each family's signature term next to its renderer β€” now a
per-family move rather than one big one.)*
- **Production rosters copied out of `BUILDINGS[].produces` in three hand-maintained shapes**, with the
Odyssey gate duplicated *differently* from the engine's own. Land the test before the refactor β€” it
passes today. **S**
- **Silhouette collisions** (C6's art half β€” the Gate deserves a bespoke hull), **per-frame allocation**
(measured 2.16 ms of pure JS and 0.9 MB/s of garbage at 306 units, before any rasterization; pop caps go
to 300 *per side*), **five byte-identical cross-module function bodies** (the two that actually bit
β€” `clamp`, `negate` β€” are shared now and re-forking is guarded by
`test/static-integrity.test.js`), and the **UI import cycle** (now 7 modules, not 6: `competition.js`
joined it deliberately. Still benign only because every back-edge is called at runtime rather than
at module-evaluation time, and still failing as a blank white screen if that stops being true β€”
but no longer "guarded nowhere": a Tarjan check in `test/static-integrity.test.js` freezes the
cluster's exact membership, so a new cycle anywhere, or an eighth member here, fails the suite).
Each **S–M**.
---
# Tier 3 β€” Deep refactors, design call first
Do not start these until the design question in each is answered. All are worth doing eventually; none is
urgent, and each is a poor first PR.
**All seven are now delivered** (see Status, above, for the three whose answers changed what got built).
Outcomes, in the table's order:
1. `hudSelection.js` β€” split by *derive vs render*: `hudPanelSignature.js`, 2,242 β†’ 1,915 lines, behind a
25-fixture golden lock landed one commit ahead of the extraction. The per-panel registry remains the
destination; this is the seam it moves into.
2. `haul.js` β€” four explicit `*_PHASES` sets with a terminal unknown-phase guard. The collection-point
asymmetry was ruled **intentional** and is now documented at the code rather than inferable from it.
3. AI decisions β€” nine predicates exported in place; the 13-site build idiom collapsed into
`tryBuild`/`tryBuildAt`.
4. Save schema β€” the field table was **rejected on inspection** and replaced by a payload-level
round-trip guard, which delivers the property the table was for.
5. Render boilerplate β€” `centeredText`, `drawLabelChip`, `hiddenByFog`. A blanket `fillStroked` collapse
was measured and skipped.
6. Test harness β€” nine hand-rolled DOM doubles consolidated into `test/_dom.js`, one file at a time, with
a suite-integrity guard against regrowth.
7. Formation ranking β€” **per-cluster**, not global. The first test written for it compared destinations
to *origin* centroids and so measured nothing; rewritten to the real invariant (ranking permutes
within a cluster, never across).
| Refactor | The question to answer first | Safety net required before starting |
|---|---|---|
| **`hudSelection.js` decomposition** β€” 2,201 lines, 2 exports, one 845-line function with 17 entity-type branches. Proposed: a panel registry of `{ key, match, signature, render }` over five modules, leaving a ~250-line orchestrator. Co-locating `signature` with `render` is what structurally prevents A6 recurring. | Registry shape, and whether the runtime `hudSelection β†’ hud β†’ hudSelection` cycle constrains it | Golden `panelTree()` serializer over ~20 fixtures, committed *with* the harness; every extraction commit must leave all goldens untouched |
| **`haul.js`'s four phase machines** β€” 657 lines, 14 untyped phase strings, five near-identical "walk there, then act" legs, two provably dead branches, and a behavioural asymmetry hidden in the duplication (`updateHaul` banks at a collection point; `updateService`/`updateFerry` walk past one to the CC). | **Is the collection-point asymmetry intentional?** That is a behaviour decision, not a refactor | Pin both behaviours first. *The unknown-phase guard β€” a terminal `else unit.order = null` β€” is a Tier-1 slice worth splitting out now* |
| **AI decision extraction** β€” `ai.js` has one export, `aiContext` is private, so every phase test must stage a whole match. Twelve pure helpers are unreachable; this is the **direct cause** of C14 and the `garrisonSlots` gap. Plus a 13-site build idiom. | Where the seam lives (`aiDecisions.js` leaf vs. exports), and how to handle the two build sites that do extra work inside the `if` | Characterisation tests on `standingArmyCap` and `withoutHomeGuard` against hand-built plain objects |
| **Declarative save schema** β€” ~630 of `persist.js`'s 865 lines are hand-rolled per-field code across four functions, with the 15 controller fields written out **four times** with two hand-typed prefixes. This is the root cause of A5, B1–B4 and B6 all being *omissions* rather than mistakes. | Whether the payload must stay byte-identical (it must β€” every `.prev` autosave in the wild) | The controller field-set equality test, and the two version-coupling guards, both **Tier 1 and cheap**: `SAVE_VERSION === GALAXY_SAVE_VERSION` while `serPlanet` is shared, since the galaxy embeds per-planet payloads with no per-planet `v` |
| **Render boilerplate extraction** β€” ~1/5 of `renderBuildings.js`'s 809 lines is copy-pasted drawing shape (30 hairline-outline sites, four near-verbatim blocks). New roster art is written by copy-paste, which is how the silhouette collisions and the missing `textAlign` restores arrived. | How far to push the primitives | Land C6's state-variant coverage **first**, then pinned per-type traces |
| **Shared test harness** β€” `_helpers.js` is imported by 7 of 101 files; the rest hand-roll 6 divergent fingerprints, 7 `fakeCtx` variants (with three-way circular attribution in their comments) and **1,538 lines** of DOM scaffolding. C1 is the direct cost of that divergence. | Nothing structural β€” but migrate **one file per commit**; the stubs differ in load-bearing ways | Per-file pass-count baseline; a shared fake being *stricter* is a feature, and any test it newly fails is a genuine finding |
| **Formation range-ranking vs. clustering** β€” `rankSlotsByRange` flattens the nested cluster layout `formationSlots` deliberately builds, so units cross-swap sub-formations and walk an extra ~150 px. | **Per-cluster or global ranking?** | A multi-cluster fixture asserting each follower lands nearer its own centroid |
---
# Suggested sequence
Five PRs, ordered so each makes the next safer. Roughly a week of focused work for Tier 1.
1. **The guards, first.** C1 + C2 together (they are two halves of one hole β€” deep-but-weak and
broad-but-shallow), C3, C4, C12, C13. Nothing else in this document is trustworthy until the
determinism guard can see cargo and the purity scan can see a subdirectory. Cost: ~1.3 s of suite time
and a handful of small test edits.
2. **D1 + D2.** Fix the 12 drifted typedefs and add the annotation-density guard. Cheap, zero runtime risk,
and it unblocks D3 β€” which is otherwise actively blocked, since annotating `supply.js` today produces
errors in correct code.
3. **Save-safety: B1 β†’ B2 β†’ B4 β†’ B5 β†’ B6, plus A5.** All identity-for-valid-saves, so the NET round-trip
tests stay green throughout. B1 first: it is the only one that destroys a campaign outright.
4. **The live defects: A1, A2, A3, A4, A6, A7, A8, A9.** Each is small and self-contained; A3, A4 and A7
fix bugs whose *identical twin* is already fixed and documented elsewhere in the same file, so the tests
are close to copy-paste. Hold A13 back for its own commit with the balance harness re-checked.
5. **C5, C6, C8, C9, C11, C14, C15, C16 + E1, E2.** Pure coverage and documentation. This is where the
mutation-testing findings get closed, and it is the batch that makes steps 1–4 *stay* fixed.
Then reassess. Tier 2 splits cleanly across parallel workstreams; Tier 3 should wait until its design
questions have actual answers.
*(Historical: this sequence was followed. Tier 1 shipped in seven commits, Tier 2 in five, Tier 3 in
eight β€” each tier merged as its own PR, with the design questions answered in writing before the
corresponding Tier-3 refactor started.)*
---
# Appendix β€” findings by domain
Full per-domain reports, with the reproductions and exact TDD plans behind each summary line above, were
produced for: **engine core sim** (8), **AI + ailab** (10), **economy/logistics** (10),
**persistence/galaxy** (10), **UI/HUD/input** (8), **render** (8), **test suite** (11), and
**cross-cutting** (10) β€” 75 findings total, of which 69 survived de-duplication and ranking into the
42 Tier-1, 20 Tier-2 and 7 Tier-3 entries above.
Domain health, in one line each:
- **Engine core sim** β€” genuinely good. Determinism discipline is real (`_gi` indices, `hashStr`
tie-breaks, two-pass splash), the hot path is allocation-conscious. Weak seam: entity liveness is asked
by `hp` while two removal paths delete live units.
- **Economy/logistics** β€” well-built; buffers clamped, throttles order-independent, tie-breaks explicit.
Weak seam: coverage is example-based and first-cycle, so "works once" hides "never again".
- **Persistence/galaxy** β€” excellent *for entities*, and that discipline stops exactly at the entity
boundary.
- **AI + ailab** β€” unusually well-decomposed; `ai.js` threading one `AiContext` through phase modules is a
good structure. Weak seam: the newest tier carries its correctness claims in prose.
- **UI/HUD/input** β€” hygiene is better than it looks (`AbortController` teardown, null-guarded queries,
`innerHTML` only from literals). The problem is shape, not rot.
- **Render** β€” the drawing is well-reasoned (real culling, cached silhouettes, batched fog). The problem is
the seam between "a draw threw" and "the frame recovers".
- **Test suite** β€” good by the metrics most suites fail. The holes are in the guards, not the ordinary
tests.
- **Cross-cutting** — architecture is healthy: zero engine→UI leaks, one documented cycle, zero orphans.
The gaps are in what's *enforced*, not what's *true*.