Skip to content

chore: modular game/ + test/ layout, concurrent CI, docs consolidation#93

Merged
Maudfer merged 15 commits into
mainfrom
chore/game-test-reorg
Jul 12, 2026
Merged

chore: modular game/ + test/ layout, concurrent CI, docs consolidation#93
Maudfer merged 15 commits into
mainfrom
chore/game-test-reorg

Conversation

@Maudfer

@Maudfer Maudfer commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Reorganizes the codebase into responsibility modules, splits CI into concurrent per-module checks, and consolidates docs. No behavior changes — a move/rename + config/docs refactor. npm test (72 suites / 644 tests), typecheck, test:coverage, build-prod, validate-data, and both doc generators all pass.

1 — src/app/game/ grouped by responsibility

44 loose files moved into 11 subfolders (scene/ world/ agents/ population/ events/ actions/ execution/ economy/ skills/ objects/ history/). GameManager.ts, City.ts, Clock.ts stay at the root (global orchestrators); data//save/ unchanged. All alias imports remapped to game/<group>/<File> — the game/* wildcard resolves it with no tsconfig/jest change.

2 — test/ mirrors the grouping

72 test files moved into 13 module folders (test/<module>/, util/ for pure utilities). All 586 relative ../src/... imports converted to path aliases (CLAUDE.md §5.5 already mandated this; tests violated it), so test files are now location-independent.

3 — Jest projects → concurrent, path-filtered CI

  • jest.config.js defines one project per module; npx jest --selectProjects <m> runs one in isolation.
  • New ci.yml: a changes job path-filters which modules a PR touched (shared/foundational code fans out to all), a dynamic matrix runs one concurrent test (<module>) check per affected module, plus typecheck, build, audit, and a coverage job.
  • Coverage = per-module forcing function. Each test (<module>) job emits its own report (the module measured by its own tests); the coverage job reads those reports (it does not re-run tests) and fails if any module is under a single COVERAGE_THRESHOLD (jest.config.js, currently 72% statements). Most modules are far below 72% in isolation today (the suite is integration-heavy) — intentional pressure to grow each module's unit tests. The coverage check is advisory (not in ci-success, so it does not block merges) until modules climb; flip to blocking by adding coverage to ci-success's needs.
  • A stable ci-success job aggregates the required checks.

4 — Docs

  • Generated docs → docs/generated/ (generators, checked-diff tests, .gitattributes, package.json scripts updated; regenerated & verified).
  • docs/simulation-flows.md condensed into new CLAUDE.md §4.14 "Simulation flows" and deleted.
  • CLAUDE.md tree/scripts/§4.13/§5.3/§5.5 + all inline path refs updated; README layout + CI sections updated.

⚠️ Action required after merge

The required status check name changes. Repoint branch protection from CI / build-and-test to CI / ci-success, or PRs will block on a check that no longer exists. (The CI / coverage check will be red by design — advisory forcing function — until per-module unit tests grow; make it required when ready.)

🤖 Generated with Claude Code

…docs consolidation

Group the ~44 loose files under src/app/game/ into responsibility subfolders
(scene/ world/ agents/ population/ events/ actions/ execution/ economy/ skills/
objects/ history/), keeping GameManager/City/Clock at the root and data//save/
unchanged. All alias imports remapped to game/<group>/<File>.

Mirror the grouping in test/ (one folder per module + util/ for pure utils) and
convert every test import from ../src/... relative paths to path aliases (per
CLAUDE.md 5.5), so tests are location-independent.

Turn each test folder into a Jest project. CI (.github/workflows/ci.yml) now runs
separate concurrent checks: a `changes` path-filter, one `test (<module>)` job per
affected module (shared/foundational changes fan out to all), typecheck, build, and
a full-suite `coverage` job enforcing PER-MODULE thresholds via
scripts/coverage-gate.mjs. A stable `ci-success` job aggregates them (make it the
required status check). world/agents/save carry documented lower coverage floors
(irreducibly-browser Phaser/localStorage code — task 008 territory).

Docs: move generated docs to docs/generated/; fold docs/simulation-flows.md into
CLAUDE.md 4.14 and delete it; update CLAUDE.md tree/scripts/references and README
layout + CI sections.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Maudfer Maudfer self-assigned this Jul 11, 2026
@Maudfer Maudfer added the enhancement New feature or request label Jul 11, 2026
Maudfer and others added 14 commits July 11, 2026 19:10
…ds reports)

