magicciv/.project/objectives/p3-29-rail1-turn-unification.md
Natalie 8bf06decf3 docs(objective): record p3-29 live-swap landed behind RUST_TURN flag (7475daa7)
Steps 3-5 now implemented (default OFF): turn_manager runs whole-round
GdTurnProcessor.step at round boundary under RUST_TURN=1, events[] -> EventBus.
Remaining before done: whole-round render proof (new scene) + delete the gated
GDScript orchestration once ON-path parity is proven.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 10:04:40 -04:00

26 KiB
Raw Blame History

id title priority scope owner status updated_at
p3-29 Rail-1 turn unification — live game calls the Rust turn, delete GDScript orchestration p3 game1 warcouncil partial 2026-06-28

Progress 2026-06-28 — live swap implemented behind RUST_TURN flag (7475daa7)

The render-gated steps 3-5 are now implemented (default OFF). turn_manager.gd::end_turn runs the whole-round GdTurnProcessor.step on live state at the round boundary under RUST_TURN=1 (_run_rust_round + _emit_rust_turn_events), with the GDScript per-player _process_* loop and the next_player() round-end sim glue gated off to avoid double-processing. Default path is byte-for-byte the existing turn. Verified pre-commit: clean headless project load (no parse/script errors, autoload registers); all referenced gdext methods/signals confirmed present (GdTurnProcessor.{step,load_authored_encounter_rates}, GdGameState.sync_{presentation_to_inner, inner_to_presentation}, EventBus {tech,culture}_researched/golden_age_{started,ended}/ worldsim_updated). Two acceptance items remain before done: (1) the whole-round-RUST_TURN render proof (a new proof scene — the existing 7k proof is the older fauna flag); (2) deleting the gated GDScript orchestration once the proof confirms ON-path parity. The GDScript path is gated, not deleted, so the fallback survives until the replacement is proven. Carve-out (worldsim/terraform) documented + retained.

Summary

The DRY / Rail-1 violation (verified 2026-06-27). There are TWO turn orchestrations:

  • LIVE: turn_manager.gdturn_processor.gd::_process_* (GDScript) + EcologyState.tick + WorldsimState — GDScript orchestrating the turn.
  • HEADLESS: GdPlayerApimc_turn::TurnProcessor::step (Rust).

The system math lives once in the Rust crates (DRY). The turn orchestration is duplicated — and the p3-26/p3-27 work this session added happiness/healing/improvements/recipes/equipment/ ecology to mc-turn while the live game still runs its GDScript copies (e.g. EcologyState.tick duplicates the new Rust ecology_phase). This session BUILT mc-turn::step into the complete single source of truth; this objective is the capstone that makes it actually single.

The bridge already exists: GdTurnProcessor::step(GdGameState) (api-gdext/src/lib.rs:6354) runs mc_turn::TurnProcessor::step on the LIVE game's state. The live turn just doesn't call it.

⚠ APPROACH CORRECTED (2026-06-27, v2) — the UI is a pure view of getState()

Owner: "shouldn't the UI just call getState()?" — re-asserting the p3-25 directive ("gd should only be UI view of simulation; simulator should provide everything").

Both prior framings were wrong. v1 (swap orchestrators) and v1.5 (extract each formula into an FFI the GDScript turn calls) BOTH keep GDScript calling logic and/or holding authoritative state. The correct architecture — already proven by the headless path — is:

  • Rust owns ALL state + runs the whole turn. GdPlayerApi.end_turn() runs it; view_json() = getState() is the complete render projection; input = act().
  • GDScript is a pure view: render getState(), translate input → act(). It does NOT hold authoritative state (no CityScript/Player entities as truth), NOT orchestrate the turn, NOT call per-phase logic. The growth/production/culture/catch-up modifiers become internal to the Rust turn — the UI never sees them; it just renders the resulting population/yields.

The dual GameState (bench CityState vs live CityScript entities) is the bug, not a constraintgetState() is the single source the UI reads. The "logic divergences" below evaporate: the UI stops computing/calling them entirely.

This folds into p3-25 (status: partial — "unify dual city model so view_json carries territory… GDScript = view only"). p3-25 = complete the projection; p3-29 = make the UI consume it

  • delete the GDScript turn. Same arc.

getState() is ALREADY a rich projection (verified 2026-06-27 — corrects an earlier false claim)

PlayerView (mc-player-api/src/view.rs:318, serialized by view_json) already carries:

  • CityView: position, owner, is_capital, population, production_queue, buildings, owned_tiles (territory), hp/max_hp, focus, yields, buildable.
  • UnitView: position, hp/max_hp, movement_left/max, experience, fortified, sentry.
  • TileView: position, biome, substrate, improvement, river, explored/visible (fog), owner_city.
  • plus resources, research, culture, civics, diplomacy, pending_events, score, legal_actions.

So the projection foundation largely exists — the earlier "carries almost nothing renderable" note was wrong (an over-grep). The gap to "UI just calls getState()" is the GDScript side.

Target architecture (replaces the v1/v1.5 worklists)

  1. Live renderer + UI panels read getState() (view_jsonPlayerView) instead of GameState.players/CityScript/Player entities (world_map.gd and panels currently read entities — e.g. world_map.gd:324,361). ← the bulk of the work, GDScript-side.
  2. Turn = GdPlayerApi.end_turn() (Rust runs everything); input = act() (already the headless action path).
  3. Delete CityScript/Player authoritative state, turn_processor.gd::_process_* orchestration + the inlined modifiers, EcologyState.tick, WorldsimState GDScript copies.
  4. Render-proof: the live game renders correctly from getState() after a Rust end_turn().

First concrete step (headless-verifiable)

Render-needs audit: list every field world_map.gd + the renderers/panels read off entities, and confirm each has a PlayerView equivalent. Most do (positions, fog, territory, hp). Flag the few that may not — render-only extras (animation deltas / UnitMoved, VFX, player colors, minimap aggregation) — as the only genuine projection additions (p3-25). Everything else is GDScript rewire, not Rust work.


Evidence — audit (2026-06-27, verified file:line): GDScript still holds turn LOGIC

This is why the UI-reads-getState() fix is needed — turn_processor.gd delegates the tick to Rust per phase but still inlines four modifier formulas (each must move INTO the Rust turn, not into an FFI the UI calls):

turn_processor.gd calls a Rust FFI for the heavy work in every phase (apply_production, process_growth, EconomyScript.process_turnGdEconomy, GdTechWeb, GdHappiness, GdCulture, ClimateScript, marine) — but four phases compute a multiplier inline with hardcoded constants:

# Site (turn_processor.gd) Inlined formula Canonical home Note
1 _process_production :53-79 unhappy *=0.75, golden *=1.2, base_prod*(1+pct)*mod*occ mc-economy/mc-city hardcoded, not from crate
2 _process_growth :250-263 1.25 if happiness>0, skip <-10 mc_happiness::get_growth_modifier CONFIRMED divergence (holds back the 0.5× unhappy tier per its own comment)
3 _process_culture :410-412 golden *=1.2 + difficulty mult mc-culture inlined
4 _catchup_research_mult :584 1.5× when ≥2 eras behind mc-tech/mc-ai GDScript-only formula

(Also GameState.get_effective_yield_mult — difficulty handicap — is a GDScript formula applied to production/research/culture; lower priority, may be an intentional batch-test override. Confirm.)

These four are the symptom — proof that the live turn runs logic GDScript should never run. The fix is NOT to expose each as an FFI the UI calls (that keeps GDScript calling logic). It is: the Rust turn (mc-turn::step, already using the canonical crate fns — e.g. get_growth_modifier, which returns 1.25/1.0/0.5/0.0 incl. the REVOLT_THRESHOLD = -10 halt) computes them internally, and the UI renders the result via getState(). When step 4 deletes turn_processor.gd::_process_*, all four vanish with it — no per-formula extraction needed.

Note the trap they reveal: where the GDScript formula diverges from the crate (e.g. growth omits the 0.5× unhappy tier), that is a GDScript bug, not a balance baseline to preserve — Rust is the truth; any balance tuning happens in Rust/JSON, never by a GDScript override. (See memory feedback_rust_drives_never_reconcile.)

