feat(@projects/@magic-civilization): ✨ expose flora succession transitions
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
97fde477c2
commit
6931b934ec
3 changed files with 162 additions and 10 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue