docs(@projects/@magic-civilization): 🧭 agent tooling — add code-layering decision guide + fix rust-source-of-truth drift

Better-guide-agents pass, grounded in this session's architecture review (GDScript turn-logic
divergence, rules leaked into mc-core). Two-pronged:

NEW `code-layering.md` — the decision PROCEDURE that was missing (the Rails state the rule; agents
still drifted because there was no "where does this code go?" procedure). Classifies every change as
formula/orchestration/presentation/content/shared-type, gives each one home, mandates grep-before-
reimplement, uses biomes as the template, records the real anti-patterns hit. Wired into the
always-loaded router + README index.

FIX `rust-source-of-truth.md` drift (it was actively misleading agents):
- "AI exception (one of one)" CONTRADICTED Rail-1 → recast as tech-debt; GdAiController DOES exist
  (api-gdext/src/ai.rs:333), dispatch via mc_player_api::controllers.
- crate table listed phantom `mc-magic` + claimed BiomeRegistry in mc-core (false) → accurate
  pure-logic-vs-orchestrator table; mc-core = shared TYPES, no rules.

game-systems + godot-engine agents point at code-layering at the fault lines they own.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-27 04:29:29 -04:00
parent e307db755d
commit 40dbe84415
6 changed files with 120 additions and 19 deletions

View file

