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>
26 KiB
| 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.gd→turn_processor.gd::_process_*(GDScript) +EcologyState.tick+WorldsimState— GDScript orchestrating the turn. - HEADLESS:
GdPlayerApi→mc_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 (noCityScript/Playerentities 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
constraint — getState() 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)
- Live renderer + UI panels read
getState()(view_json→PlayerView) instead ofGameState.players/CityScript/Playerentities (world_map.gdand panels currently read entities — e.g.world_map.gd:324,361). ← the bulk of the work, GDScript-side. - Turn =
GdPlayerApi.end_turn()(Rust runs everything); input =act()(already the headless action path). - Delete
CityScript/Playerauthoritative state,turn_processor.gd::_process_*orchestration + the inlined modifiers,EcologyState.tick,WorldsimStateGDScript copies. - Render-proof: the live game renders correctly from
getState()after a Rustend_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_turn→GdEconomy, 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.gdruns the turn viaGdTurnProcessor.step(GameState)instead of the per-playerproc._process_*loop. DONE behind a flag (7475daa7, 2026-06-28). UnderRUST_TURN=1,end_turn()runs_run_rust_round()at the round boundary (is_last_in_round()):sync_presentation_to_inner→GdTurnProcessor.step(whole round, all players + sim glue,state.turnincrement) →sync_inner_to_presentation; the per-player_process_*loop and thenext_player()round-end ecology/climate/wild/diplomacy passes are gated OFF to avoid double-processing.- The returned
TurnResultis rendered to UI (EventBus signals). DONE (7475daa7):_emit_rust_turn_eventstranslatesstep'sevents[](kind-tagged dicts fromreplay::event_to_dict) into EventBus signals —TechResearched/CultureResearched/GoldenAgeStarted/GoldenAgeEndednow; 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_updatedemitted to nudge the fauna overlay. - [~]
turn_processor.gd::_process_*orchestration + the duplicateEcologyState.tickdeleted (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 underRUST_TURN=1but 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 innext_player()): world-event dispatch / terraform drain / contamination tick /worldsim_updatedrender hook stay GDScript-driven —stepdoes 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 todone. The existingiter_7k_turn_processor_gated_proofcovers the olderRUST_FAUNA_ENCOUNTERSflag, not the whole-roundRUST_TURNpath — a NEW proof scene is needed that drivesend_turn()through a full round withRUST_TURN=1and shows state advancing + rendering. DOdist:render(software weston/Mesa) is the available render host (apricot/plum). - GUT green; headless
mc-turnalready 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 — onlyCityFounded / 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_dictsurfaces only a thin slice (lair/victory/fauna).
So the real p3-29 work, in order:
- 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). - Surface them through
turn_result_to_dictso GDScript receives them. turn_managertranslatesTurnResult→EventBusemits (GDScript = pure render).- Swap turn_manager to
GdTurnProcessor.step; deleteturn_processor.gd::_process_*- the duplicate
EcologyState.tick.
- the duplicate
- 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 intoFloraSuccessionevents (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::step → TurnResult.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.tick → take_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)
- New
TurnEventvariants + emission:✅ (T1, 3e89c411a),CultureResearched✅ (T3, 34ab497e1),GoldenAgeStarted/Ended✅ (T2, f98b1fd01),UnitHealedItemProduced(§A, blocked on D1) — §A event emission DONE except ItemProduced. - Flora/ecology surface (§C): stop discarding
_transitions(ecology_phase.rs:76); addflora_transitions(+ creature/biome) toTurnResult. Tied to the registry-events refactor the Step-1 note defers — track it here so it is not lost. - Keystone (§B): generic
events[]inturn_result_to_dict. ✅ DONE (e165b4e6c, lib.rs:6573) — every §A event now reaches the live UI as a kind-tagged dict. - Decisions:
item_produced(event vs fold),strategic_gate_rejected+ round-lifecycle markers (surface viastepvs 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):
mc-replay/src/event.rs— add variant (+turn: u32) + arm inTurnEvent::turn()+ a serde round-trip case in the#[cfg(test)]block.- emit at the compute site that already decides the thing (do NOT recompute).
mc-player-api/src/dispatch.rs::translate_processor_events— add an exhaustive arm (translate to awire::Eventwhen one exists, else drop in the=> {}group; keeps the match exhaustive).api-gdext/src/replay.rs::event_to_dict— add akind-tagged dict arm.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): distinctItemProducedevent {turn, clan, city, item_id, hex}, mirroring the separate liveitem_producedsignal (turn_processor.gd:130), distinct fromCityBuildingCompleted. (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 throughstep()/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 inprocessor.rs::process_culture_researchon tradition completion.wire::Event::CultureResearchedalready 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 inhealing.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 correctamount. - T3 —
GoldenAgeStarted{turn, clan} (gated on D1-style confirm it's start-only; addGoldenAgeEndedonly if the live UI consumesgolden_age_ended— it does, event_bus.gd:176, so add both). Emit on the false→true (and true→false)golden_age_activetransition inhappiness_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 inprocess_city_productionalongsideCityBuildingCompleted. - T5 — §C creature/biome:
CreatureBorn/CreatureDied/BiomeChangedfrom the ecology tick. CORRECTION (verified 2026-06-27): the buffer pattern alone does NOT suffice —EcologyEngine::process_step(engine.rs:341) returns onlyVec<FloraTransition>; it does NOT report fauna births/deaths or biome-label changes. The livecreature_born/creature_diedsignals come from GDScriptfauna.gd:184/209(a separate path), and biome changes are unreported by the headless engine. So T5 first needs a new report surface inmc-ecology(e.g.process_stepreturns 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: Arrayinturn_result_to_dictloopsevents_emittedthroughevent_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.