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:
parent
36893f5e45
commit
4008e56643
2 changed files with 0 additions and 455 deletions
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue