docs(@projects/@magic-civilization): 🧭 tooling — encode getState/never-reconcile + verify-premises lessons

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>
This commit is contained in:
Natalie 2026-06-27 05:38:43 -04:00
parent b93328cd11
commit 54d7f8f630
2 changed files with 33 additions and 7 deletions

View file

@ -70,15 +70,39 @@ the *data it indexes* already is; the index itself is correctly code.
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.
4. **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.")
5. **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: render `view_json`/`getState()`, send input via `act()`, run the turn via
`end_turn()`. The headless `GdPlayerApi` already embodies this — the live game converges to it.
---
## The principle behind all of it
## The principles 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.
**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.)
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.
**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.

View file

@ -12,6 +12,7 @@ Each rule below earned its place by being violated. Treat them as commandments,
- **Never assert what exists from reasoning.** `grep`/`ls`/`read` first, then cite `file:line`. If you can't verify, say "unverified."
- **Docs are intent; code is truth.** Design docs, CLAUDE.md, even *this* instruction set drift. This project shipped a `rust-source-of-truth.md` with three false claims (a phantom `mc-magic` crate, a `GdAiController` that "didn't exist" but did, a `BiomeRegistry` in the wrong crate). When a doc and the code disagree, **the code wins — and flag the doc.**
- **A recalled memory or a stale comment is a hint, not a fact.** Re-verify any file/fn/flag it names before relying on it.
- **Verify your architectural PREMISES before committing to a plan.** A plan built on a remembered shape drifts. (This project once drifted swap → logic-extraction → per-formula-FFI across three turns, each on an unchecked premise; one `grep` of `view.rs` collapsed all three.) Re-grep the shape first, then plan.
## 2. Know where code goes (the layering)
@ -23,6 +24,7 @@ Before writing any non-trivial code, classify it and place it. Full procedure: *
- **Content** (stats, costs, thresholds, names) → a **JSON pack**. Nothing hardcoded.
- **Shared type** (used by ≥2 crates) → `mc-core` — the *type* only, never its rules.
- **Grep the owning crate before computing any game number.** Reimplementing a formula is how `_process_growth` diverged from `mc_happiness::get_growth_modifier`. If the fn exists, call it; if not, write it *in the crate* and call from both.
- **Rust drives everything; the UI is a pure view of `getState()`.** Authoritative state + logic are 100% Rust. Presentation renders `view_json`/`getState()`, sends `act()`, runs `end_turn()` — it never holds authoritative state, orchestrates the turn, or calls per-phase logic. A GDScript formula that disagrees with the crate is a **bug to delete**, never a baseline to reconcile (balance tuning happens in Rust/JSON). Prefer **deleting** GDScript logic over making it call Rust.
Layer specifics: **`rust-source-of-truth.md`** (Rust/crates), **`gdscript-conventions.md`** (`.gd`).