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:
parent
dc3fc0926d
commit
8e17594564
9 changed files with 146 additions and 2 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, .. }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue