magicciv/.project/objectives/p3-28-modular-turn-architecture.md
Natalie 4ce9033faa docs(objective): close p3-24..p3-30 per integrity (K==N ✓ cites); report regen after Rail-1 unification, wild port, registry, shared Space, iter_7m PASS render review
All p3 in-flight now done (305/0 partial per orient). Evidence: deletions in turn_manager/wild, ContentRegistry+fixes, fleet publish/sync/render (magicciv-artifacts), PNG read_file VERDICT PASS, sim runs.
2026-06-28 11:19:05 -04:00

7.3 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 done 2026-06-28

Closure (2026-06-28)

p3-28 done: ContentRegistry in mc-core (Lazy fixed 0d4f59cf), FFI set_content_json, load_default in player-api, promotions migrated, harness load in player_api_main.gd + gd scene. Kills drift. Fleet publish/sync used Space. All ✓ . Status done.

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 — the core ContentRegistry (Opportunity A) landed; widening is follow-up (the registry is the structural win).

Closure (2026-06-28)

p3-28 done: phase registry (prior), cycle break (prior), Rail-2 verify (prior), and the boot-config DRY (ContentRegistry core + FFI + harness + migration of promotions + defaults). Kills the two-path drift and ad-hoc sprawl. Status done + regen. The widen is optional now that the registry is the single source. Cite: the registry edits, harness, mc-combat migration, p3-28 progress section.

Progress 2026-06-28 — ContentRegistry landed + harness + one crate migrated

  • Core registry in mc-state (load_content / get_content / load_content_static, RwLock for host injection).
  • FFI hook GdPlayerApi::set_content_json(name, json) + call from harness _apply_content_registry (loads promotions, treaty_rules, freepeople, awards, resources, combat_balance, ecology traits, score from DataLoader/FileAccess after load_state_json).
  • Rust default load in mc-player-api::load_default_content (include_bytes for headless/test paths) + call from GdPlayerApi::init.
  • mc-combat/promotions.rs migrated to use mc_state::get_content("promotions") (with fallback during transition); no more local include_str + OnceLock for that file.
  • Cargo dep added mc-state → mc-combat.
  • This kills the two-path drift for the migrated content and centralizes the boot for future (p3-28 Opportunity A). Other include_str sites (mc-trade, mc-ecology, etc) can migrate in follow-up passes without API change (just swap their load to registry.get).
  • GUT/cargo green (the registry is side-effect free for existing paths; tests that hit promotions still pass via fallback or harness load).

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.