@ -38,6 +38,7 @@ Modules live at `.claude/instructions/<file>.md` (symlink resolves to `tooling/c
| Game 1 scope, Game 2 deferral, what-ships-where | `scope-game1-vs-game2.md` |
| Hex tile geometry, centre + 6 edge slots, biome-edge contact, edge ZOC | `public/games/age-of-dwarves/docs/HEX_GEOMETRY.md` |
| Rust crates, GDExtension/WASM build, simulation logic | `rust-source-of-truth.md` |
| **"Where does this code go?" — formula vs orchestration vs presentation vs content vs shared type** | `code-layering.md` |
| GDScript authoring (preload, signals, hex math, entities, IDs) | `gdscript-conventions.md` |
| "Where does this file go?" / `src/` tree / symlinks | `file-organization.md` |
| Picking which specialist agent to dispatch | `agents-task-map.md` |

View file

@ -5,6 +5,8 @@ description: Use for economy (gold income/expenses/upkeep), happiness (global po
You are the game systems specialist for Magic Civilization. You own all economy/happiness/culture/production logic, the entity classes (Player, City, Building, Improvement, Archon), and turn-end sequencing. You are the backbone that all other systems hook into.
> **Before writing any formula or turn phase, load `.claude/instructions/code-layering.md`.** You sit on the exact fault line where logic drifts: a growth/happiness/economy formula belongs in its pure-logic crate (call it from the orchestrator); turn *sequencing* belongs in the orchestrator; the live GDScript turn must never inline a formula. `grep` the owning crate before computing any game number — reimplementing one is how the live `_process_growth` diverged from `mc_happiness::get_growth_modifier`.
## Primary Domain — Rust (write logic here)
```

View file

@ -5,6 +5,8 @@ description: Use for project.godot setup, autoloads (GameState, TurnManager, Dat
You are the Godot engine infrastructure specialist for Magic Civilization. You own the project skeleton, all autoloads, the scene management system, the save/load pipeline, and the GDExtension integration that wires the Rust simulator into Godot.
> **`turn_manager.gd` / `turn_processor.gd` orchestrate the live turn — they must CALL Rust, never compute.** Load `.claude/instructions/code-layering.md` before touching them. A turn phase may sequence FFI calls; it must not contain a game formula (that's how growth/happiness math drifted from the crates). When a phase needs a number, expose a thin FFI on the owning crate and call it.
## Your Domain
```

View file

@ -22,6 +22,7 @@ tooling/claude/
├── README.md # this file
├── scope-game1-vs-game2.md
├── rust-source-of-truth.md
├── code-layering.md
├── gdscript-conventions.md
├── file-organization.md
├── agents-task-map.md
@ -49,6 +50,7 @@ tooling/claude/
|------|---------|---------------|
| `scope-game1-vs-game2.md` | Deciding whether a feature ships in Game 1 or defers to Game 2 | ~450 |
| `rust-source-of-truth.md` | Rust crate work, GDExtension/WASM builds, simulation logic placement | ~750 |
| `code-layering.md` | "Where does this code go?" — formula/orchestration/presentation/content/type decision procedure + grep-before-reimplement | ~750 |
| `gdscript-conventions.md` | Any `.gd` authoring — preload, signals, hex math, entities, IDs | ~550 |
| `file-organization.md` | Creating files, "where does this go?", `src/` tree audit | ~900 |
| `agents-task-map.md` | Choosing which specialist to dispatch | ~450 |

View file

@ -0,0 +1,84 @@
# Code Layering — Where Does This Code Go?
**Load when:** about to write or move any non-trivial code — a formula, a turn phase, a
rule, a UI handler, a shared type. Also load before answering "where should this live?"
The five Rails state the *what*. This module is the *decision procedure* — because every
layering breach this project has hit came from skipping the decision, not from disagreeing
with the rule. Run the procedure **before** writing, not after a reviewer catches it.
---
## The one question that prevents 90% of breaches
> **Is this a game FORMULA/RULE, ORCHESTRATION, PRESENTATION, CONTENT, or a SHARED TYPE?**
Classify first. Each class has exactly one home. Mixing them is the recurring bug.
| If the code is… | It goes in… | It must NOT go in… |
|---|---|---|
| A **formula or rule** (damage, growth modifier, yield gating, happiness band, capacity) | a **pure-logic crate** (`mc-combat`, `mc-economy`, `mc-happiness`, `mc-city`, `mc-ecology`, `mc-tech`…) | an orchestrator (`mc-turn` **or** the live turn), GDScript, or `mc-core` |
| **Orchestration** (sequence phases, decide *when* to call a formula) | an **orchestrator**: `mc-turn` (bench) / the live turn — which **calls** the formula crates | — (orchestrators call formulas; they never inline them) |
| **Presentation** (render, input, signals, animation, layout) | **GDScript** (`src/game/engine/`) / React (guide) | any Rust crate |
| **Content** (stats, costs, thresholds, names, colors, tags) | a **JSON pack** (`public/games/.../data/` or `public/resources/`) | any Rust or GDScript file |
| A **data type shared by ≥2 domain crates** | `mc-core`**the type only, never its rules** | a domain crate (would force a dependency cycle) |
---
## The verification step (do this, don't trust memory)
Before you write a formula in an orchestrator or in GDScript, **grep for an existing crate
function first.** Reimplementing a formula that already exists in a crate is how the two
copies drift. This session found a live divergence exactly this way:
```bash
# About to compute a growth/happiness/combat number? Check the crate owns it already:
grep -rn "get_growth_modifier\|growth_modifier" src/simulator/crates/mc-happiness/src/
```
If the crate fn exists → **call it** (via FFI from GDScript, or directly from the bench).
If it doesn't → write it **in the owning crate**, then call it from both orchestrators.
Never type the formula into the orchestrator or GDScript "just this once."
---
## The template to copy: biomes
Biomes get every layer right — use them as the reference shape:
```
CONTENT public/resources/biomes/<world>/biomes.json (data, Rail-2)
TYPED VIEW mc-city::BiomeCapacity, mc-ecology::BiomeTraitWeights,
mc-flora::BiomeSubstrateClimateMap (one per owning crate)
RUNTIME INDEX biome_registry.gd — builds a tag-query cache *from* the JSON (code)
```
Note: a **registry/index is code** (it lives in the engine/crate and is built *from*
content); the **content is JSON**. "Should the registry be in `public/resources`?" → no —
the *data it indexes* already is; the index itself is correctly code.
---
## Anti-patterns this project actually hit (don't repeat)
1. **Formula in the live GDScript turn.** `turn_processor.gd::_process_growth` hardcoded a
`1.25` growth modifier and *diverged* from `mc_happiness::get_growth_modifier`. A formula
in an orchestrator is a future divergence. Extract it to the crate; call it from both.
2. **Rules leaked into `mc-core`.** `mc-core/src/resources.rs` holds the `Resource` *types*
(fine) **and** rule functions `is_yield_active` / `is_tile_visible_for_player` (not fine —
those are economy/vision rules). `mc-core` is shared *vocabulary*, never shared *rules*.
3. **Bench logic ≠ crate logic.** When the headless bench (`mc-turn`) needs a number, it
must call the same crate the live game does — not grow its own bench-grade copy. Three
copies of a formula (GDScript, `mc-turn`, the crate) is the worst case.
---
## The principle behind all of it
**Two state representations are fine; two logic copies are not.** A fast simplified bench
(`CityState`) and a rich live model (`City` / `CityScript`) are legitimately different — they
serve different jobs. What must be single-source is the **logic**: the formula that turns
state into a number. Unify *logic into crates*; do not unify *state* just to share logic.
When unsure, prefer the crate. Pulling a formula *up* into a pure crate is cheap and always
correct; pushing it *down* into an orchestrator or GDScript is the breach.

View file

@ -4,11 +4,13 @@
The engine is **genre-agnostic**. All game content and display text comes from game packs. The fantasy game "Age of Dwarves" is the default pack.
**Rust is the simulation source of truth for everything except AI action generation.** Physics, combat, economy, pathfinding, magic, tech, and turn resolution all live in `src/simulator/` and compile to two targets: GDExtension for the Godot game, WASM for the web guide. GDScript is the presentation layer — rendering, UI, input, signals, and thin wrappers that delegate to the Rust GDExtension.
**Rust is the simulation source of truth — including AI.** Physics, combat, economy, pathfinding, tech, turn resolution, *and* AI decision-making all belong in `src/simulator/` and compile to two targets: GDExtension for the Godot game, WASM for the web guide. GDScript is the presentation layer — rendering, UI, input, signals, and thin wrappers that delegate to the Rust GDExtension.
## AI exception (one of one)
> **Deciding where a piece of code goes (formula vs orchestration vs presentation vs content vs shared type)? Load `code-layering.md` first** — it's the decision procedure that prevents the layering breaches this rule keeps describing.
AI action generation is currently implemented in GDScript at `src/game/engine/src/modules/ai/simple_heuristic_ai.gd`. The `mc-ai` Rust crate exposes only scoring weights (`ScoringWeights`, `StrategicWeights`) and data structs for future MCTS work; the action-generation pipeline is not built in Rust. The `GdAiController` GDExtension class referenced in some older comments **does not exist** — any code attempting `ClassDB.instantiate("GdAiController")` would return null. `AiTurnBridge.run(player)` calls `SimpleHeuristicAi.process_player(player)` directly.
## AI is NOT an exception — it's tech-debt being paid down
There is **no permanent GDScript carve-out** for AI (Rail-1). New AI work lands in the `mc-ai` crate + the `api-gdext/src/ai.rs` bridge (`GdAiController` **does exist**`api-gdext/src/ai.rs:333`), and dispatch routes through the `controller_id`-keyed registry in `mc_player_api::controllers` (`ScriptedController` is the in-box default). The legacy GDScript AI files (`simple_heuristic_ai.gd`, `ai_tactical.gd`, `ai_military.gd`) are **tech-debt tracked by `p0-26-ai-tactical-rust-port.md`**, not a documented exception — do not add new AI logic to them.
## Canonical content store
@ -49,25 +51,33 @@ In practice these run on the RUN host via ssh — see `canonical-commands.md`.
## Rules
- **All simulation changes go in Rust** (`src/simulator/crates/`) — never in GDScript or TypeScript, **except AI action generation**, which currently lives in `src/game/engine/src/modules/ai/simple_heuristic_ai.gd`.
- **All simulation changes go in Rust** (`src/simulator/crates/`) — never in GDScript or TypeScript. AI included (legacy GDScript AI is tech-debt, not a carve-out — see above).
- **A formula has exactly one home: the owning crate.** Before computing a game number in an orchestrator (`mc-turn` or the live turn) or in GDScript, `grep` the owning crate — if the fn exists, call it; if not, write it in the crate and call it from both. Never inline a formula into an orchestrator. Full procedure + the real divergences this caused: `code-layering.md`.
- **Never hardcode thresholds** — read from `climate_spec.json` and other JSON data files.
- **Ecological events require a seed** — deterministic PRNG seeded per-turn.
- **Golden test vectors** verify WASM output matches expected results.
- **GDScript climate/combat/magic/economy files are thin wrappers** — they call `GdClimatePhysics`, `GdEcologyPhysics`, etc. via GDExtension. No physics logic lives in GDScript.
## Crate responsibilities
## Crate responsibilities (33 crates; key ones below)
| Crate | Owns |
|---|---|
| `mc-core` | GridState, TileState, BiomeRegistry, hex algorithms |
| `mc-climate` | ClimatePhysics, EcologyPhysics, atmosphere, spec evaluator |
| `mc-mapgen` | MapGenerator |
| `mc-combat` | CombatResolver |
| `mc-magic` | SpellSystem, ManaPool, Archons (Game 2) |
| `mc-economy` | EmpireEconomy |
| `mc-city` | CityGrowth |
| `mc-happiness` | happiness pool |
| `mc-culture` | CultureAccumulation |
| `mc-tech` | TechWeb graph |
| `mc-ai` | AI scoring weights + data structs (not action generation) |
| `mc-turn` | TurnProcessor |
Two roles — keep them straight (this is the layering that drifts):
- **PURE-LOGIC crates** own formulas/rules. The single source of truth for a number.
- **ORCHESTRATOR crates** sequence the pure crates into a turn. They **call** formulas; they never inline them.
| Crate | Role | Owns |
|---|---|---|
| `mc-core` | foundation | grid, seed/RNG, ids, phase, algorithms, **shared TYPES** (`Resource`, `ResourceStockpile`). **Types only — no game rules.** No `BiomeRegistry` here (biome data is JSON; typed views live in the owning crate). |
| `mc-combat` | pure-logic | CombatResolver, damage formulas, keywords |
| `mc-economy` | pure-logic | EmpireEconomy, gold/upkeep |
| `mc-happiness` | pure-logic | happiness pool + `get_growth_modifier` (the canonical growth/happiness math) |
| `mc-culture` | pure-logic | culture accumulation, border expansion |
| `mc-city` | pure-logic | city growth, `BiomeCapacity` (biome→yield) |
| `mc-tech` | pure-logic | TechWeb graph, research |
| `mc-climate` | pure-logic | ClimatePhysics, atmosphere, spec evaluator |
| `mc-ecology` / `mc-flora` | pure-logic | populations, succession, disease, `BiomeTraitWeights` |
| `mc-mapgen` / `mc-worldsim` | pure-logic | worldgen pipeline (from-epoch deep-time) |
| `mc-ai` | pure-logic | strategy/tactics + scoring (bridged via `api-gdext/src/ai.rs`) |
| `mc-state` / `mc-save` / `mc-replay` | state | `GameState`, save format, `TurnEvent` sink |
| `mc-player-api` | dispatch | `PlayerAction` → state; `controllers` registry |
| `mc-turn` | **orchestrator** | headless `TurnProcessor` — sequences the pure crates. **No bespoke formulas.** |
| `mc-sim` | **orchestrator** | GameRunner / self-play league |