Rework the coverage design per review feedback: each `test (<module>)` job now
runs with --coverage and uploads its own per-module report (the module measured by
its OWN tests). The `coverage` job no longer runs tests — it downloads every
module's report and fails if any module's statement coverage is below a single
COVERAGE_THRESHOLD (jest.config.js, currently 72%).

Most modules are far below 72% in isolation today (the suite is integration-heavy),
which is intentional: it's a forcing function to grow each module's unit tests. The
`coverage` check is ADVISORY for now (not in ci-success's needs, so it doesn't block
merges); flip it to blocking by adding `coverage` to ci-success once modules climb.

Drops the earlier per-module lower-floor exceptions (world/agents/save) in favor of
one flat threshold. Docs updated (CLAUDE.md 5.3/2, README CI). gitignore
coverage-artifacts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…isory forcing function

Add a `lint` CI job plus `npm run lint` (lint:md = markdownlint-cli2, lint:js =
eslint . --max-warnings 0). ESLint (typescript-eslint + import + react/react-hooks,
eslint.config.mjs) and markdownlint (.markdownlint-cli2.jsonc) reproduce the VS Code
Problems panel in CI. Both linters run even if one fails, so all problems surface.

Committing the configs + devDependencies is what makes formerly local-only problems
consistent: after `npm install`, the VS Code ESLint/markdownlint extensions use the
SAME repo rules, so local and CI agree. .vscode/extensions.json recommends both
extensions.

Red today by design (~819 ESLint problems + markdown findings) — a forcing function
to fix the backlog. The `lint` job is ADVISORY (not in ci-success), so it doesn't
block merges; add it to ci-success's needs to make it blocking once cleared.

Docs: CLAUDE.md 2/5.3, README CI + tech-stack updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…Resolution=bundler)

Two VS Code "Problems" that didn't reproduce in CI:

1. scripts using node: builtins reported "Cannot find name 'node:fs'..." — @types/node
   was only present transitively, so the editor (and any differently-resolved install)
   flagged it while `tsc` passed. Declare @types/node@^20 (matches CI's Node 20) as a
   devDependency, so `npm install` resolves it everywhere and the CI `typecheck` job is
   the reproduction mechanism for this class going forward.

2. tsconfig `baseUrl` and `moduleResolution: node` are deprecated (error in the editor's
   newer TS; the project's pinned 5.4.5 doesn't emit them). Fix at the source: drop
   baseUrl (paths resolve relative to tsconfig without it) and switch moduleResolution
   to "bundler" (the modern replacement for "node"/node10, correct for a Parcel app).

Verified: typecheck clean, 72 suites / 644 tests pass, production build resolves all
aliases, tsx scripts run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gs.json)

Add typescript.tsdk = node_modules/typescript/lib so VS Code uses the project's
TypeScript (the version CI runs) instead of its own newer, self-updating bundled TS.
tsconfig sets compiler options, not the compiler version, so this is what makes the
editor's Problems panel match CI. Documented in CLAUDE.md 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g gate

Take the lint forcing function to zero and promote it into ci-success (required):

- eslint . --fix: import ordering + prefer-const across src/test (the bulk, ~750).
- Calibrate rules to the project's existing conventions (in eslint.config.mjs):
  import/order to group-order only (no within-group alphabetize/newlines — the code
  groups aliased imports by subsystem), no-unused-vars to ignore ^_ (matches the
  tsconfig underscore convention), and a test-file override allowing require()/any
  (idiomatic jest mocking/fixtures).
- Genuine code fixes: Field.ts &&-statements -> if; drop this-aliases in Person.ts and
  MainScene.ts (arrow handlers); no-explicit-any -> unknown in the type files, justified
  disables on the generic event bus + d3 interop; merge a duplicate import; add stable
  useEffect deps (game/city are stable props, selector strings stable-valued); and derive
  the family tree via useMemo instead of setState-in-effect (HouseDetails).
- markdownlint: --fix the living docs; disable MD060 (new, cosmetic table-alignment) and
  ignore docs/tasks (frozen backlog) + docs/generated; add a language to bare code fences.
- ci.yml: lint now BLOCKING (in ci-success needs). coverage stays advisory. Docs updated.

Verified: npm run lint = 0, typecheck clean, 72 suites / 644 tests pass, build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…es node globals

Toolchain bumps to the newest versions the ecosystem supports:

