diff --git a/src/simulator/crates/mc-ecology/src/engine.rs b/src/simulator/crates/mc-ecology/src/engine.rs index 9979a8a8..c08fdb8c 100644 --- a/src/simulator/crates/mc-ecology/src/engine.rs +++ b/src/simulator/crates/mc-ecology/src/engine.rs @@ -16,6 +16,25 @@ use crate::tier; use crate::traits::{Diet, Habitat}; use crate::wilds::{self, LairConfig}; +/// A flora succession transition emitted when a `Diet::Producer` slot advances +/// (or retreats to) a new ecology tier during the played-turn tier-advancement +/// tick (g2-07). Surfaced by [`EcologyEngine::process_step`] so the orchestrator +/// (`mc-worldsim::WorldSim::step`) can push a chronicle entry and the renderer / +/// game log can show flora succeeding forward. +/// +/// Only flora (Producer diet) transitions are reported — fauna tier changes are +/// not part of the succession narrative. Deterministic: the collection is sorted +/// by `(col, row, species_id)` before return so the chronicle order is stable +/// across the parallel advancement pass. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FloraTransition { + pub col: i32, + pub row: i32, + pub species_id: u32, + pub from_tier: i32, + pub to_tier: i32, +} + /// Territory radius (in tiles) for a lair of the given tier. /// Mirrors wilds::territory_radius but inlined here to avoid a circular dep. fn lair_territory_radius(tier: i32) -> i32 { @@ -273,7 +292,11 @@ impl EcologyEngine { /// It must be called by the caller after both flora and fauna have stepped, /// and after event survival bonuses have been applied — so it sees the true /// final state of the tick. See `evolution::run_evolution` for the full order. - pub fn process_step(&mut self, grid: &mut GridState, dt: f32, seed: u64) { + /// Run one continuous ecology tick. Returns the flora (Producer-diet) + /// succession transitions that occurred during this tick's tier-advancement + /// pass (g2-07) so the orchestrator can chronicle them; empty when no flora + /// crossed a tier this tick. Callers that don't care may discard the value. + pub fn process_step(&mut self, grid: &mut GridState, dt: f32, seed: u64) -> Vec { // 1. Emergence: throttled to every EMERGENCE_INTERVAL ticks. // New species colonize slowly — per-tick checks add serial overhead with no benefit. if self.tick_count.is_multiple_of(EMERGENCE_INTERVAL) { @@ -343,8 +366,8 @@ impl EcologyEngine { crate::generation::apply_migrations(&mut self.tile_populations, &migrations); } - // 4. Tier advancement - self.run_tier_advancement(grid); + // 4. Tier advancement (captures flora succession transitions to chronicle). + let flora_transitions = self.run_tier_advancement(grid); // 5. Fish stock dynamics (water tiles only) fish::tick_fish_stocks( @@ -374,6 +397,8 @@ impl EcologyEngine { // 9. Lair formation: lair-forming species create new Active lairs on eligible tiles. wilds::check_lair_formation(grid, self, &self.lair_config.clone(), current_turn); + + flora_transitions } /// Check emergence for all tiles and add new populations. @@ -425,7 +450,11 @@ impl EcologyEngine { const GLOBAL_T10_PER_DIET_CAP: usize = 5; /// Run tier advancement and remove extinct populations. - fn run_tier_advancement(&mut self, grid: &GridState) { + /// Advance every tile's population tiers one tick and return the flora + /// (Producer-diet) succession transitions that occurred (g2-07). Fauna tier + /// changes are not reported. The returned vec is sorted by + /// `(col, row, species_id)` for deterministic chronicle ordering. + fn run_tier_advancement(&mut self, grid: &GridState) -> Vec { let species_registry = &self.species_registry; // Pre-pass: count T10 slots per diet across all tiles. @@ -449,8 +478,12 @@ impl EcologyEngine { .map(|(&diet, _)| diet) .collect(); - // Parallel: advance tiers in-place, collect extinction indices per tile. - let extinctions: Vec<((i32, i32), Vec)> = self.tile_populations + // Parallel: advance tiers in-place, collect extinction indices and flora + // (Producer-diet) succession transitions per tile. Each tile is processed + // independently so the per-tile transition capture is race-free; the + // collected vecs are sorted afterwards for deterministic ordering. + type TileResult = ((i32, i32), Vec, Vec); + let per_tile: Vec = self.tile_populations .par_iter_mut() .filter_map(|(key, slots)| { let (col, row) = *key; @@ -480,6 +513,11 @@ impl EcologyEngine { slots.iter().map(|s| s.tier >= 10).collect() }; + // Snapshot pre-advancement tier per slot so we can detect flora + // succession transitions after the tick + any post-passes settle. + let tiers_before: Vec<(u32, i32)> = + slots.iter().map(|s| (s.species_id, s.tier)).collect(); + let to_remove = tier::tick_tiers_capped( slots, &carrying_caps, @@ -510,10 +548,55 @@ impl EcologyEngine { } } - if to_remove.is_empty() { None } else { Some(((col, row), to_remove)) } + // Capture flora (Producer-diet) tier transitions after all + // post-passes settle. Index-aligned with `tiers_before` because + // `tick_tiers_capped` and the post-passes mutate in place without + // reordering (removals are applied sequentially *after* this pass). + let mut transitions: Vec = Vec::new(); + for (i, slot) in slots.iter().enumerate() { + let Some((before_id, before_tier)) = tiers_before.get(i).copied() else { + continue; + }; + if before_id != slot.species_id || before_tier == slot.tier { + continue; + } + let is_flora = species_registry + .get(&slot.species_id) + .map(|sp| sp.traits.diet == Diet::Producer) + .unwrap_or(false); + if is_flora { + transitions.push(FloraTransition { + col, + row, + species_id: slot.species_id, + from_tier: before_tier, + to_tier: slot.tier, + }); + } + } + + if to_remove.is_empty() && transitions.is_empty() { + None + } else { + Some(((col, row), to_remove, transitions)) + } }) .collect(); + // Gather flora transitions across all tiles into a deterministically + // ordered vec (sorted by (col, row, species_id)). + let mut flora_transitions: Vec = per_tile + .iter() + .flat_map(|(_, _, t)| t.iter().copied()) + .collect(); + flora_transitions.sort_unstable_by_key(|t| (t.col, t.row, t.species_id)); + + let extinctions: Vec<((i32, i32), Vec)> = per_tile + .into_iter() + .filter(|(_, to_remove, _)| !to_remove.is_empty()) + .map(|(key, to_remove, _)| (key, to_remove)) + .collect(); + // Sequential: apply removals and clean up empty tile entries. for ((col, row), mut to_remove) in extinctions { if let Some(slots) = self.tile_populations.get_mut(&(col, row)) { @@ -529,6 +612,8 @@ impl EcologyEngine { self.tile_populations.remove(&(col, row)); } } + + flora_transitions } /// Habitat-gradient dispersal: populations migrate toward better habitat, diff --git a/src/simulator/crates/mc-ecology/src/lib.rs b/src/simulator/crates/mc-ecology/src/lib.rs index 9da11c08..2e22b987 100644 --- a/src/simulator/crates/mc-ecology/src/lib.rs +++ b/src/simulator/crates/mc-ecology/src/lib.rs @@ -44,7 +44,7 @@ pub use flora_select::{ pub use fauna_select::{TerrainFaunaIndex, FaunaSpec, FaunaManifest, SelectedFauna, pick_fauna_for_tile, domain_gate as fauna_domain_gate}; pub use fauna_glyphs::{FaunaGlyphCluster, lineage_to_glyph_cluster}; pub use config::{DispersalConfig, EcologyConfig, FloraFeedbackConfig}; -pub use engine::{EcologyContinuationState, EcologyEngine, load_biome_emergence_multipliers_json}; +pub use engine::{EcologyContinuationState, EcologyEngine, FloraTransition, load_biome_emergence_multipliers_json}; pub use biological::{advance_bloom_streak, derive_biological_events, BiologicalEvent, BiologicalThresholds}; pub use events::{EventCategory, EventTierData, load_event_categories}; pub use species::load_species_library; diff --git a/src/simulator/crates/mc-worldsim/src/lib.rs b/src/simulator/crates/mc-worldsim/src/lib.rs index c7b799a5..939b5ff1 100644 --- a/src/simulator/crates/mc-worldsim/src/lib.rs +++ b/src/simulator/crates/mc-worldsim/src/lib.rs @@ -57,7 +57,7 @@ use mc_ecology::tile::TileEcoState; use mc_ecology::EcologyEngine; use mc_mapgen::events::GeologicalThresholds; use mc_state::game_state::GameState; -use mc_turn::chronicle::Chronicle; +use mc_turn::chronicle::{Chronicle, ChronicleEntry}; use mc_turn::{TurnProcessor, TurnResult}; pub use event_dispatch::dispatch_world_events; @@ -183,7 +183,25 @@ impl WorldSim { // SeedDomain::WorldsimDynamics mixed with the turn index. let eco_seed = derive_step(self.seed, SeedDomain::WorldsimDynamics, &[u64::from(turn_idx)]); - self.ecology.process_step(grid, TURN_DT, eco_seed); + let flora_transitions = self.ecology.process_step(grid, TURN_DT, eco_seed); + + // 3b. Chronicle flora succession transitions (g2-07). One + // `ChronicleEntry::WorldEvent` per tile/species that crossed a + // tier this tick, under the "biological" category with kind + // "flora_transition". `severity_milli` carries the new tier × 1000 + // so the log/renderer can show how far the tile succeeded. The + // transition list is already sorted (col,row,species_id) → + // deterministic chronicle order. + for tr in &flora_transitions { + self.chronicle.push(ChronicleEntry::WorldEvent { + turn: turn_idx, + category: "biological".to_string(), + kind: "flora_transition".to_string(), + col: tr.col, + row: tr.row, + severity_milli: tr.to_tier * 1000, + }); + } // 4. World-event dispatch (geological / biological / anomalous). dispatch_world_events( @@ -615,6 +633,55 @@ mod tests { ); } + /// g2-07 acceptance — a flora succession transition emits a chronicle event + /// (`ChronicleEntry::WorldEvent { category: "biological", kind: "flora_transition" }`) + /// surfaced in the playable game log. Drives `WorldSim::step` until the + /// seeded flora crosses a tier, then asserts the chronicle carries a + /// matching entry whose `severity_milli` encodes the new tier. + #[test] + fn flora_transition_emits_chronicle_event() { + let (mut sim, mut state) = make_flora_fixture(SEED); + for _ in 0..60 { + sim.step(&mut state); + } + + let end = flora_tier(&sim).expect("flora present"); + assert!(end > 1, "precondition: flora advanced a tier"); + + let flora_events: Vec<&ChronicleEntry> = sim + .chronicle + .entries() + .iter() + .filter(|e| { + matches!( + e, + ChronicleEntry::WorldEvent { category, kind, .. } + if category == "biological" && kind == "flora_transition" + ) + }) + .collect(); + assert!( + !flora_events.is_empty(), + "a flora succession transition must push a chronicle WorldEvent" + ); + + // The transition tile must match the seeded tile, and severity_milli must + // encode a tier in (1, 10]. + let on_tile = flora_events.iter().any(|e| match e { + ChronicleEntry::WorldEvent { col, row, severity_milli, .. } => { + *col == FLORA_TILE.0 + && *row == FLORA_TILE.1 + && *severity_milli > 1000 + && *severity_milli <= 10_000 + } + _ => false, + }); + assert!( + on_tile, + "flora_transition chronicle entry must be at the seeded tile with tier-encoded severity" + ); + } + /// g2-07 acceptance — succession is deterministic: same `seed` → identical /// tier sequence across played turns. #[test]