magicciv/.project/objectives/p3-28-modular-turn-architecture.md
Natalie 6332d47011 fix(infra): make the DO fleet actually work on real hardware + render host
Real-DO testing surfaced bugs the mocked tests couldn't:
- ssh key: reference shared 'mc-fleet' key via data source, not a duplicate (DO 422s on dup pubkeys).
- cmd_dist_up: fail loudly on failed apply; dist:up waits for cloud-init readiness.
- snapshot cloud-init skips runcmd -> bake authorized_keys (FLEET_PUBKEY) + 'cloud-init clean' before snapshot.
- build user passwordless sudo; apt dpkg-lock race fixed (cloud-init --wait + Lock::Timeout).
- size s-8vcpu-16gb-amd (tier max); creds via PKR_VAR env not argv.
- render host: weston+Mesa baked; ./run dist:render proven (Godot->PNG on DO, no GPU). forge:dns shortcut.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 12:45:29 -04:00

5.5 KiB

id title priority scope owner status updated_at
p3-28 Modular turn architecture — break dep cycle, phase registry, boot-config DRY p3 game1 warcouncil partial 2026-06-27

Summary

The per-subsystem sprawl noticed while porting climate/events/happiness/healing/ecology revealed three SOLID/DRY/DIP debts. "Foundation first" tackled the layering + phase pieces.

Acceptance

  • Break the mc-turn↔mc-ecology dependency cycle (DIP) 90e5c3841 — mc-ecology pulled in the whole mc-mapgen crate only to reach mc_mapgen::seed, itself a re-export of mc_core::seed. Repointed to mc_core; dropped the mc-mapgen dep. Cycle root cut.
  • End-of-turn phase registry (OCP) ba60fe2d4 — mc-turn::sim_phases::END_OF_TURN_PHASES (ordered fn(&mut GameState) data list). Ecology moved out of its mc-player-api apply_end_turn special-case into mc-turn::step; all phases now in one crate. Extending the living world = one registry line + a phase module, no step() edit.
  • Boot-config DRY (Opportunity A) — collapse the 6 set_*_json FFI setters + 9 _apply_* GDScript harness loaders + N #[serde(skip)] GameState fields into one Rust-native content loader. Removes GDScript-shuttling-data (Rail-3 smell); single source of truth = the JSON files. NOT YET DONE (was the separate option 2). ⚠ WASM constraint (verified 2026-06-27) — a file-reading boot_from_resources(path) is wrong: shared simulation code compiles to WASM too, and the web guide has no filesystem. The loader must be a ContentRegistry populated by the host at engine init — Godot passes res:// bytes across FFI, the web guide fetches packs → bytes across bindgen — with include_str! surviving only as the headless/test fallback. So the target is a registry fed by injected bytes, not a path-reading function.
  • Rail-2 verify gate (enforcement, v1) — catch the next hardcode before it ships. 2026-06-27 — tools/check-no-rust-hardcoded-content.py + verify.sh Step 19 (parallel to the Rail-1 Step 18 gate). Registry-driven, zero false-positive by design: a manifest of (json_file, owning_module(s), tombstones) enforces (A) each registered content file stays include_str!-loaded by its owner, and (B) tombstoned const names (e.g. promotions XP_THRESHOLDS/HEAL_ON_PROMOTE_FRACTION) never resurrect. Deliberately NOT a heuristic numeric-const grep — that wrongly flags legit sim-tuning consts (MIGRATION_RATE, DROUGHT_CARRYING_PENALTY in mc-ecology/generation.rs). Proven: passes clean, fails on an injected XP_THRESHOLDS revert. Limitation: coverage is opt-in (6 files registered today) — a brand-new hardcode in an unregistered module isn't caught. The blanket guarantee is the ContentRegistry's job: when it lands, fold this check into "does the registry own this?" rather than a manifest. Until then, grow REGISTRY as content modules are identified.
  • Dedup the ad-hoc include_str! content sites (Opportunity A, same arc) — verified 2026-06-27: 8 JSON-config include_str! sites across simulator crates, each rolling its own OnceLock + a fragile relative path (6 at ../../../../../). treaty_rules.json is embedded 3 separate times (no dedup). These fold into the ContentRegistry above — registry.get::<T>("promotions") replaces per-crate path+parse. (WGSL shader include_str!s are correct to embed and stay.)
  • Widen the registry — optionally fold climate (convert the method to a free fn) + happiness into the registry / a positioned-phase model so the whole turn sequence is data.

Notes

Created 2026-06-26. The registry currently covers the contiguous end-of-turn world-sim phases (ecology, healing); climate stays a TurnProcessor method (shares climate helpers) and happiness stays positioned post-economy — both deliberately, to avoid risky reordering. The big remaining win is the boot-config DRY (3 layers → 1 Rust loader).

Content-loading audit (2026-06-27) — the two-path divergence the registry must kill

Why Opportunity A matters beyond DRY: content currently reaches the simulation two different ways, and they silently drift apart.

  • In-game (Godot drives): GDScript DataLoader reads packs at runtime and passes overrides across FFI (mc-player-api/src/projection.rs:41"overrides from DataLoader for the in-game path").
  • Headless (Rust drives alone): no DataLoader, so Rust falls back to its compile-time include_str! copy (mc-player-api/src/dispatch.rs:410"build-time copy for this headless path (no GDScript DataLoader)").

When one side is edited and the other isn't, the two paths disagree. Instance #1 (fixed this session): mc-combat promotion XP hardcoded XP_THRESHOLDS = [10,30,60,100,…] + 50% heal, while the canonical public/resources/promotions/promotions.json said [15,30,45,60] + 30% heal. This was a Rail-2 violation (Rust hardcoding game content) AND a two-path divergence. Fixed by routing mc-combat::{check_promotion,heal_on_promote,max_promotion_level,xp_threshold} through a OnceLock+include_str! config loaded from promotions.json (owner confirmed the JSON values are canonical — the 4 thresholds match the 4-level promotion trees; the Rust 8-level curve had 4 phantom levels). cargo test -p mc-combat -p mc-turn -p mc-player-api green.

That fix is the per-file pattern applied once; the ContentRegistry (Opportunity A) is the structural fix that makes the divergence impossible — both hosts feed one registry, so there is no second copy to drift.