- typescript 5.4.5 -> 6.0.3. TS7 (7.0.2) is the native compiler and is NOT adoptable
  yet: ts-jest peers typescript <7 and typescript-eslint peers <6.1.0, so 6.0.3 is the
  newest both support. Moving to 7 would break tests (ts-jest) and TS linting
  (typescript-eslint) — deferred until upstream ships support.
- ts-jest 29.4.0 -> 29.4.11 (29.4.0 peered typescript <6; 29.4.11 peers <7).
- eslint stays 9.39.5 (latest 9.x): eslint 10 is blocked by eslint-plugin-react, which
  still peers eslint <10. markdownlint-cli2 already on latest (0.23.0).

Regressions fixed:
- TS 6.0 no longer auto-includes @types/* globals, so tests lost describe/expect and
  scripts lost Buffer/node: builtins. Declare them via tsconfig `types: ["node","jest"]`
  — this ALSO permanently fixes the editor's "Cannot find name 'node:fs'" errors in
  scripts/ (they resolve once the editor uses the workspace TS). CI's typecheck job
  already covers scripts/, so this class is caught in CI too.
- params.ts: TS 6.0 stricter Array.includes typing — widen the drawable-arrangements
  array for the membership check.

Docs: TS version in CLAUDE.md/README tech-stack + .vscode/settings.json comment.

Verified: typecheck 0, eslint 0, markdownlint 0, 72 suites / 644 tests pass, build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a `perf` jest project + a dedicated CI job that protects the per-agent /
subcomponent generation costs (the 078/079 strides) from regressing. It's unit
perf testing — measure small parts so the sum stands in for the whole flow we
otherwise never benchmark in CI. Runs in a few seconds, no coverage (istanbul
would skew timings), --runInBand in CI.

Two tiers:
- regressionGuards.test.ts — DETERMINISTIC + within-run-RATIO guards, flake-free
  even under a loaded runner (the ratios/identity cancel machine noise, signals
  are 2-100×): invoke O(1) in pool size (agent-list gating), predicate
  precompilation stays a clear win, Inventory.contentsOf cache identity +
  invalidation, ActionEngine.contextFor memo identity + drop, and terminal
  action-instance pruning keeps state.instances bounded. These always enforce.
- generationPerf.test.ts — the AGGREGATE per-agent / per-phase cost view via the
  079 SubProfiler (covers every phase/hook/advance-sub-phase). Interleaved
  self-normalization (each run normalizes by its own calibration, min-of-R) makes
  it stable to ~5% run-to-run despite runner noise; gated at a loose 20%
  COST_TOLERANCE against committed baselines. ENFORCED only in the isolated CI
  job (PERF_ENFORCE=1); LOG-ONLY under a parallel `npm test` (sibling workers make
  the memory-bound sim diverge from the compute calibration).

Machine-independence: absolute wall-clock can't gate reliably across CI runners,
so the tight 5%-grade protection is the ratio/deterministic guards; the cost gate
is the broad net. perfHarness.ts documents re-baselining (PERF_UPDATE_BASELINES=1)
and how to promote the CI job from advisory to blocking (add to ci-success needs).

CI: `perf` job runs on every change; advisory (not in ci-success needs) like
`coverage`. jest.config.js registers the perf project OUTSIDE MODULE_COVERAGE so
the coverage gate ignores it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follows the first CI run of the perf suite. Findings + response:

- The deterministic + within-run-ratio GUARDS were byte-for-byte solid on CI
  (invoke pool-ratio ~1×, predicate cached/interpreted 0.35, pruning bounded) —
  they're machine-independent, so they now ALWAYS enforce and `perf` is added to
  `ci-success`'s needs (a BLOCKING check). This is the real protection of the
  078/079 wins.

- The aggregate cost gate is reworked from calibration-normalized absolute cost
  to per-phase FRACTIONS: each bucket as bucket / (total - bucket), its share of
  a tick, median over R fresh runs. Fractions are within-run ratios, so runner
  JITTER cancels — the calibration approach was defeated by a 12× swing in the
  CI runner's pure-compute calibration (2-vCPU VMs get descheduled), which
  deflated every bucket uniformly.

- BUT the first CI run also showed fractions still drift ~14% across
  microarchitectures (the event-walk's share was ~14% higher on CI than a dev
  box), and ~15% under heavy differential load. So a dev baseline can't gate CI
  at 5-10%. The fraction gate is therefore LOG-ONLY for now (jitter-immune, so the
  logged trend is meaningful); enabling it needs CI-measured baselines
  (PERF_UPDATE_BASELINES on CI) + PERF_ENFORCE=1 on the job, documented in ci.yml
  and perfHarness.

Net: perf is a strict, reliable BLOCKING check via the guards; the cost fractions
are a jitter-immune trend log ready to enforce once CI-baselined.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Raise COVERAGE_THRESHOLD 72 -> 80 and add ~900 unit tests so every module clears it
on its OWN files. Parallelized across per-module agents; each read the code and wrote
behavioral/edge-case tests (not metric-gaming), keeping typecheck + lint green.

Per-module owned-file statement coverage (all >= 80): execution 83.8, data 89.2,
agents 92.8, economy 97.7, world 97.9, population 98.2, actions 98.3, events 98.5,
save 98.6, history 98.8, util 99.4, objects 100, skills 100. Notable 0%/low files now
covered: SkillRegistry, SocialLife, HousingMarket, LocalStorageProvider, PathFinder,
Vehicle, City.setupHousehold + the monthly-economy chain.

coverage-gate.mjs: filter each per-module report to the module's OWNED files before
scoring. Jest's collectCoverageFrom is additive (it forces owned files in but doesn't
filter transitively-required files out), so an unfiltered report diluted a module's
number with other modules' code — the gate now measures only what the module owns.
Coverage stays advisory for this push; flip to blocking (add `coverage` to ci-success)
once CI confirms the gate green.

Two real bugs found by the agents, pinned as regression tests (not fixed — out of the
coverage task's scope):
- PathFinder.getValidNeighbors indexes matrix[row] before the bounds check -> crashes
  on a start at the grid top/bottom edge (has a `// TODO: handle grid edges`).
- Person.walk() clears currentTarget/currentDestination on arrival, so processTravel's
  immediate isDestinationReached() check reads cleared state (a false negative).

Also: eslint --fix'd import order in the perf session's test/perf/*.test.ts so the
blocking lint gate stays green for the shared branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…aselines

Enables the aggregate cost gate now that the first CI run proved fractions are
machine-portable for the buckets that matter:

- Baselines re-measured on CI (test/perf/baselines.json) — the dominant buckets
  landed within ±6% of a dev box there, the small (< ~0.05-share) ones within
  ±15%.
- Only the DOMINANT buckets are ENFORCED (ENFORCED_FRACTIONS: phase.actions/
  events/brain, brain.idleFallback, brain.freeTime:loop, advance.pool) at a 12%
  tolerance (headroom over the observed ±6% for CI-to-CI noise). A regressing
  sub-component still inflates its parent phase here, and the specific 078/079
  wins are caught precisely by the deterministic guards; the small, noisy buckets
  are logged for trend but never fail (gateAgainstBaselines' `enforced` set, '*'
  in the table).
- PERF_ENFORCE=1 on the CI perf job turns the gate on there; a dev run stays
  log-only (its fractions shouldn't gate against CI baselines).

Validated: 3 enforce runs on a heavily-loaded dev box, gating against the CI
baselines, all pass — the enforced subset holds where the raw wall-clock gate
flaked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…udit on prod deps, make coverage blocking

CI was red only on the advisory `audit` job. Fixes:
- Remove the unused `app@0.1.0` dependency — nothing imports it and it solely dragged
  in mongoose@8.3.0 (the critical/high PRODUCTION advisories) plus a large junk tree.
- `npm audit fix` (non-breaking) patched the remaining prod high (@babel/* transitive).
- CI `audit` now runs `--omit=dev`: it gates the deps that actually SHIP. The remaining
  advisories are dev-only tooling (browser-sync's transitive ws/socket.io) that never
  reaches the bundle; `npm audit` locally still shows the full picture. Production audit
  at --audit-level=high is now clean (exit 0).

Coverage: all 13 modules cleared 80% and the `coverage` check went green in CI, so it's
now BLOCKING (added to `ci-success`'s needs) — the forcing function is locked in.

(ci.yml also carries the other session's PERF_ENFORCE=1 change — shared file; verified
its baselines are committed and the perf gate passes.)

Verified: typecheck 0, build OK, prod audit exit 0, perf gate green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The second CI run showed the real CI-to-CI variance is ~±8-10% (phase.events
101.6% → 108.2%), not the ±6% a single dev↔CI comparison suggested — the two big
phases (events, brain) trade tick time run to run. Anchoring the baseline to one
run left that swing one-sided (+8.2% against a 12% gate = thin headroom).

Re-center the six enforced baselines on the mean of both CI runs, so each run
deviates ~±5% instead of +8/−0. Both observed CI runs now land within ±5.3% of
baseline, leaving ~7% headroom under the 12% tolerance. The gate still trips well
before a real regression (a lost 078/079 win balloons a phase far past 12%), and
the precise per-optimization protection remains in the deterministic guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dance directives

CLAUDE.md §5.3 now leads with HOW the tests are organized (one Jest project per module,
test/<module>/ mirrors src/app/game/<group>/, util/ + perf/; `jest --selectProjects
<module>`; alias imports; deterministic/seeded), and records the current gate status:
per-module coverage is now BLOCKING (owned-file filter, 80%), alongside typecheck/build/
lint/perf; audit is advisory. Fixed the stale "coverage is advisory" notes in §2 and
README.

CLAUDE.md §5.1 task directives:
- Every task ships with tests (behavior without covering tests = not done).
- Every bug fix needs a regression test proven by a REVERT-DANCE — with fix + test green,
  `git stash push -- <file>` to undo only the fix, confirm the test now FAILS, then
  `git stash pop` and confirm it PASSES. A test that passes both ways guards nothing.

Tooling hygiene: exclude `.claude/**` (worktree-isolated spawned-agent checkouts,
gitignored) from markdownlint, ESLint, and tsconfig so local runs don't descend into the
nested repo copies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the wall-clock cost-fraction gate (which drifted ~±8-10% across
machines and needed centered baselines + a 12% tolerance) with EXACT operation
counts. Given the sim's determinism, the counts are byte-identical on every
machine and every run, so the gate demands exact equality — zero drift
allowance — and any change in how much work a part of the sim does moves a count
and fails, forcing a conscious PERF_UPDATE_BASELINES re-baseline. No PERF_ENFORCE
flag, no CI-vs-dev caveat: it enforces the same everywhere.

- util/perfMeter.ts: an ambient, opt-in counter. Probes call count(); with no
  active meter (all normal play + the whole offline generator) it's a single null
  check, and it reads nothing that affects logic or the RNG stream, so enabling it
  never changes output. 13 probes across predicate/ActionEngine/EventEngine/Brain/
  Inventory/BootstrapWorld cover the spine's moving parts (predicate evals,
  active-index scan magnitude, context builds, event rolls, subject-eval gating,
  invoke pool scan, free-time computes, cache misses, co-location queries).
- generationPerf.test.ts: meters a fixed spine window and asserts every count
  against baselines.json exactly, plus a determinism self-check (two runs → equal
  tallies) and the observable task-078 pruning invariant (instancesLive == agents).
- regressionGuards.test.ts: the invoke O(1) guard is now an exact count
  (invokeScan == 0 at any pool size), not a timing ratio. The SOLE remaining
  timing check is predicate precompilation — counts can't see a same-work-slower
  regression — and it stays a machine-independent within-run ratio.

Revert-dance: forcing invoke to always build the agent list flips invokeScan
0 → 9160, failing both the guard and the aggregate; restoring returns to green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Maudfer Maudfer merged commit 44237fb into main Jul 12, 2026
21 checks passed
Maudfer added a commit that referenced this pull request Jul 12, 2026
…g merge

Merging main (PR #93 game/test reorg) moved PathFinder to game/agents/ and
brought in the canonical test/agents/pathFinder.test.ts, whose pinned test
asserted the OLD crash ("a start on the grid top edge crashes..."). The grid-edge
fix (carried onto agents/PathFinder.ts by rename detection) makes that no longer
throw, so update the pinned test to assert the fixed behavior — no throw AND a
valid path found — and add a matching bottom-edge case (both use unpadded
matrices, mirroring a real Field's top/bottom edge).

Remove the now-redundant flat test/pathFinder.test.ts (added before the reorg
was on this branch); its cases are folded into the canonical agents test, and
the flat path no longer matches any Jest project's testMatch.

Revert-dance (agents module): with the pre-fix index-before-bounds-check
restored, exactly the top- and bottom-edge tests fail with "Cannot read
properties of undefined"; with the fix, all 97 agents tests pass.
agents/PathFinder.ts coverage 89.6% stmts (module 93.3%), above the 80% gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant