diff --git a/.project/designs/p3-rail1-ui-pure-view-migration-design.md b/.project/designs/p3-rail1-ui-pure-view-migration-design.md new file mode 100644 index 00000000..ec3cea98 --- /dev/null +++ b/.project/designs/p3-rail1-ui-pure-view-migration-design.md @@ -0,0 +1,131 @@ +# Rail-1 endgame — UI becomes a pure view of the Rust game server + +**Owner directive (2026-06-27):** *"remove all game logic from GD and migrate so the UI is +completely driven by the game server (Rust)."* This is the capstone of Rail-1: the live game must +run on the Rust simulation as the single source of truth (SOT), with GDScript reduced to rendering +`view_json`/`getState()`, sending input via `act()`, and running the turn via `end_turn()`. + +This doc is the **verified architecture + phased sequence** for that program. It is grounded in a +three-surface file:line audit (2026-06-27) + ground-truth reads of `view.rs`, `projection.rs`, +`game_state.rs`, `turn_manager.gd`, `turn_processor.gd`. Status of each phase is tracked in the +objectives ([[p3-29-rail1-turn-unification]], [[p3-25-rail1-city-model-unify-headless-view-completeness]], +[[p3-24-rail1-economy-turn-logic-port]]); this doc is the cross-cutting blueprint they execute against. + +> **Division of labour (2026-06-27):** a separate agent owns the AI/agent work. THIS track owns +> state ownership + the turn + the projection + the GDScript view flip. Coordinate on shared files +> (`api-gdext/src/lib.rs` `GdGameState`, `ai_turn_bridge_dispatch.gd`) before editing. + +--- + +## The verified reality (why this is a spine rewrite, not a turn-call swap) + +**The live game holds TWO authoritative state stores that do NOT sync during interactive play:** + +1. **GDScript entities = the live SOT.** `Player` (`src/entities/player.gd`), `CityScript` + (`src/entities/city.gd` — hybrid: some fields proxy the Rust slot, queues/placed-buildings are + GDScript-only), `UnitScript` (`src/entities/unit.gd` — fully GDScript-authoritative). The live + turn (`turn_processor.gd::_process_*`) reads/mutates these entities (e.g. `c.population`, + `c.add_building_at`, `unit.position`). +2. **Rust `GdGameState` = a parallel copy.** `inner: mc_state::GameState` (bench model) + + `presentation_cities: Vec>` (rich cities the renderers read). Populated at + setup / save-load / explicit bridge calls (`apply_found_city`), **NOT** on every live mutation. + +**Consequence (the load-bearing finding):** calling `GdTurnProcessor.step(inner)` during a live +human game today would run on a **stale mirror** — cities founded / units moved interactively are +absent or stale in `inner`. So "swap the turn to call `step()`" is impossible until Rust holds the +*live* state. The headless `GdPlayerApi` already embodies the target shape (Rust owns state; +`view_json` projects it; `act()` mutates it; `end_turn()` runs the turn) — **the live game must +converge onto that exact path.** + +Corollary: the **bench `MapUnit`/`CityState` are REDUCED models**. Some live state (unit +`experience`/XP, per-building producer queues, `placed_buildings` tile positions) has no bench field +yet. Projection completeness therefore requires *widening the Rust model*, not just projecting it. + +--- + +## Target architecture (the headless GdPlayerApi shape, applied to the live game) + +``` + ┌──────────────── Rust (SOT) ─────────────────┐ + input ───▶ │ act(PlayerAction) ──▶ GameState (single) │ + │ end_turn() ──▶ mc_turn::step(...) │ + │ view_json() ──▶ PlayerView (project) │ + └───────────────────────────┬──────────────────┘ + │ getState() + GDScript (pure view): render PlayerView · translate input → act() · run turn → end_turn() + — holds NO authoritative state, runs NO sim logic, computes NO formulas. +``` + +- **Rust owns ALL state + runs the whole turn.** Growth/production/culture/catch-up modifiers + become internal to the Rust turn; the UI never sees them, it renders the resulting numbers. +- **GDScript entities `Player`/`CityScript`/`UnitScript` are DELETED** (or reduced to dumb DTOs + hydrated from `view_json`). `GameState` autoload keeps only the `GdGameState` handle + the latest + decoded view. +- **One input path:** every player order routes through `GdPlayerApi.apply_action_json()` / + `GdGameState.act()`. The AI dispatch and human input converge on it (kills the + `ai_turn_bridge_dispatch.gd` GDScript-mutation table). + +--- + +## Phased sequence (each phase: cargo + GUT green; live phases also need a render-proof) + +### Phase 0 — Projection completeness (HEADLESS-VERIFIABLE, do first, in progress) +Make `view_json` carry everything the renderers + panels read off entities. The audit's verified +gap list (false positives removed against `view.rs`): + +| Gap | Where read | Rust backing | Action | +|---|---|---|---| +| `UnitView` posture set (sentry, deployed, embarked, stealth, ambush, field_aura, fire_arrows, pursuing, shield_wall, braced, rage, war_cry, formation_id, auto_join, current_action) | `unit_panel.gd:738-870` | **exists** on `MapUnit` (game_state.rs:1464-1634) | **add to UnitView + project** ← Phase-0 increment 1 | +| `UnitView.movement_left/max`, `sentry`, `promotion_available` | unit_panel, world_map pathing | **exists** (`movement_remaining`/`base_moves`/`is_sentrying`/`pending_promotion`) but **stubbed `0`/`false`** in `project_units` (projection.rs:357-362) | **fix the stubs** (correctness bug) | +| `UnitView.equipped` items + charges | `unit_panel.gd:789` | `MapUnit.equipped` (mc_items) exists | add `EquippedItemView` | +| `UnitView.experience` / XP | unit_panel, promotion gating | **NOT on MapUnit** — GDScript-only | widen `MapUnit` (couples to Phase 1 SOT) | +| `CityView.culture_stored` | `city_screen.gd:287` | city culture state | add scalar (parallel to `food_stored`) | +| `CityView.building_queues` (per-building producer queues) | `city_screen.gd:389` | rich `mc_city::City.queues`, not bench `CityState` | dual-city-model (p3-25 s6 / p3-26 B7) | +| `CityView.placed_buildings` (tile positions) | `city_renderer.gd:256` | GDScript-only | widen model OR keep as render-only residue | +| `ResourceView.golden_age_active/turns` | `top_bar.gd:162` | player GA fields | add to ResourceView | +| `ResourceView.happiness_breakdown` | `top_bar.gd:278` | happiness inputs | add map (or defer — derived) | + +**Excluded (NOT gaps — keep out per the rails):** player **color** (presentation theme, indexed by +slot — stays a GDScript/theme lookup, not a sim fact); **flora cover / canopy** rendering +(`hex_renderer.gd:234` — Game-2 biosphere rendering, out of Game-1 scope); per-unit **vision range** +fed to a GDScript fog computation (`world_map_vision.gd:57`) — fog is already Rust-computed +(`TileView.explored/visible`); the fix is to **delete** the GDScript vision math, not feed it a +field; animation deltas / VFX (render-only, never sim state). + +### Phase 1 — Rust holds the LIVE state (the SOT flip; render-gated) +Make `GdGameState.inner` (+ `presentation_cities`) the authoritative live state, synced on every +mutation — i.e. route live input through `act()` so there is no GDScript-only mutation to diverge. +Widen the bench model where the live game needs fields it lacks (unit XP, per-building queues, +placed-buildings) so Phase-0 projections carry real live data. This is the dual-model unification +(p3-25 step 6, p3-26 B7) generalised from cities to units. + +### Phase 2 — Live turn = `end_turn()` (render-gated; p3-29 steps 3-5) +`turn_manager.end_turn()` calls `GdTurnProcessor.step(GdGameState)` once instead of the per-player +`proc._process_*` loop + the end-of-round ecology/worldsim/climate/diplomacy glue. The returned +`TurnResult.events` drive EventBus signals (GDScript translates, emits no sim logic). The Rust step +already computes every phase (p3-26/27 work) and emits the granular UI events (p3-29 T1-T6). + +### Phase 3 — Delete the GDScript sim layer +Delete `turn_processor.gd::_process_*` + the four inlined modifier formulas, `EcologyState.tick`, +the `ai_turn_bridge_dispatch.gd` mutation table, and the authoritative entity classes (or reduce to +view DTOs). Flip every renderer/panel read (audit Group A) from entities to the decoded `PlayerView`. +Wire `_process_wild_creatures` to the `GdWildAiController` bridge ([[p3-30-wild-creature-ai-rust-port]]). + +### Phase 4 — Render-proof + GUT +Live game plays a full turn through the Rust path with UI parity vs the old GDScript turn (phase +gate). GUT green headless. This is the true Rail-1 finish line. + +--- + +## Verification reality +Phase 0 is fully headless (cargo + projection tests; the live game is untouched). Phases 1-4 rewrite +the playable turn loop → **must not merge without the render-proof** (Rail UI rule + phase gate). +Per the owner directive this track proceeds; render-gated phases land their code behind the proof, +not blind. Where a phase widens the bench model, headless `mc-turn`/`mc-player-api` tests + the +headless play loop (`magic_civ_view/act/end_turn`) prove the Rust behaviour ahead of the render-proof. + +## Risk +Highest-stakes change in the project — it rewrites the spine of the playable game. Mitigations: +(1) Phase 0 first (additive, zero live risk, unblocks everything); (2) one input path via `act()` +before deleting any entity mutation; (3) cargo + GUT green at every step; (4) the headless +`GdPlayerApi` is the proven reference — converge onto it, don't invent a parallel shape.