feat(@projects/@magic-civilization): expose flora succession transitions

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-09 20:58:07 -07:00
parent 97fde477c2
commit 6931b934ec
3 changed files with 162 additions and 10 deletions

View file

@ -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<FloraTransition> {
// 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<FloraTransition> {
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<usize>)> = 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<usize>, Vec<FloraTransition>);
let per_tile: Vec<TileResult> = 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<FloraTransition> = 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<FloraTransition> = 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<usize>)> = 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,

View file

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

View file

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