diff --git a/.project/objectives/p3-29-rail1-turn-unification.md b/.project/objectives/p3-29-rail1-turn-unification.md index 26d54505..fb3cc59d 100644 --- a/.project/objectives/p3-29-rail1-turn-unification.md +++ b/.project/objectives/p3-29-rail1-turn-unification.md @@ -86,3 +86,66 @@ this session but the live game still runs GDScript — the event-surface gap was 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`** | ❌ MISSING — wire has it (wire.rs:293), no `TurnEvent`; emit in `process_culture_research` | — | +| `golden_age_started` | `_process_growth` | **`GoldenAgeStarted`** (new) | ❌ MISSING — golden-age computed in `happiness_phase` (`process_golden_age`), emits nothing | happiness_phase.rs:35 | +| `unit_healed` | `_process_healing` | **`UnitHealed`** (new) | ❌ MISSING — `healing.rs` emits no event | 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`) + +❌ **NOT STARTED.** `turn_result_to_dict` exposes **no generic `events[]`** from +`events_emitted` — it only pulls `AmbientEncounterFired` (lib.rs:6620). So every §A event +already in `TurnResult` (even the DONE `CityGrew`/`CityBordersExpanded`) **never reaches +GDScript on the live-step path.** The kind-tagged helper `event_to_dict` already exists +(`api-gdext/src/replay.rs`) but is wired to the *replay* path only. **Work:** add a generic +`events[]` to `turn_result_to_dict` reusing `event_to_dict`; `turn_manager` iterates it → +`EventBus`. Until this lands, all §A enum work is invisible to the live game — this is the +true "single source" keystone, independent of how many §A variants exist. + +### 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) + +1. **New `TurnEvent` variants + emission:** `CultureResearched`, `GoldenAgeStarted`, + `UnitHealed`, `ItemProduced` (§A) — the parallel session is walking §A in order. +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`. **Highest leverage** — without + it none of §A reaches the live UI. Not yet started. +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). diff --git a/src/simulator/api-gdext/src/replay.rs b/src/simulator/api-gdext/src/replay.rs index b70e3237..39f8c5a1 100644 --- a/src/simulator/api-gdext/src/replay.rs +++ b/src/simulator/api-gdext/src/replay.rs @@ -218,6 +218,15 @@ fn event_to_dict(evt: &TurnEvent) -> Dictionary { d.set("col", hex.q as i64); d.set("row", hex.r as i64); } + TurnEvent::FloraSuccession { turn, hex, species_id, from_tier, to_tier } => { + d.set("kind", GString::from("FloraSuccession")); + d.set("turn", *turn as i64); + d.set("species_id", *species_id as i64); + d.set("from_tier", *from_tier as i64); + d.set("to_tier", *to_tier as i64); + d.set("col", hex.q as i64); + d.set("row", hex.r as i64); + } TurnEvent::GameOver { turn, winner, reason_kind, condition, resigned_clan } => { d.set("kind", GString::from("GameOver")); d.set("turn", *turn as i64); diff --git a/src/simulator/crates/mc-player-api/src/dispatch.rs b/src/simulator/crates/mc-player-api/src/dispatch.rs index 069aceb6..7e86764b 100644 --- a/src/simulator/crates/mc-player-api/src/dispatch.rs +++ b/src/simulator/crates/mc-player-api/src/dispatch.rs @@ -982,7 +982,8 @@ fn translate_processor_events(events: &[mc_replay::TurnEvent]) -> Vec { // p3-29: surfaced for replay + the live UI (city growth / border // expansion), not the wire protocol — drop here to keep it exhaustive. | mc_replay::TurnEvent::CityGrew { .. } - | mc_replay::TurnEvent::CityBordersExpanded { .. } => {} + | mc_replay::TurnEvent::CityBordersExpanded { .. } + | mc_replay::TurnEvent::FloraSuccession { .. } => {} } } out diff --git a/src/simulator/crates/mc-replay/src/event.rs b/src/simulator/crates/mc-replay/src/event.rs index 5a2f648f..31f743df 100644 --- a/src/simulator/crates/mc-replay/src/event.rs +++ b/src/simulator/crates/mc-replay/src/event.rs @@ -133,6 +133,21 @@ pub enum TurnEvent { /// The newly-claimed tile. hex: TileCoord, }, + /// p3-29: a tile's dominant flora advanced/regressed a succession tier + /// (single-source replacement for the GDScript turn's `flora_succession` + /// signal). Tile-scoped (flora is unowned), so no clan/city. + FloraSuccession { + /// Turn the event fired on. + turn: u32, + /// Tile the succession happened on. + hex: TileCoord, + /// Flora species id that changed tier. + species_id: u32, + /// Tier before. + from_tier: i32, + /// Tier after. + to_tier: i32, + }, /// A wonder finished construction. WonderBuilt { /// Turn the event fired on. @@ -527,6 +542,7 @@ impl TurnEvent { | Self::CityBuildingCompleted { turn, .. } | Self::CityGrew { turn, .. } | Self::CityBordersExpanded { turn, .. } + | Self::FloraSuccession { turn, .. } | Self::WonderBuilt { turn, .. } | Self::WarDeclared { turn, .. } | Self::PeaceSigned { turn, .. } diff --git a/src/simulator/crates/mc-state/src/game_state.rs b/src/simulator/crates/mc-state/src/game_state.rs index 33928d92..a573eafd 100644 --- a/src/simulator/crates/mc-state/src/game_state.rs +++ b/src/simulator/crates/mc-state/src/game_state.rs @@ -432,6 +432,13 @@ pub struct GameState { /// until the first ecology tick seeds the world. #[serde(default)] pub worldsim_state_json: String, + /// p3-29: transient buffer of flora-succession transitions + /// `(col, row, species_id, from_tier, to_tier)` the ecology phase produced + /// this turn. `step()` drains it into `TurnResult` as `FloraSuccession` + /// events (the ecology phase has no event sink — uniform `fn(&mut GameState)` + /// registry signature). `#[serde(skip)]` — cleared/drained every turn. + #[serde(skip)] + pub pending_flora_events: Vec<(i32, i32, u32, i32, i32)>, /// p3-26 B3: improvement definitions (`id → {build_turns, food, production}`), /// boot-loaded from `public/resources/improvements/*.json`. `#[serde(skip)]` /// static content like the other catalogs; drives both the build-tick diff --git a/src/simulator/crates/mc-turn/src/ecology_phase.rs b/src/simulator/crates/mc-turn/src/ecology_phase.rs index 7bea88ed..bd290d62 100644 --- a/src/simulator/crates/mc-turn/src/ecology_phase.rs +++ b/src/simulator/crates/mc-turn/src/ecology_phase.rs @@ -73,7 +73,7 @@ pub fn process_ecology_phase(state: &mut GameState) { // too slow to populate a fresh world (mirrors the live first-tick seed). engine.seed_initial(grid, seed); } - let _transitions = engine.process_step(grid, 1.0, seed); + let transitions = engine.process_step(grid, 1.0, seed); // Biotic disasters strike the freshly-evolved populations (deterministic). let _diseases = engine.apply_disease_events(grid, &disease_categories, turn, seed); @@ -81,6 +81,15 @@ pub fn process_ecology_phase(state: &mut GameState) { if let Ok(s) = serde_json::to_string(&engine.continuation_state()) { state.worldsim_state_json = s; } + + // p3-29: buffer flora-succession transitions for step() to drain into the + // TurnResult as FloraSuccession events (the grid borrow has ended above). + state.pending_flora_events.clear(); + state.pending_flora_events.extend( + transitions + .iter() + .map(|t| (t.col, t.row, t.species_id, t.from_tier, t.to_tier)), + ); } #[cfg(test)] diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index fa19c9ca..b2ba5b04 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -528,6 +528,21 @@ impl TurnProcessor { // reacts to the climate just ticked; healing settles afterward. crate::sim_phases::run_end_of_turn_phases(state); + // p3-29: drain the ecology phase's flora-succession buffer into the turn + // result as FloraSuccession events (the phase registry has no event sink). + if !state.pending_flora_events.is_empty() { + let turn_now = state.turn; + for (col, row, species_id, from_tier, to_tier) in state.pending_flora_events.drain(..) { + result.events_emitted.push(mc_replay::TurnEvent::FloraSuccession { + turn: turn_now, + hex: mc_replay::TileCoord::new(col, row), + species_id, + from_tier, + to_tier, + }); + } + } + // Phase 5a-sentry: wake sentrying units that have enemies in vision range (2 hex). // Runs after movement so positions are current; runs before PvP so the // now-awoken unit's state is consistent when combat checks fire. @@ -8733,6 +8748,25 @@ mod tests { assert!(grew, "CityGrew must fire on growth; got {events:?}"); } + /// p3-29: `step()` drains the ecology phase's `pending_flora_events` buffer + /// into the TurnResult as `FloraSuccession` events (grid=None → ecology + /// no-ops and leaves the pre-seeded buffer for step to drain). + #[test] + fn step_drains_flora_buffer_into_flora_succession_events() { + let processor = TurnProcessor::new(100); + let mut state = GameState::default(); + state.pending_flora_events = vec![(2, 3, 7, 0, 1)]; + let result = processor.step(&mut state); + let saw = result.events_emitted.iter().any(|e| { + matches!( + e, + mc_replay::TurnEvent::FloraSuccession { species_id: 7, from_tier: 0, to_tier: 1, .. } + ) + }); + assert!(saw, "FloraSuccession must be drained; got {:?}", result.events_emitted); + assert!(state.pending_flora_events.is_empty(), "buffer drained empty"); + } + /// p2-67 Bug 4: when a city has `Queueable::Unit { unit_id }` queued /// and production_stored >= unit_spawn_cost, `try_spawn_unit` should /// spawn THAT unit_id (not hardcoded dwarf_warrior) and clear the queue. diff --git a/src/simulator/crates/mc-turn/src/sim_phases.rs b/src/simulator/crates/mc-turn/src/sim_phases.rs index 34e7ec3e..0b9df8a2 100644 --- a/src/simulator/crates/mc-turn/src/sim_phases.rs +++ b/src/simulator/crates/mc-turn/src/sim_phases.rs @@ -17,6 +17,9 @@ use mc_state::game_state::GameState; /// A turn phase: mutates the whole `GameState` for one turn, looping internally. +/// Phases that surface UI/replay events write them to a transient `GameState` +/// buffer (e.g. ecology → `pending_flora_events`) which `step()` drains into the +/// `TurnResult` — keeping the phase signature uniform. pub type SimPhaseFn = fn(&mut GameState); /// Ordered end-of-turn world-sim phases, run after climate. Order matters: diff --git a/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs b/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs index 723a1674..de3fa119 100644 --- a/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs +++ b/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs @@ -208,6 +208,7 @@ fn ten_turn_run_emits_each_wired_variant() { TurnEvent::CityBuildingCompleted { .. } => "CityBuildingCompleted", TurnEvent::CityGrew { .. } => "CityGrew", TurnEvent::CityBordersExpanded { .. } => "CityBordersExpanded", + TurnEvent::FloraSuccession { .. } => "FloraSuccession", TurnEvent::CityFounded { .. } => "CityFounded", TurnEvent::WonderBuilt { .. } => "WonderBuilt", TurnEvent::CityCaptured { .. } => "CityCaptured", @@ -326,6 +327,7 @@ fn events_emitted_appears_on_turn_result() { TurnEvent::CityBuildingCompleted { .. } => "CityBuildingCompleted", TurnEvent::CityGrew { .. } => "CityGrew", TurnEvent::CityBordersExpanded { .. } => "CityBordersExpanded", + TurnEvent::FloraSuccession { .. } => "FloraSuccession", TurnEvent::CityFounded { .. } => "CityFounded", TurnEvent::WonderBuilt { .. } => "WonderBuilt", TurnEvent::CityCaptured { .. } => "CityCaptured",