From 35a0a2c17fccb94d75d08560e93c53069cb84ecd Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 7 Jun 2026 23:49:06 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20document=20bullet2=20port=20feasibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .project/designs/p2-80-bullet2-port-sizing.md | 88 +++++++++++++++++++ .../p2-80-mc-worldsim-integration.md | 2 +- 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 .project/designs/p2-80-bullet2-port-sizing.md diff --git a/.project/designs/p2-80-bullet2-port-sizing.md b/.project/designs/p2-80-bullet2-port-sizing.md new file mode 100644 index 00000000..1418d3ad --- /dev/null +++ b/.project/designs/p2-80-bullet2-port-sizing.md @@ -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. diff --git a/.project/objectives/p2-80-mc-worldsim-integration.md b/.project/objectives/p2-80-mc-worldsim-integration.md index 6c149f51..f5354b85 100644 --- a/.project/objectives/p2-80-mc-worldsim-integration.md +++ b/.project/objectives/p2-80-mc-worldsim-integration.md @@ -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`).