Superseded: the swap steps below (3-5) are historical record. Steps 1-2 (event surfacing) remain valuable — getState() will carry those events. The former "B7" city-model convergence is dropped (resolved by one Rust-owned state behind getState(), not by converging two models).

Acceptance

Steps 1-2 COMPLETE (headless-safe, 2026-06-27): the Rust turn surfaces all granular UI events (CityGrew 06c6e2547, CityBordersExpanded db808e477, FloraSuccession 7b6d24bde) AND GdTurnProcessor.step's result dict carries them as result["events"] (e165b4e6c, via replay::event_to_dict). Also fixed a latent bug surfaced by the consolidation: B2 healing healed besieged cities → siege-suppress (de68c9c10). All headless prep for the swap is done.

Remaining = the live swap (steps 3-5, render-gated):

  • turn_manager.gd runs the turn via GdTurnProcessor.step(GameState) instead of the per-player proc._process_* loop. DONE behind a flag (7475daa7, 2026-06-28). Under RUST_TURN=1, end_turn() runs _run_rust_round() at the round boundary (is_last_in_round()): sync_presentation_to_innerGdTurnProcessor.step (whole round, all players + sim glue, state.turn increment) → sync_inner_to_presentation; the per-player _process_* loop and the next_player() round-end ecology/climate/wild/diplomacy passes are gated OFF to avoid double-processing.
  • The returned TurnResult is rendered to UI (EventBus signals). DONE (7475daa7): _emit_rust_turn_events translates step's events[] (kind-tagged dicts from replay::event_to_dict) into EventBus signals — TechResearched/CultureResearched/ GoldenAgeStarted/GoldenAgeEnded now; entity-payload kinds (CityGrew/UnitCreated/…) deferred (need a live entity lookup) — the board is already correct via the synced presentation slots, so only the per-event signal is deferred. worldsim_updated emitted to nudge the fauna overlay.
  • [~] turn_processor.gd::_process_* orchestration + the duplicate EcologyState.tick deleted (or reduced to UI-only translation). NOT deleted — GATED instead. Deliberate: the swap ships behind a default-OFF flag so both paths coexist for safe rollout + A/B parity verification. The GDScript orchestration is gated off under RUST_TURN=1 but not yet removed; deletion follows once the render proof confirms ON-path parity (else we'd lose the fallback before proving the replacement).
  • WorldsimState/terraform: either ported into mc-turn (preferred, completes Rail-1) or kept as the one remaining GDScript-driven pass with a tracked carve-out. Carve-out kept (and documented in next_player()): world-event dispatch / terraform drain / contamination tick / worldsim_updated render hook stay GDScript-driven — step does NOT cover them (p3-26/p3-27 boundary). The grid is still resolved across turns so this pass keeps running under the flag.
  • Render proof: a scenes/tests/ proof scene + screenshot showing the live game plays a turn correctly through the Rust step (UI parity with the old GDScript turn). STILL OPEN — the blocker to done. The existing iter_7k_turn_processor_gated_proof covers the older RUST_FAUNA_ENCOUNTERS flag, not the whole-round RUST_TURN path — a NEW proof scene is needed that drives end_turn() through a full round with RUST_TURN=1 and shows state advancing + rendering. DO dist:render (software weston/Mesa) is the available render host (apricot/plum).
  • GUT green; headless mc-turn already proven (it IS the step being adopted). Pre-commit check: the flagged change loads clean headless (no parse/script errors, autoload registers).

Notes

Created 2026-06-27 after the owner flagged that "headless runs every system the live game does" is duplication, not DRY. HIGH-STAKES: rewrites the playable game's turn loop — must not merge without the render proof (Rail UI rule + phase-gate). Sequence: (1) audit what the GDScript loop emits vs TurnResult; (2) wire turn_manager → GdTurnProcessor.step behind the result-render; (3) delete the GDScript orchestration; (4) render-proof on apricot/plum. This is the true Rail-1 finish line — bigger than B7 (per-building queues), which becomes moot once the live game runs the Rust turn (which has the model the unified turn will use).

De-risking audit (2026-06-27) — the bulk is event-surfacing, not the call-swap

GdTurnProcessor::step(GdGameState) (the bridge) exists and the Rust step computes every system. The blocker to switching the live game is UI event parity:

  • TurnResult.events_emitted: Vec<mc_replay::TurnEvent> is REPLAY-focused — only CityFounded / CityCaptured / UnitKilled / UnitCreated / GameOver / AmbientEncounterFired.
  • The live GDScript turn emits far richer UI signals inline: city_grew, city_building_completed, city_unit_completed, city_border_expanded, culture_researched, flora_succession, fauna_round_started/ended, …
  • turn_result_to_dict surfaces only a thin slice (lair/victory/fauna).

So the real p3-29 work, in order:

  1. Enrich the Rust turn's event surface — emit the granular UI events (growth, building/unit completion, border expansion, culture, flora succession) from the phases that cause them into TurnResult (headless-verifiable, no live-game risk).
  2. Surface them through turn_result_to_dict so GDScript receives them.
  3. turn_manager translates TurnResultEventBus emits (GDScript = pure render).
  4. Swap turn_manager to GdTurnProcessor.step; delete turn_processor.gd::_process_*
    • the duplicate EcologyState.tick.
  5. Render-proof on apricot/plum.

Steps 1-2 are the bulk and are safe (Rust + headless tests, live game untouched). Steps 3-5 are the live-game swap (needs the render proof). This is why the simulation was buildable headless this session but the live game still runs GDScript — the event-surface gap was never closed.

Step-1 progress (2026-06-27)

  • CityGrew event (06c6e2547) — process_city_production emits it on growth.
  • CityBordersExpanded event (db808e477) — process_culture emits it on tile claim.
  • FloraSuccession (7b6d24bde) — ecology buffers transitions into transient GameState.pending_flora_events; step() drains them into FloraSuccession events (avoided the registry-signature cascade via the buffer). STEP 1 COMPLETE — the Rust turn now emits all three granular UI events the GDScript turn emitted inline.

Both high-value growth/border events are surfaced (replay value now; UI-parity at the swap). Remaining: FloraSuccession + the registry-events refactor + dict surface + the live swap + render proof — one focused pass.

Complete event-parity matrix (full sweep, 2026-06-27 — verified file:line)

The Step-1 checklist above walks the audit's named subset. This matrix is the complete turn-emitted signal surface, swept from event_bus.gd + every _process_* the live turn runs (turn_processor.gd) + the end-of-round ecology/worldsim glue (turn_manager.gd:283-325), so the objective captures everything. UI/input/camera/overlay/selection signals are explicitly out of scope (genuine presentation); only sim-state-change events the turn produces appear.

A. Per-player _process_* events → mc-turn::stepTurnResult.events_emitted

GDScript signal _process_* fn TurnEvent Status Evidence
city_grew _process_growth CityGrew DONE processor.rs:1616 (06c6e25)
city_border_expanded _process_culture CityBordersExpanded DONE processor.rs:1218 (db808e4)
city_building_completed _process_production CityBuildingCompleted exists processor.rs:1600
city_unit_completed _process_production UnitCreated{city:Some} exists (dispatch xlate) processor.rs:1984/2061
unit_created _process_research UnitCreated exists processor.rs:1984
tech_researched _process_research TechResearched exists processor.rs:1329
culture_researched _process_culture_research CultureResearched DONE (T1, 3e89c411a) — TurnEvent::CultureResearched emitted at completion site; dispatch translates to wire (wire.rs:293); event_to_dict arm processor.rs:1284
golden_age_started/_ended _process_growth GoldenAgeStarted/GoldenAgeEnded DONE (T3, 34ab497e1) — both edges buffered in happiness_phase (pending_golden_age_events), drained in step() happiness_phase.rs
unit_healed _process_healing UnitHealed DONE (T2, f98b1fd01) — buffered in healing.rs (pending_heal_events, clamped applied amount), drained in step() healing.rs
item_produced _process_production ItemProduced (new; or fold into building) MISSING — designer call: distinct from CityBuildingCompleted?
strategic_gate_rejected _process_production TurnResult.strategic_gate_rejected field ⚠️ field EXISTS but NOT in turn_result_to_dict (AI advisory; decide surface vs keep-GDScript) combat_event.rs:28

B. Keystone surfacing gap (step 2) — turn_result_to_dict (api-gdext/src/lib.rs:6562)

DONE (e165b4e6c, verified 2026-06-27 lib.rs:6573-6577). turn_result_to_dict now loops result.events_emitted through event_to_dict into a generic d.set("events", …) array — every §A TurnEvent (incl. the new CultureResearched) reaches GDScript on the live-step path as a kind-tagged dict. The earlier "NOT STARTED" note drifted; the keystone landed with step 2. turn_manager iterating events[]EventBus is the remaining GDScript-side render wiring (the render-gated swap, steps 3-5), not a Rust gap.

C. End-of-round ecology / worldsim (turn_manager.gd:283-325)

Signal Source Status Evidence
flora_succession EcologyState.ticktake_flora_transitions() MISSING — ecology_phase computes transitions then discards them ecology_phase.rs:76 let _transitions =
creature_died / creature_born / biome_changed EcologyState.tick MISSING from TurnResult ecology_phase.rs
ambient_encounter_fired _process_rust_fauna_encounters surfaced lib.rs:6620
fauna_round_started/ended, worldsim_round_started/ended, round_started/ended, player_round_*, game_phase_changed worldsim bridge end_player_round_phase (_emit_phase_events) ⚠️ already Rust-sourced via the worldsim bridge, NOT via mc-turn::step — lifecycle markers; reconcile under acceptance #4 turn_manager.gd _emit_phase_events
terrain_transformed, weather_event_applied _process_climate + WorldsimState terraform ⚠️ CARVE-OUT (acceptance #4) + separate GdClimate path

D. Superset the swap GAINS (note, not a gap)

mc-turn::step computes things the GDScript turn does NOT emit; adopting the Rust turn ADDS them — flag so it is not a surprise at render-proof:

  • city_starved / CityStarved (wire.rs:250) — Rust emits, GDScript turn does not.

Net remaining for "make the Rust turn the single source" (this objective)

  1. New TurnEvent variants + emission: CultureResearched (T1, 3e89c411a), GoldenAgeStarted/Ended (T3, 34ab497e1), UnitHealed (T2, f98b1fd01), ItemProduced (§A, blocked on D1) — §A event emission DONE except ItemProduced.
  2. Flora/ecology surface (§C): stop discarding _transitions (ecology_phase.rs:76); add flora_transitions (+ creature/biome) to TurnResult. Tied to the registry-events refactor the Step-1 note defers — track it here so it is not lost.
  3. Keystone (§B): generic events[] in turn_result_to_dict. DONE (e165b4e6c, lib.rs:6573) — every §A event now reaches the live UI as a kind-tagged dict.
  4. Decisions: item_produced (event vs fold), strategic_gate_rejected + round-lifecycle markers (surface via step vs worldsim-bridge/GDScript carve-out, ties to acceptance #4).

Coder handoff (2026-06-27) — ordered, file-cited task list

Scope correction. Commit 9e0013a02 says "step 1 complete", but it closed only the audit's named 3 events (CityGrew, CityBordersExpanded, FloraSuccession). Per the §A matrix above, the granular event surface is not complete: 4 §A variants are still missing and the §B dict keystone is not started. "Step 1" = the full §A+§C set, not the named 3. The tasks below are the real remaining steps 1-2 (Rust + headless only, live game untouched, no render-proof needed).

Proven pattern (5 touch-points per new event — follow CityGrew/CityBordersExpanded):

  1. mc-replay/src/event.rs — add variant (+ turn: u32) + arm in TurnEvent::turn() + a serde round-trip case in the #[cfg(test)] block.
  2. emit at the compute site that already decides the thing (do NOT recompute).
  3. mc-player-api/src/dispatch.rs::translate_processor_events — add an exhaustive arm (translate to a wire::Event when one exists, else drop in the => {} group; keeps the match exhaustive).
  4. api-gdext/src/replay.rs::event_to_dict — add a kind-tagged dict arm.
  5. mc-turn/tests/event_collector_wiring.rs — add the variant to BOTH name-map matches (lines ~207 and ~325) so the 10-turn wiring test compiles + asserts it can fire.

BLOCKING decisions (need an owner ruling before coding T3/T-dec)

  • D1 — item_produced: RULED (owner, 2026-06-27): distinct ItemProduced event {turn, clan, city, item_id, hex}, mirroring the separate live item_produced signal (turn_processor.gd:130), distinct from CityBuildingCompleted. (T4 unblocked, deferred — owner pivoted this session to p3-26/p3-27 headless completion; the live swap that consumes §A is render-gated.)
  • D2 — strategic_gate_rejected + round-lifecycle markers (fauna_round_*, worldsim_round_*, player_round_*, game_phase_changed): surface through step() / TurnResult, or keep the existing worldsim-bridge + GDScript path as the acceptance-#4 carve-out? Recommend carve-out (already Rust-sourced via the bridge); confirm.

Tasks (each is a self-contained PR; headless-verifiable)

  • T1 — CultureResearched {turn, clan, tradition: String}. Emit in processor.rs::process_culture_research on tradition completion. wire::Event::CultureResearched already exists (wire.rs:293) → dispatch TRANSLATES (not drops). DoD: wiring test asserts it fires in a run that completes a tradition; serde round-trip green.
  • T2 — UnitHealed {turn, clan, unit_id, amount, hex}. Emit per healed unit in healing.rs. Needs an events sink into the healing phase — reuse the same &mut Vec<TurnEvent> threading the §A phases already use (NOT the registry signature). DoD: wiring test + a damaged unit heals → exactly one event with correct amount.
  • T3 — GoldenAgeStarted {turn, clan} (gated on D1-style confirm it's start-only; add GoldenAgeEnded only if the live UI consumes golden_age_ended — it does, event_bus.gd:176, so add both). Emit on the false→true (and true→false) golden_age_active transition in happiness_phase.rs::process_golden_age. DoD: wiring test + transition fires once per edge.
  • T4 — ItemProduced (pending D1). If distinct: {turn, clan, city, item_id, hex}, emit in process_city_production alongside CityBuildingCompleted.
  • T5 — §C creature/biome: CreatureBorn / CreatureDied / BiomeChanged from the ecology tick. CORRECTION (verified 2026-06-27): the buffer pattern alone does NOT suffice — EcologyEngine::process_step (engine.rs:341) returns only Vec<FloraTransition>; it does NOT report fauna births/deaths or biome-label changes. The live creature_born/creature_died signals come from GDScript fauna.gd:184/209 (a separate path), and biome changes are unreported by the headless engine. So T5 first needs a new report surface in mc-ecology (e.g. process_step returns population/biome deltas, or a sibling reporter) — and a design call on granularity (per-tile population delta? species-emergence threshold? new-species-on-tile only?). Bigger than T1-T3; needs an owner ruling on creature-event semantics before coding.
  • T6 — §B KEYSTONE: DONE (e165b4e6c, lib.rs:6573) — generic events: Array in turn_result_to_dict loops events_emitted through event_to_dict; every §A event reaches the live game as a kind-tagged dict. (The "do regardless / none reach the game" framing was pre-landing.)

Swap-phase carve-out to track (steps 3-5, not now)

  • _process_wild_creatures (turn_processor.gd:459) runs GDScript wild-creature AI (wild_creature_ai.gd, 302 LOC — guard/attack/roam) inside the live turn. This is a Rail-1 logic gap NOT covered by p0-26 (player AI) and NOT an event. The unified turn must either drive a Rust wild-AI or keep this as a declared carve-out. Tracked by p3-30-wild-creature-ai-rust-port.

Audit honesty note

This handoff covers event-emission parity + the dict keystone. It does NOT assert numeric parity (that each Rust phase computes the same values as its GDScript twin) — that is the swap's render-proof + GUT job (acceptance #5/#6). Helper fns in turn_processor.gd (_apply_building_bonuses, _sum_city_building_effect, _grant_free_tech, _check_resource_reveals, _build_border_candidates_json) ride along with the orchestration deletion in steps 3-5 — verify their logic is covered by the Rust phase before deleting, not assumed. _get_healing_rate is confirmed mirrored (healing.rs); the others are unverified.