feat(simulator): Introduce EventDispatcher infrastructure for dynamic world state updates with event types in event_dispatch.rs

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-06 16:03:16 -07:00
parent 36893f5e45
commit 4008e56643
2 changed files with 0 additions and 455 deletions

View file

@ -1,453 +0,0 @@
//! p3-13: world event dispatch — geological, biological, anomalous.
//!
//! Lives in `mc-sim` rather than `mc-turn` because the three derive crates
//! (mc-mapgen, mc-ecology, mc-climate) cannot be depended on by mc-turn without
//! creating a cycle: mc-mapgen → mc-turn already exists for the spawn-box
//! prologue contract, and mc-ecology → mc-mapgen, so both chains return to
//! mc-turn. mc-sim sits above all of them and is the correct orchestration
//! layer.
//!
//! ## Call site
//!
//! Call `dispatch_world_events` *after* `TurnProcessor::step` returns, passing
//! the same `GameState`. The function is a no-op when `state.grid` is `None`
//! (headless unit-test path).
//!
//! ## Damage store
//!
//! `TileState` (mc-core) does not embed `TileEcoState` (mc-ecology) because
//! mc-core cannot depend on mc-ecology. The caller owns a `HashMap<(u16,u16),
//! TileEcoState>` keyed by `(col as u16, row as u16)` and passes it as
//! `eco_map`. The dispatch function accumulates damage hits there; the caller
//! persists the map alongside the game save.
//!
//! ## Event → damage channel mapping
//!
//! | Category | Kind | DamageChannel |
//! |-------------|-------------------|---------------|
//! | geological | earthquake | Land |
//! | geological | volcanic_eruption | Land + Air |
//! | geological | landslide | Land |
//! | biological | plague | Water |
//! | biological | bloom | — (positive) |
//! | biological | migration_pulse | — (neutral) |
//! | anomalous | aurora | — (cosmetic) |
//! | anomalous | fog_bank | Air (stub 0.1)|
//! | anomalous | thermal_anomaly | Air (stub) |
//!
//! ## FogBank → apply_fog
//!
//! For each `AnomalousEvent::FogBank`, the flat tile index is computed from
//! `(col, row)` and the grid width, then forwarded to
//! `mc_observation::fog::apply_fog` on `state.fog_map`.
//!
//! ## Chronicle
//!
//! One `ChronicleEntry::WorldEvent` is pushed per emitted event so the history
//! panel and balance scrapers have a complete turn record.
use std::collections::HashMap;
use mc_climate::anomalous::{derive_anomalous_events, AnomalousEvent, AnomalousThresholds};
use mc_core::DamageChannel;
use mc_ecology::biological::{
advance_bloom_streak, derive_biological_events, BiologicalEvent, BiologicalThresholds,
};
use mc_ecology::tile::{apply_damage, TileEcoState};
use mc_mapgen::events::{derive_events as derive_geological_events, GeologicalThresholds};
use mc_observation::apply_fog;
use mc_turn::chronicle::{Chronicle, ChronicleEntry};
use mc_state::game_state::GameState;
/// Run all three world-event derivation passes for this turn and apply their
/// side-effects to `state` and `eco_map`.
///
/// Returns the total number of events dispatched (useful for tests / balance
/// logging). Returns `0` immediately when `state.grid` is `None`.
///
/// `seed` should be the game's master seed. The caller owns seed derivation
/// so tests can pass arbitrary values.
///
/// `eco_map` is a sparse per-tile ecological damage accumulator, keyed by
/// `(col as u16, row as u16)`. The caller persists this alongside the game
/// save. Entries are never removed by this function — saturation is via
/// `u16::MAX` within `apply_damage`.
pub fn dispatch_world_events(
state: &mut GameState,
seed: u64,
geo_thresholds: &GeologicalThresholds,
bio_thresholds: &BiologicalThresholds,
anomalous_thresholds: &AnomalousThresholds,
eco_map: &mut HashMap<(u16, u16), TileEcoState>,
chronicle: &mut Chronicle,
) -> usize {
// Advance the bloom streak counter once per turn BEFORE derivation so the
// "N consecutive turns of favourable climate" gate (p3-13c) observes the
// freshly-advanced streak. Mutable borrow scoped tightly so the immutable
// grid ref below is valid for the rest of the pass.
if let Some(g_mut) = state.grid.as_mut() {
advance_bloom_streak(g_mut, bio_thresholds);
}
let grid = match state.grid.as_ref() {
Some(g) => g,
None => return 0,
};
let turn_i32 = state.turn as i32;
let turn_u32 = state.turn;
// ── Geological events ────────────────────────────────────────────────────
let geo_events = derive_geological_events(grid, geo_thresholds, turn_i32, seed);
// ── Biological events ────────────────────────────────────────────────────
let bio_events = derive_biological_events(grid, bio_thresholds, turn_i32, seed);
// ── Anomalous events ─────────────────────────────────────────────────────
// No temp_history plumbed yet — ThermalAnomaly is skipped per design when
// the slice is None.
let anom_events = derive_anomalous_events(grid, anomalous_thresholds, turn_i32, seed, None);
let total = geo_events.len() + bio_events.len() + anom_events.len();
// ── Apply geological damage + chronicle ──────────────────────────────────
for ev in &geo_events {
let key = (ev.col as u16, ev.row as u16);
let eco = eco_map.entry(key).or_insert_with(TileEcoState::clean);
apply_damage(eco, DamageChannel::Land, ev.severity);
if ev.kind == "volcanic_eruption" {
apply_damage(eco, DamageChannel::Air, ev.severity * 0.5);
}
chronicle.push(ChronicleEntry::WorldEvent {
turn: turn_u32,
category: "geological".to_string(),
kind: ev.kind.clone(),
col: ev.col,
row: ev.row,
severity_milli: (ev.severity * 1000.0) as i32,
});
}
// ── Apply biological damage + chronicle ──────────────────────────────────
for ev in &bio_events {
let (col, row, severity, kind) = match ev {
BiologicalEvent::Plague { col, row, severity } => {
let key = (*col as u16, *row as u16);
let eco = eco_map.entry(key).or_insert_with(TileEcoState::clean);
apply_damage(eco, DamageChannel::Water, *severity);
(*col, *row, *severity, "plague")
}
BiologicalEvent::Bloom { col, row, intensity } => {
// Positive event — no damage counters.
(*col, *row, *intensity, "bloom")
}
BiologicalEvent::MigrationPulse { from_col, from_row, magnitude, .. } => {
// Neutral event — no damage counters.
(*from_col, *from_row, *magnitude, "migration_pulse")
}
};
chronicle.push(ChronicleEntry::WorldEvent {
turn: turn_u32,
category: "biological".to_string(),
kind: kind.to_string(),
col,
row,
severity_milli: (severity * 1000.0) as i32,
});
}
// ── Apply anomalous effects + chronicle ───────────────────────────────────
let current_turn_u16 = (state.turn as u16).min(u16::MAX);
for ev in &anom_events {
let (col, row, severity, kind) = match ev {
AnomalousEvent::Aurora { col, row } => {
// Cosmetic — no counters.
(*col, *row, 0.0_f32, "aurora")
}
AnomalousEvent::FogBank { col, row, duration } => {
// Apply fog to the game state fog map (flat tile index).
// The grid width is safe to access here because we already
// confirmed grid is Some above; re-borrow briefly.
let grid_width = state.grid.as_ref().map(|g| g.width).unwrap_or(1);
let flat_idx = ((*row * grid_width) + *col) as u16;
apply_fog(flat_idx, *duration, current_turn_u16, &mut state.fog_map);
// Stub Air accumulation (historical only — 0.1 hit).
let key = (*col as u16, *row as u16);
let eco = eco_map.entry(key).or_insert_with(TileEcoState::clean);
apply_damage(eco, DamageChannel::Air, 0.1);
(*col, *row, *duration as f32, "fog_bank")
}
AnomalousEvent::ThermalAnomaly { col, row, z_score } => {
let key = (*col as u16, *row as u16);
let eco = eco_map.entry(key).or_insert_with(TileEcoState::clean);
apply_damage(eco, DamageChannel::Air, z_score.abs() * 0.1);
(*col, *row, *z_score, "thermal_anomaly")
}
};
chronicle.push(ChronicleEntry::WorldEvent {
turn: turn_u32,
category: "anomalous".to_string(),
kind: kind.to_string(),
col,
row,
severity_milli: (severity * 1000.0) as i32,
});
}
total
}
#[cfg(test)]
mod tests {
use super::*;
use mc_core::grid::GridState;
use mc_mapgen::tectonics::{boundary_kind, plate_kind};
/// Build a minimal 4×3 grid with tiles configured to fire at least one
/// event per category under the low-threshold configs below.
fn seeded_grid() -> GridState {
let mut grid = GridState::new(4, 3);
// ── Geological: earthquake (convergent boundary) ──────────────────────
let idx00 = grid.idx(0, 0);
grid.tiles[idx00].boundary_kind = boundary_kind::CONVERGENT;
grid.tiles[idx00].mountain_proximity = 0.9;
grid.tiles[idx00].col = 0;
grid.tiles[idx00].row = 0;
// ── Geological: volcanic eruption ─────────────────────────────────────
let idx10 = grid.idx(1, 0);
grid.tiles[idx10].plate_kind = plate_kind::VOLCANIC_ARC;
grid.tiles[idx10].mountain_proximity = 0.8;
grid.tiles[idx10].col = 1;
grid.tiles[idx10].row = 0;
// ── Geological: landslide (slope+saturation proxy) ───────────────────
let idx20 = grid.idx(2, 0);
grid.tiles[idx20].mountain_proximity = 0.85;
grid.tiles[idx20].moisture = 0.9;
grid.tiles[idx20].col = 2;
grid.tiles[idx20].row = 0;
// ── Biological: plague (high civ-presence, low quality) ───────────────
let idx01 = grid.idx(0, 1);
grid.tiles[idx01].civilization_presence = 0.9;
grid.tiles[idx01].quality = 0;
grid.tiles[idx01].col = 0;
grid.tiles[idx01].row = 1;
// ── Biological: bloom (warm + wet + flora-rich) ───────────────────────
let idx11 = grid.idx(1, 1);
grid.tiles[idx11].mean_temp = 0.7; // normalised 0-1 (post-climate-derive)
grid.tiles[idx11].mean_precip = 0.7;
grid.tiles[idx11].canopy_cover = 0.7;
grid.tiles[idx11].undergrowth = 0.6;
grid.tiles[idx11].col = 1;
grid.tiles[idx11].row = 1;
// ── Anomalous: aurora (high |latitude|, low humidity) ─────────────────
let idx02 = grid.idx(0, 2);
grid.tiles[idx02].latitude = 0.85;
grid.tiles[idx02].humidity = 0.05;
grid.tiles[idx02].col = 0;
grid.tiles[idx02].row = 2;
// ── Anomalous: fog_bank (high humidity, low temperature) ─────────────
let idx12 = grid.idx(1, 2);
grid.tiles[idx12].humidity = 0.95;
grid.tiles[idx12].temperature = 0.05;
grid.tiles[idx12].col = 1;
grid.tiles[idx12].row = 2;
grid
}
fn make_state_with_grid() -> GameState {
let mut state = mc_state::game_state::GameState::default();
state.grid = Some(seeded_grid());
state.turn = 1;
state
}
fn low_geo_thresholds() -> GeologicalThresholds {
GeologicalThresholds {
earthquake_trigger_chance: 1.0,
// multiplier=4 → severity = (4*0.5).clamp(0.1, 1.0) = 1.0 → 1 hit in apply_damage
earthquake_convergent_multiplier: 4.0,
earthquake_radius: 1,
earthquake_elevation_delta: -0.05,
earthquake_movement_penalty: 0.2,
earthquake_unit_damage: 5,
volcanic_eruption_trigger_chance: 1.0,
volcanic_eruption_magma_proxy_min: 0.0,
volcanic_eruption_radius: 2,
volcanic_eruption_elevation_delta: 0.1,
volcanic_eruption_movement_penalty: 0.5,
volcanic_eruption_unit_damage: 15,
landslide_slope_proxy_min: 0.5,
landslide_moisture_min: 0.5,
landslide_trigger_chance: 1.0,
landslide_radius: 1,
landslide_elevation_delta: -0.1,
landslide_movement_penalty: 0.3,
landslide_unit_damage: 8,
}
}
fn low_bio_thresholds() -> BiologicalThresholds {
BiologicalThresholds {
// plague_civ_min=0.0 → severity = ((1.0-0.0)*2.0).clamp=1.0 → 1 hit
plague_civ_min: 0.0,
plague_quality_max: 4, // catch all quality levels
plague_trigger_chance: 1.0,
plague_spread_factor: 0.5,
plague_spread_severity_scale: 0.5,
bloom_temp_min: 0.5,
bloom_temp_max: 0.9,
bloom_precip_min: 0.5,
bloom_canopy_min: 0.5,
bloom_undergrowth_min: 0.4,
bloom_trigger_chance: 1.0,
bloom_streak_min: 0,
migration_source_min: 5.0,
migration_neighbour_max: 50.0,
migration_differential_min: 3.0,
migration_trigger_chance: 1.0,
migration_max_hops: 3,
}
}
fn low_anomalous_thresholds() -> AnomalousThresholds {
AnomalousThresholds {
aurora_latitude_min: 0.75,
aurora_humidity_max: 0.2,
aurora_trigger_chance: 1.0,
fog_bank_humidity_min: 0.8,
fog_bank_temperature_max: 0.3,
fog_bank_trigger_chance: 1.0,
fog_bank_cooldown_turns: 1,
fog_bank_duration_turns: 3,
thermal_anomaly_z_threshold: 2.5,
thermal_anomaly_base_chance: 0.001,
thermal_anomaly_volcanic_bonus: 0.02,
}
}
// ── p3_13_event_dispatch ──────────────────────────────────────────────────
#[test]
fn p3_13_event_dispatch_geological_applies_land_damage() {
let mut state = make_state_with_grid();
let mut eco_map: HashMap<(u16, u16), TileEcoState> = HashMap::new();
let mut chronicle = Chronicle::new();
let n = dispatch_world_events(
&mut state,
42,
&low_geo_thresholds(),
&low_bio_thresholds(),
&low_anomalous_thresholds(),
&mut eco_map,
&mut chronicle,
);
// Chronicle has at least one geological entry — primary coverage check.
let has_geo = chronicle.entries().iter().any(|e| {
matches!(e, ChronicleEntry::WorldEvent { category, .. } if category == "geological")
});
assert!(has_geo, "expected at least one geological ChronicleEntry::WorldEvent");
// At least one eco_map entry carries non-zero Land damage from geo events.
let has_land_damage = eco_map.values().any(|eco| eco.land_pollution_count > 0);
assert!(
has_land_damage,
"expected Land channel damage from geological events; eco_map keys = {:?}",
eco_map.keys().collect::<Vec<_>>()
);
}
#[test]
fn p3_13_event_dispatch_biological_plague_applies_water_damage() {
let mut state = make_state_with_grid();
let mut eco_map: HashMap<(u16, u16), TileEcoState> = HashMap::new();
let mut chronicle = Chronicle::new();
dispatch_world_events(
&mut state,
42,
&low_geo_thresholds(),
&low_bio_thresholds(),
&low_anomalous_thresholds(),
&mut eco_map,
&mut chronicle,
);
// Chronicle must have at least one biological entry.
let has_bio = chronicle.entries().iter().any(|e| {
matches!(e, ChronicleEntry::WorldEvent { category, .. } if category == "biological")
});
assert!(has_bio, "expected at least one biological ChronicleEntry::WorldEvent");
// At least one eco_map entry carries non-zero Water damage from plague.
let has_water_damage = eco_map.values().any(|eco| eco.water_pollution_count > 0);
assert!(
has_water_damage,
"expected Water channel damage from plague in eco_map"
);
}
#[test]
fn p3_13_event_dispatch_anomalous_fog_populates_fog_map() {
let mut state = make_state_with_grid();
let mut eco_map: HashMap<(u16, u16), TileEcoState> = HashMap::new();
let mut chronicle = Chronicle::new();
dispatch_world_events(
&mut state,
42,
&low_geo_thresholds(),
&low_bio_thresholds(),
&low_anomalous_thresholds(),
&mut eco_map,
&mut chronicle,
);
// state.fog_map should have at least one entry for the FogBank tile.
assert!(
!state.fog_map.is_empty(),
"expected fog_map to have at least one entry after FogBank dispatch"
);
let has_anom = chronicle.entries().iter().any(|e| {
matches!(e, ChronicleEntry::WorldEvent { category, .. } if category == "anomalous")
});
assert!(has_anom, "expected at least one anomalous ChronicleEntry::WorldEvent");
}
#[test]
fn p3_13_event_dispatch_noop_without_grid() {
let mut state = mc_state::game_state::GameState::default();
// state.grid is None by default — headless unit-test path.
let mut eco_map: HashMap<(u16, u16), TileEcoState> = HashMap::new();
let mut chronicle = Chronicle::new();
let n = dispatch_world_events(
&mut state,
42,
&low_geo_thresholds(),
&low_bio_thresholds(),
&low_anomalous_thresholds(),
&mut eco_map,
&mut chronicle,
);
assert_eq!(n, 0, "should be no-op when grid is None");
assert!(chronicle.is_empty(), "chronicle should be untouched when grid is None");
assert!(eco_map.is_empty(), "eco_map should be untouched when grid is None");
}
}

View file

@ -1,5 +1,3 @@
pub mod event_dispatch;
use mc_balance::{outcome::GameOutcome, runner::SimRunner, strategy::StrategyConfig};
use mc_city::CityState;
use mc_state::game_state::{GameState, PlayerState};