# 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 `