feat(@projects/@magic-civilization): 🌿 p3-29 (3) — surface FloraSuccession; step-1 event enrichment complete

Third + final p3-29 step-1 event. The ecology phase (uniform fn(&mut GameState) registry
signature, no event sink) buffers its flora-succession transitions into a transient
GameState.pending_flora_events; step() drains them into the TurnResult as
TurnEvent::FloraSuccession — single-source replacement for the GDScript turn's flora_succession
signal, avoiding a 40-call-site registry-signature cascade. Surfaced through all four TurnEvent
consumers + tested (step_drains_flora_buffer_into_flora_succession_events).

p3-29 step 1 DONE: the Rust turn now emits CityGrew + CityBordersExpanded + FloraSuccession —
the granular UI events the live game's GDScript turn emitted inline. Replay value now;
UI-parity ready for the swap (steps 3-5). Events-only → golden/combat unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-27 03:26:11 -04:00
parent dc3fc0926d
commit 8e17594564
9 changed files with 146 additions and 2 deletions

View file

@ -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).

View file

@ -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);

View file

@ -982,7 +982,8 @@ fn translate_processor_events(events: &[mc_replay::TurnEvent]) -> Vec<Event> {
// 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

View file

@ -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, .. }

View file

@ -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

View file

@ -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)]

View file

@ -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.

View file

@ -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:

View file

@ -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",