feat(@projects/@magic-civilization): document bullet2 port feasibility

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-07 23:49:06 -07:00
parent b6f8c83d71
commit 35a0a2c17f
2 changed files with 89 additions and 1 deletions

View file

@ -0,0 +1,88 @@
# p2-80 Bullet 2 — Whole-Game Rust-Turn Port: Feasibility & Sizing
**Status:** sizing report (2026-06-07). Produced by the "attempt the whole-game
port" phase-1 feasibility check before committing to the expensive leg.
**TL;DR:** Bullet 2 ("drive the playable game through `WorldSim::step`") cannot
be a call-site swap. The objective's original premise — that the playable game
already advances through `mc_turn::TurnProcessor::step` — is **false**. Closing
bullet 2 requires a **whole-game data-ownership inversion**: making the Rust
`GameState` authoritative for the interactive turn and reducing GDScript to a
view. That is multi-session, multi-objective work (overlaps `p0-26`, `p1-29j`),
explicitly beyond p2-80's stated scope. This report maps it so the decision to
proceed is made on data, not estimate.
## What was verified (cheap reads, no code written)
1. **Data ownership is GDScript.** Every discrete-turn system follows the same
pattern — `economy.gd` is representative (`src/game/engine/src/modules/empire/economy.gd:35`):
read the GDScript `player`/`game_map` → build a params JSON → instantiate a
**throwaway, stateless** Rust calculator (`GdEconomy`) → **write the result
back** onto the GDScript entity (`player.gold`, `player.cities`, `player.units.erase(...)`).
Happiness, production, research, combat all mirror this. **The state of record
is the GDScript `player`/`city`/`unit`/`game_map` objects.** `GdGameState` /
`mc_state::GameState` is a *side-handle* (mod controllers, combat-balance,
fauna-encounter rolls), not the authoritative turn state.
2. **The Rust turn is a parallel, unvalidated implementation.**
`mc_turn::TurnProcessor::step` is comprehensive (16,185 lines; `processor.rs`
alone is 376 KB — economy, production, combat, formations, capture, victory,
prologue, policy, lairs, patrols, couriers). But its only callers are
**pure-Rust benches**: `solo_dominion` is *"1 AI player vs NPC fauna, 1500
turns"* — an abstracted single-player survival loop with one hardcoded city,
**not** the multi-clan 4X the interactive game runs. `bridge_contract_tests.rs`
(29 KB) locks **internal Rust signatures**, *not* GDScript↔Rust outcome
parity. **There is no guarantee the Rust turn reproduces the live game's
rules** — the port therefore includes *reconciliation*, not just wiring.
3. **`WorldSim::step` bundles the discrete turn.** `WorldSim::step` =
`processor.step(state)` (the Rust discrete turn) + climate + ecology +
events (`mc-worldsim/src/lib.rs:165`). Driving the playable game through it
*requires* the Rust discrete turn to be authoritative first.
## Why this is whole-codebase, not a bridgehead
The inversion touches **every** system that reads or writes player/city/unit/map
state. Each must stop mutating GDScript entities and instead mutate the Rust
`GameState`, with GDScript re-deriving its view from the Rust state each turn
(the inverse of today's "read GDScript → calc in Rust → write GDScript back").
Render (reads GDScript tiles/entities) and save (serializes GDScript entities)
both re-plumb onto the Rust state. This is the Rail-1 gameplay port tracked
across `p0-26` (AI surface), `p1-29j` (autoplay action-application — *stub*), and
more — not a p2-80 deliverable.
## Recommended phased path (if the inversion is greenlit)
- **Increment 1 — Autoplay bridgehead (`p1-29j`).** Route *autoplay* (AI-only,
no interactive UI) action-application through the Rust turn, producing a
**multi-clan** Rust `GameState` and advancing it via `TurnProcessor::step` (→
later `WorldSim::step`). Verify it produces sane full games (city counts, pop
peak, tech tiers, victory-turn distribution) **converging** with the GDScript
autoplay's `turn_stats.jsonl` on matched seeds. This is *Rust-authoritative
with a view projection* — it **sidesteps the interactive inversion** and does
not touch the render/save paths just stabilized. **It does NOT flip bullet 2**
(bullet 2 = the *interactive* game), but it proves the Rust turn is viable and
measures the reconciliation gap. **Open sub-task:** a clean multi-clan parity
harness does not exist yet — `solo_dominion` is single-player; building the
multi-clan Rust autoplay *is* the bulk of increment 1.
- **Increment 2…N — Interactive inversion, system by system,** behind the
bridgehead: economy → production → research → cities → combat → units, each
flipping authority from GDScript entity to Rust `GameState`, with GDScript
re-deriving its view. Render + save re-plumb onto Rust state. Only when the
*interactive* turn runs on `WorldSim::step` does bullet 2 close.
## Guardrail
Do **not** touch the interactive turn, render hook, or save path until the
bridgehead proves the Rust turn produces sane full multi-clan games. The
bridgehead is safe precisely because it is Rust-authoritative-with-projection
and never mutates the live interactive state.
## What is already true (p2-80 functional intent — MET)
The worldsim engines **already run every playable turn** (climate + ecology +
world-events via the bridge, orchestrated by `turn_manager.gd`), proven
**visible** (render hook ✓) and **persistent** (fauna save persistence ✓). Bullet
2 is the residual *architectural-purity* question of **where the turn
orchestration lives** (Rust vs GDScript) — unblockable only by the inversion
above.

View file

@ -84,7 +84,7 @@ hydrology re-solve) hook the terraforming cascade into the same step.
- ✓ **Fauna `tile_populations` persist in the live save.** `EcologyState.to_save_json` / `restore_from_save_json` (wrapping the existing `GdFaunaEcology::continuation_state_to_json` bridge) wired into the `save_manager` envelope as `ecology_state`, mirroring `worldsim_state` (eco_map). Restore suppresses world-genesis re-seeding only when it injected a non-empty world (empty/old saves fall through to fresh seeding); `_registered_already` stays false so the first tick reloads the species registry the slots reference. Backward-compatible (missing key → `""` → re-seed). **Verified end-to-end on apricot (seed 5):** the real save carries a 1.3 MB non-empty `ecology_state` (`tile_populations` + `species_registry` + `tick_count`); two independent runs are byte-identical except the envelope wall-clock `timestamp`; resuming a turn-11 save restores `fauna_tiles=960` immediately (not re-seeded from 0) and the post-load curve (960→959→917) matches the uninterrupted run. GUT `test_continued_trajectory_byte_identical_after_save_load` (in `test_worldsim_playable_path.gd`, 6/6) proves the continued trajectory is byte-identical, not merely a lossless round-trip.
- ✓ **Flora tier state already persists** via `tile_serializer.gd` (`canopy_cover` / `undergrowth` / `fungi_network` / `succession_progress` round-trip with the map); `climate._grid` rebuilds from the loaded map, so flora succession continues across load. **`eco_map`** persists via the pre-existing `worldsim_state` payload.
- ◻ **Residual gap — grid-only Rust accumulators** (`bloom_streak`, `surface_water`, `original_biome_id`, `marine_bloom_turns`) are NOT persisted: they live only on `climate._grid`, which is rebuilt fresh from the map on load, so a *fully* byte-identical continued trajectory still diverges on those fields. And the bullet's literal `mc-save` (`format.rs` / `round_trip.rs`) Rust-format round-trip of `tile_populations` is **not** added — the live game saves through the GDScript envelope, not `mc_save::SaveFile`; closing this needs either a Rust-format round_trip.rs test or an explicit decision that the GDScript envelope is the canonical Game-1 save path.
- ◻ **api-gdext per-turn call — BLOCKED WITH CAUSE (whole-game Rust port), not a p2-80-sized change.** The bullet's premise (that the playable game already runs `mc_turn::TurnProcessor::step`, so swapping to `WorldSim::step` is a call-site change) is **false** — see the Premise Correction in the Summary. The interactive discrete turn is GDScript; `WorldSim::step` = Rust `processor.step(state)` (the unified discrete turn) **+** climate + ecology + events. Driving the playable game through `WorldSim::step` therefore requires first porting the *entire discrete turn* (economy / production / research / cities / combat) into `mc_turn::TurnProcessor` and making the live `GdGameState` the authoritative state a Rust turn advances — i.e. the whole-game Rail-1 port tracked separately (`p0-26`-class, `p1-29j` autoplay action-application still a *stub*). That is explicitly **out of p2-80's scope** (Non-goals: "this is integration, persistence, determinism, and presentation only — not authoring/porting engines"). **Deliberately not built:** a narrow "unify the three worldsim orchestration calls into one Rust call" increment — it would not satisfy the bullet's literal text (the game still wouldn't advance through `WorldSim::step`), the Rail-1 gain is marginal (the GDScript "orchestration" is three ordered bridge calls + a seed mix, not simulation logic), and it would risk the render+save paths just stabilized. **Functional note:** p2-80's *functional* intent — "drive the existing worldsim engines in the playable turn" — is already MET: climate + ecology + world-events run every playable turn via the bridge (`turn_manager.gd`), proven visible (Render hook ✓) and persistent (Save persistence). This bullet is the residual *architectural-purity* question of **where the orchestration lives** (Rust vs GDScript), unblockable only by the whole-game port.
- ◻ **api-gdext per-turn call — BLOCKED WITH CAUSE (whole-game Rust port), not a p2-80-sized change.** The bullet's premise (that the playable game already runs `mc_turn::TurnProcessor::step`, so swapping to `WorldSim::step` is a call-site change) is **false** — see the Premise Correction in the Summary. The interactive discrete turn is GDScript; `WorldSim::step` = Rust `processor.step(state)` (the unified discrete turn) **+** climate + ecology + events. Driving the playable game through `WorldSim::step` therefore requires first porting the *entire discrete turn* (economy / production / research / cities / combat) into `mc_turn::TurnProcessor` and making the live `GdGameState` the authoritative state a Rust turn advances — i.e. the whole-game Rail-1 port tracked separately (`p0-26`-class, `p1-29j` autoplay action-application still a *stub*). That is explicitly **out of p2-80's scope** (Non-goals: "this is integration, persistence, determinism, and presentation only — not authoring/porting engines"). **Deliberately not built:** a narrow "unify the three worldsim orchestration calls into one Rust call" increment — it would not satisfy the bullet's literal text (the game still wouldn't advance through `WorldSim::step`), the Rail-1 gain is marginal (the GDScript "orchestration" is three ordered bridge calls + a seed mix, not simulation logic), and it would risk the render+save paths just stabilized. **Functional note:** p2-80's *functional* intent — "drive the existing worldsim engines in the playable turn" — is already MET: climate + ecology + world-events run every playable turn via the bridge (`turn_manager.gd`), proven visible (Render hook ✓) and persistent (Save persistence). This bullet is the residual *architectural-purity* question of **where the orchestration lives** (Rust vs GDScript), unblockable only by the whole-game port. **Full feasibility + sizing + phased bridgehead plan: `.project/designs/p2-80-bullet2-port-sizing.md`** (verified data-ownership inversion; recommended increment 1 = `p1-29j` autoplay bridgehead).
- ✓ **api-wasm parity — documented separate cut, no silent divergence.** `api-wasm` (the guide-web design lab) depends on `mc-climate` + `mc-ecology` + `mc-compute` but **not** `mc-turn` / `mc-worldsim` (`api-wasm/Cargo.toml`). It exposes `WasmGrid` — a worldgen snapshot plus per-tile climate/tectonics stepping for interactive parameter exploration (`generate_for_lab`, `tileClimateJson`, `computeStatsJson`) — **not** a playable game: no `GameState`, no `TurnProcessor`, no cities/units/events. It deliberately does **not** reuse `WorldSim::step`, which requires `mc-turn::TurnProcessor` + a full `GameState` and would pull the entire gameplay stack into the WASM bundle for a tool that only visualizes worldgen + climate. **No physics divergence:** the game and the lab call the *same* `mc-climate` / `mc-ecology` `process_step` fns — only the orchestration differs (lab: worldgen snapshot + climate stepping for viz; game: `WorldSim::step` per turn).
- ✓ **Full continuous-tick set wired** — verified by grep against the step. `EcologyEngine::process_step` (`engine.rs:276`) runs emergence, LV `tick_populations`, dispersal, tier succession, fish stocks, feedback, lair lifecycle. `generation::apply_migrations` runs in that step too (carrying-capacity migration, `engine.rs:343` — the g2-10 path). `biological::advance_bloom_streak` runs **once per turn** inside `dispatch_world_events` (`mc-worldsim/src/event_dispatch.rs:91`, unconditional when a grid is present), which `WorldSim::step` calls (`lib.rs:189`). `evolution::run_evolution` is **deliberately out** of the per-turn set: it is the pre-game from-epoch worldgen evolution (`evolution.rs:116` "Run pre-game evolution on a grid" — runs `world_age.evolution_ticks` of geological→biological deep time; every caller is a worldgen binary/bench, never a per-turn path). Running it per turn would re-evolve millennia each turn. *(Caveat: `advance_bloom_streak` mutates the grid-only `bloom_streak`, in the bullet-1 unpersisted-grid-state residual — it runs each turn but does not yet survive save/load.)*
- ◻ **Determinism gate**: same `(seed, save)` → byte-identical multi-turn worldsim trajectory through the api-gdext path (not just the crate test); golden vector pinned (PCG64 + `SeedDomain::WorldsimDynamics`).