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>
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 ofmc_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(orderedfn(&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_*_jsonFFI 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-readingboot_from_resources(path)is wrong: shared simulation code compiles to WASM too, and the web guide has no filesystem. The loader must be aContentRegistrypopulated by the host at engine init — Godot passesres://bytes across FFI, the web guide fetches packs → bytes across bindgen — withinclude_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.shStep 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 staysinclude_str!-loaded by its owner, and (B) tombstoned const names (e.g. promotionsXP_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_PENALTYinmc-ecology/generation.rs). Proven: passes clean, fails on an injectedXP_THRESHOLDSrevert. Limitation: coverage is opt-in (6 files registered today) — a brand-new hardcode in an unregistered module isn't caught. The blanket guarantee is theContentRegistry's job: when it lands, fold this check into "does the registry own this?" rather than a manifest. Until then, growREGISTRYas content modules are identified. - Dedup the ad-hoc
include_str!content sites (Opportunity A, same arc) — verified 2026-06-27: 8 JSON-configinclude_str!sites across simulator crates, each rolling its ownOnceLock+ a fragile relative path (6 at../../../../../).treaty_rules.jsonis embedded 3 separate times (no dedup). These fold into theContentRegistryabove —registry.get::<T>("promotions")replaces per-crate path+parse. (WGSL shaderinclude_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
DataLoaderreads 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.