Final agent-tooling pass folding in this session's hardest-won lessons (previously only in my memory): code-layering.md: - anti-pattern #4 "fixing the orchestrator instead of eliminating it" (the swap→extract→FFI drift; the fix is usually DELETE the GDScript path, let Rust compute + UI render getState()). - anti-pattern #5 "UI holding state / calling logic instead of reading getState()". - principles rewritten: (1) Rust drives everything — divergence is a bug to DELETE, never reconcile; (2) the UI is a pure view of getState() (render/act/end_turn, GdPlayerApi is the reference); (3) single-source LOGIC not necessarily STATE — but never two state copies in ONE running game. specialist-preamble.md (always-loaded core): - verify rule gains "verify architectural PREMISES before committing to a plan" (the 3-turn drift, collapsed by one grep of view.rs). - layering rule gains the Rust-drives / UI-is-a-view-of-getState one-liner. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6.6 KiB
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:
# 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)
- Formula in the live GDScript turn.
turn_processor.gd::_process_growthhardcoded a1.25growth modifier and diverged frommc_happiness::get_growth_modifier. A formula in an orchestrator is a future divergence. Extract it to the crate; call it from both. - Rules leaked into
mc-core.mc-core/src/resources.rsholds theResourcetypes (fine) and rule functionsis_yield_active/is_tile_visible_for_player(not fine — those are economy/vision rules).mc-coreis shared vocabulary, never shared rules. - 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. - Fixing the orchestrator instead of eliminating it. When you find logic in GDScript, the
reflex is "make it call Rust correctly" (extract a formula, add an FFI the GDScript calls).
That keeps GDScript calling logic — still the disease. The real fix is usually to delete
the GDScript path: the Rust turn computes it, the UI renders the result from
getState(). (This session drifted swap → logic-extraction → per-formula-FFI before landing on "delete it.") - The UI holding state / calling logic instead of reading
getState(). Presentation that reads authoritative entities (CityScript/Player) and orchestrates phases is the bug. The UI is a pure view: renderview_json/getState(), send input viaact(), run the turn viaend_turn(). The headlessGdPlayerApialready embodies this — the live game converges to it.
The principles behind all of it
1. Rust drives everything — never reconcile. When a GDScript formula disagrees with the crate, the GDScript is a bug to delete, not a baseline to preserve. Don't ask "which is the truth" or "make the crate match the live behavior" — that inverts Rail-1. Rust is the truth; the live game calls it. If adopting the canonical value changes gameplay balance, that's a separate tuning task done in Rust/JSON — it never gates or reverses the fix. (A "pending balance sign-off" comment in GDScript is itself a symptom of the violation.)
2. The UI is a pure view of getState(). Presentation renders view_json/getState(), sends
input via act(), runs the turn via end_turn(). It does not hold authoritative state, does
not orchestrate the turn, does not call per-phase logic. Authoritative state + logic are
100% Rust, per running context. The headless GdPlayerApi is the reference implementation — when
the live game diverges from that shape, the live game is wrong.
3. Single-source the LOGIC, not necessarily the STATE. Two separate Rust-owned contexts (the
fast bench mc-turn/CityState and the live game) legitimately differ in state shape — they're
different programs. What's illegitimate is one running game holding two state copies (Rust +
GDScript entities) that can diverge. Within a context: one authoritative state (Rust), the UI views
it. Across contexts: shared logic crates, never copied formulas.
When unsure, prefer the crate, and prefer deleting GDScript logic over making it call Rust. Pulling logic up into Rust (and rendering the result) is always correct; pushing it down into an orchestrator or GDScript — or having the UI call it — is the breach.