feat(@projects/@magic-civilization): 🌍 p3-26 B8 — seismic + impact + tsunami event categories
Three geological natural-event categories (parallel agent port, extracted file-only onto current main — the agent worktree forked from a stale base, so a branch merge was unsafe; events.rs is a clean superset verified to compile + test against current HEAD): - seismic — shifts tile elevation over a radius (with falloff). - impact (asteroid) — T1-4 crater (biome/elevation/moisture/quality + local heat + aerosol); T5 extinction (global aerosol injection). Magic/anchor/resource-spawn deferred (Game-3). - tsunami — floods coastal land (moisture/quality/reef_health), skips open water. Each: apply_X + dispatch_X + match arm in process_events + tests. Configs already present (seismic/impact/tsunami.json) + auto-loaded via the headless harness, so they dispatch in process_climate_phase with no extra wiring. mc-climate 61/0; mc-turn builds. 6/12 categories live (wildfire/drought/volcanic/seismic/impact/tsunami). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2a360af22a
commit
afa7613fd8
3 changed files with 531 additions and 1 deletions
|
|
@ -206,7 +206,10 @@ pub fn process_events(
|
|||
"wildfire" => dispatch_wildfire(grid, cfg, tier, turn_seed, channel),
|
||||
"drought" => dispatch_drought(grid, cfg, tier, turn_seed, channel),
|
||||
"volcanic" => dispatch_volcanic(grid, cfg, tier, turn_seed, channel),
|
||||
// TODO(p3-26 gap 2): impact/seismic/tsunami/plague/pandemic/marine/solar/glacial.
|
||||
"seismic" => dispatch_seismic(grid, cfg, tier, turn_seed, channel),
|
||||
"impact" => dispatch_impact(grid, cfg, tier, turn_seed, channel),
|
||||
"tsunami" => dispatch_tsunami(grid, cfg, tier, turn_seed, channel),
|
||||
// TODO(p3-26 gap 2): plague/pandemic/marine/solar/glacial.
|
||||
_ => None,
|
||||
};
|
||||
if let Some(ev) = ev {
|
||||
|
|
@ -413,6 +416,313 @@ fn dispatch_volcanic(
|
|||
})
|
||||
}
|
||||
|
||||
// ── seismic ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Apply a seismic event: shift `elevation` by `elevation_delta` (clamped to [0,1])
|
||||
/// over a hex disk of `radius` around `center`, with a linear distance falloff so
|
||||
/// the epicentre moves most. Returns the count of tiles affected. Mirrors GDScript
|
||||
/// `process_seismic` (building/unit/wall damage is recorded in the event log and
|
||||
/// processed by city.gd — not applied here, same as the live handler).
|
||||
pub fn apply_seismic(
|
||||
grid: &mut mc_core::grid::GridState,
|
||||
center: (i32, i32),
|
||||
radius: i32,
|
||||
elevation_delta: f32,
|
||||
) -> i32 {
|
||||
use mc_core::algorithms::hex::{axial_to_offset, hex_spiral, offset_to_axial};
|
||||
let (cq, cr) = offset_to_axial(center.0, center.1);
|
||||
let mut affected = 0;
|
||||
for (q, r) in hex_spiral(cq, cr, radius) {
|
||||
let (col, row) = axial_to_offset(q, r);
|
||||
if let Some(t) = grid.tile_mut(col, row) {
|
||||
let dist = {
|
||||
let dq = (q - cq).abs();
|
||||
let dr = (r - cr).abs();
|
||||
let ds = (q + r - cq - cr).abs();
|
||||
((dq + dr + ds) / 2) as f32
|
||||
};
|
||||
let falloff = 1.0 - dist / (radius + 1) as f32;
|
||||
t.elevation = (t.elevation + elevation_delta * falloff).clamp(0.0, 1.0);
|
||||
affected += 1;
|
||||
}
|
||||
}
|
||||
affected
|
||||
}
|
||||
|
||||
/// Resolve the seismic tier config + pick a non-water center, then quake.
|
||||
fn dispatch_seismic(
|
||||
grid: &mut mc_core::grid::GridState,
|
||||
cfg: &EventCategoryConfig,
|
||||
tier: usize,
|
||||
turn_seed: f64,
|
||||
channel: f64,
|
||||
) -> Option<FiredEvent> {
|
||||
use mc_core::grid::biome_registry::{has_tag, BiomeTag};
|
||||
let tc = cfg.raw.get("tiers").and_then(|t| t.get(tier.to_string()))?;
|
||||
let radius = tc.get("radius").and_then(|v| v.as_i64()).unwrap_or(2) as i32;
|
||||
let elevation_delta = tc.get("elevation_delta").and_then(|v| v.as_f64()).unwrap_or(0.01) as f32;
|
||||
let center = pick_matching_tile(grid, channel, turn_seed, |b| !has_tag(b, BiomeTag::IsWater))?;
|
||||
let affected = apply_seismic(grid, center, radius, elevation_delta);
|
||||
if affected == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(FiredEvent {
|
||||
category: "seismic".to_string(),
|
||||
tier,
|
||||
center,
|
||||
affected,
|
||||
})
|
||||
}
|
||||
|
||||
// ── impact ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Apply an asteroid impact. Handles two distinct code paths that mirror GDScript
|
||||
/// `process_impact`:
|
||||
///
|
||||
/// **T5 extinction path** (`crater_radius` present, no `elevation_delta`):
|
||||
/// - Crater disk: non-water tiles → `"desert"`, elevation −`elevation_loss`, moisture 0, quality 1.
|
||||
/// - Global sulfate aerosol injection (`aerosol_strength` on every tile).
|
||||
/// - Biome collapse: living terrain → quality loss; jungle → grassland; swamp → desert.
|
||||
/// - Impact site → `"mana_node"` (game-3 anchor skipped per contract, same as volcanic).
|
||||
///
|
||||
/// **T1-T4 standard path** (`elevation_delta` present):
|
||||
/// - If `elevation_delta > 0`: center → `crater_terrain`, elevation raised, quality 3.
|
||||
/// - If `elevation_delta ≤ 0`: center → `"lake"` (low elevation) or `"desert"`, elevation lowered, quality 1.
|
||||
/// - Heat pulse: `magic_heat_delta` += `heat_delta` over `heat_radius`.
|
||||
/// - Aerosol injection in `aerosol_radius` around center.
|
||||
///
|
||||
/// Returns tiles affected (crater + scorch disk for T5, 1 for T1-T4).
|
||||
/// Resource/wonder-anchor bits from GDScript are Game-3 deferred, matching `apply_volcanic`.
|
||||
pub fn apply_impact(
|
||||
grid: &mut mc_core::grid::GridState,
|
||||
center: (i32, i32),
|
||||
tier: usize,
|
||||
crater_radius: i32,
|
||||
elevation_loss: f32,
|
||||
aerosol_strength_global: f32,
|
||||
aerosol_strength_local: f32,
|
||||
aerosol_radius: i32,
|
||||
elevation_delta: f32,
|
||||
crater_terrain: &str,
|
||||
heat_radius: i32,
|
||||
heat_delta: f32,
|
||||
) -> i32 {
|
||||
use mc_core::algorithms::hex::{axial_to_offset, hex_spiral, offset_to_axial};
|
||||
use mc_core::grid::biome_registry::{has_tag, BiomeTag};
|
||||
|
||||
if tier == 5 && crater_radius > 0 {
|
||||
// Extinction-level path
|
||||
let (cq, cr) = offset_to_axial(center.0, center.1);
|
||||
let mut affected = 0;
|
||||
// Phase 1: crater vaporisation
|
||||
for (q, r) in hex_spiral(cq, cr, crater_radius) {
|
||||
let (col, row) = axial_to_offset(q, r);
|
||||
if let Some(t) = grid.tile_mut(col, row) {
|
||||
if !has_tag(&t.biome_label_id, BiomeTag::IsWater) {
|
||||
t.biome_label_id = "desert".to_string();
|
||||
t.elevation = (t.elevation - elevation_loss).max(0.0);
|
||||
t.moisture = 0.0;
|
||||
t.quality = 1;
|
||||
affected += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Phase 2: global aerosol
|
||||
if aerosol_strength_global > 0.0 {
|
||||
for t in &mut grid.tiles {
|
||||
t.sulfate_aerosol += aerosol_strength_global;
|
||||
}
|
||||
}
|
||||
// Phase 3: biome collapse (living terrain quality loss / downgrade)
|
||||
let living: &[&str] = &[
|
||||
"forest", "jungle", "enchanted_forest", "swamp", "grassland", "plains", "boreal_forest",
|
||||
];
|
||||
let biome_kill_quality: i32 = 2; // GDScript hardcodes biome_kill_quality_loss=2 from tier_cfg default
|
||||
for t in &mut grid.tiles {
|
||||
if living.iter().any(|b| *b == t.biome_label_id.as_str()) {
|
||||
t.quality = (t.quality - biome_kill_quality).max(1);
|
||||
match t.biome_label_id.as_str() {
|
||||
"jungle" => {
|
||||
t.biome_label_id = "grassland".to_string();
|
||||
t.moisture = (t.moisture - 0.15).max(0.0);
|
||||
}
|
||||
"enchanted_forest" => {
|
||||
t.biome_label_id = "forest".to_string();
|
||||
}
|
||||
"swamp" => {
|
||||
t.biome_label_id = "desert".to_string();
|
||||
t.moisture = 0.0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Impact site mana node (anchor/resource Game-3 deferred, matching volcanic)
|
||||
if let Some(t) = grid.tile_mut(center.0, center.1) {
|
||||
t.biome_label_id = "mana_node".to_string();
|
||||
t.quality = 5;
|
||||
}
|
||||
return affected;
|
||||
}
|
||||
|
||||
// T1-T4 standard path
|
||||
let (cq, cr) = offset_to_axial(center.0, center.1);
|
||||
|
||||
// Center crater
|
||||
if let Some(t) = grid.tile_mut(center.0, center.1) {
|
||||
if elevation_delta > 0.0 {
|
||||
t.biome_label_id = crater_terrain.to_string();
|
||||
t.elevation = (t.elevation + elevation_delta).min(1.0);
|
||||
t.moisture = (t.moisture - 0.1).max(0.0);
|
||||
t.quality = 3;
|
||||
} else {
|
||||
t.biome_label_id = if t.elevation < 0.15 { "lake" } else { "desert" }.to_string();
|
||||
t.elevation = (t.elevation + elevation_delta).max(0.0);
|
||||
t.quality = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Heat pulse
|
||||
if heat_radius > 0 && heat_delta > 0.0 {
|
||||
for (q, r) in hex_spiral(cq, cr, heat_radius) {
|
||||
let (col, row) = axial_to_offset(q, r);
|
||||
if let Some(t) = grid.tile_mut(col, row) {
|
||||
t.magic_heat_delta += heat_delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Local aerosol injection
|
||||
if aerosol_strength_local > 0.0 && aerosol_radius > 0 {
|
||||
for (q, r) in hex_spiral(cq, cr, aerosol_radius) {
|
||||
let (col, row) = axial_to_offset(q, r);
|
||||
if let Some(t) = grid.tile_mut(col, row) {
|
||||
t.sulfate_aerosol += aerosol_strength_local;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
|
||||
}
|
||||
|
||||
/// Resolve the impact tier config + pick any tile (including water, per GDScript which
|
||||
/// uses `rng.randi_range(0, w-1)` — not a land-only pick), then apply.
|
||||
fn dispatch_impact(
|
||||
grid: &mut mc_core::grid::GridState,
|
||||
cfg: &EventCategoryConfig,
|
||||
tier: usize,
|
||||
turn_seed: f64,
|
||||
channel: f64,
|
||||
) -> Option<FiredEvent> {
|
||||
let tc = cfg.raw.get("tiers").and_then(|t| t.get(tier.to_string()))?;
|
||||
|
||||
// GDScript picks any tile (randi_range over full grid), including water/ocean.
|
||||
// We use pick_matching_tile with an always-true predicate for internal determinism.
|
||||
let center = pick_matching_tile(grid, channel, turn_seed, |_b| true)?;
|
||||
|
||||
let crater_radius = tc.get("crater_radius").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
|
||||
let elevation_loss = tc.get("elevation_loss").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
|
||||
let aerosol_strength = tc.get("aerosol_strength").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
|
||||
let aerosol_radius = tc.get("aerosol_radius").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
|
||||
let elevation_delta = tc.get("elevation_delta").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
|
||||
let crater_terrain = tc.get("crater_terrain").and_then(|v| v.as_str()).unwrap_or("mountains");
|
||||
let heat_radius = tc.get("heat_radius").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
|
||||
let heat_delta = tc.get("heat_delta").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
|
||||
|
||||
// For T5, aerosol is global (aerosol_global: true in JSON); for T1-T4 it's local.
|
||||
let is_global_aerosol = tier == 5 && crater_radius > 0;
|
||||
let (global_aero, local_aero) = if is_global_aerosol {
|
||||
(aerosol_strength, 0.0_f32)
|
||||
} else {
|
||||
(0.0_f32, aerosol_strength)
|
||||
};
|
||||
|
||||
let affected = apply_impact(
|
||||
grid, center, tier,
|
||||
crater_radius, elevation_loss,
|
||||
global_aero, local_aero, aerosol_radius,
|
||||
elevation_delta, crater_terrain,
|
||||
heat_radius, heat_delta,
|
||||
);
|
||||
Some(FiredEvent {
|
||||
category: "impact".to_string(),
|
||||
tier,
|
||||
center,
|
||||
affected,
|
||||
})
|
||||
}
|
||||
|
||||
// ── tsunami ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Apply a tsunami: floods non-water tiles within `inland_reach` of a coast center.
|
||||
/// Each non-water tile: moisture += `moisture_gain` (clamped to 1.0), quality set to
|
||||
/// `quality_reset` (if > 0, clamped to max(1, quality_reset)). Water tiles with
|
||||
/// `reef_destruction=true` lose 0.5 reef_health. Returns flooded tile count.
|
||||
/// Mirrors GDScript `process_tsunami` (building/wall/unit damage is event-log-only).
|
||||
pub fn apply_tsunami(
|
||||
grid: &mut mc_core::grid::GridState,
|
||||
center: (i32, i32),
|
||||
inland_reach: i32,
|
||||
moisture_gain: f32,
|
||||
quality_reset: i32,
|
||||
reef_destruction: bool,
|
||||
) -> i32 {
|
||||
use mc_core::algorithms::hex::{axial_to_offset, hex_spiral, offset_to_axial};
|
||||
use mc_core::grid::biome_registry::{has_tag, BiomeTag};
|
||||
let (cq, cr) = offset_to_axial(center.0, center.1);
|
||||
let mut flooded = 0;
|
||||
for (q, r) in hex_spiral(cq, cr, inland_reach) {
|
||||
let (col, row) = axial_to_offset(q, r);
|
||||
if let Some(t) = grid.tile_mut(col, row) {
|
||||
if has_tag(&t.biome_label_id, BiomeTag::IsWater) {
|
||||
if reef_destruction {
|
||||
t.reef_health = (t.reef_health - 0.5).max(0.0);
|
||||
}
|
||||
} else {
|
||||
if moisture_gain > 0.0 {
|
||||
t.moisture = (t.moisture + moisture_gain).min(1.0);
|
||||
}
|
||||
if quality_reset > 0 {
|
||||
t.quality = quality_reset.max(1);
|
||||
}
|
||||
flooded += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
flooded
|
||||
}
|
||||
|
||||
/// Resolve the tsunami tier config + pick a coast center, then flood inland.
|
||||
/// GDScript requires the center tile to have `is_coast` tag; non-coast picks return early.
|
||||
fn dispatch_tsunami(
|
||||
grid: &mut mc_core::grid::GridState,
|
||||
cfg: &EventCategoryConfig,
|
||||
tier: usize,
|
||||
turn_seed: f64,
|
||||
channel: f64,
|
||||
) -> Option<FiredEvent> {
|
||||
use mc_core::grid::biome_registry::{has_tag, BiomeTag};
|
||||
let tc = cfg.raw.get("tiers").and_then(|t| t.get(tier.to_string()))?;
|
||||
let inland_reach = tc.get("inland_reach").and_then(|v| v.as_i64()).unwrap_or(1) as i32;
|
||||
let moisture_gain = tc.get("moisture_gain").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
|
||||
let quality_reset = tc.get("quality_reset").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
|
||||
let reef_destruction = tc.get("reef_destruction").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
// Must land on a coast tile (GDScript: `if not BiomeRegistry.has_tag(center, "is_coast"): return`).
|
||||
let center = pick_matching_tile(grid, channel, turn_seed, |b| has_tag(b, BiomeTag::IsCoast))?;
|
||||
|
||||
let affected = apply_tsunami(grid, center, inland_reach, moisture_gain, quality_reset, reef_destruction);
|
||||
if affected == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(FiredEvent {
|
||||
category: "tsunami".to_string(),
|
||||
tier,
|
||||
center,
|
||||
affected,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -605,4 +915,222 @@ mod tests {
|
|||
assert!(!cfgs.contains_key("cross_triggers"));
|
||||
assert!(!cfgs.contains_key("events"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_seismic_shifts_elevation_with_falloff() {
|
||||
use mc_core::grid::GridState;
|
||||
let mut grid = GridState::new(12, 12);
|
||||
for t in &mut grid.tiles {
|
||||
t.biome_label_id = "grassland".into();
|
||||
t.elevation = 0.5;
|
||||
}
|
||||
let affected = apply_seismic(&mut grid, (5, 5), 2, 0.1);
|
||||
assert!(affected >= 1, "should affect tiles in radius");
|
||||
// Center tile (dist=0) gets full delta (falloff = 1 - 0/3 = 1.0).
|
||||
let center = grid.tile(5, 5).unwrap();
|
||||
assert!(
|
||||
(center.elevation - 0.6).abs() < 1e-4,
|
||||
"center elevation should be 0.5 + 0.1*1.0 = 0.6, got {}",
|
||||
center.elevation
|
||||
);
|
||||
// A tile at distance=2 gets partial shift (falloff = 1 - 2/3 ≈ 0.333).
|
||||
// We just verify the total shift is less than at center.
|
||||
// (Exact neighbor depends on hex offset math; just verify bounds.)
|
||||
assert!(
|
||||
grid.tiles.iter().any(|t| t.elevation > 0.5 && t.elevation < 0.6),
|
||||
"some tile should have partial elevation shift from falloff"
|
||||
);
|
||||
// Elevation is clamped to [0, 1].
|
||||
let mut grid2 = GridState::new(6, 6);
|
||||
for t in &mut grid2.tiles {
|
||||
t.biome_label_id = "grassland".into();
|
||||
t.elevation = 0.99;
|
||||
}
|
||||
apply_seismic(&mut grid2, (3, 3), 1, 0.5);
|
||||
assert!(
|
||||
grid2.tiles.iter().all(|t| t.elevation <= 1.0),
|
||||
"elevation must not exceed 1.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_seismic_deterministic() {
|
||||
use mc_core::grid::GridState;
|
||||
let make = || {
|
||||
let mut g = GridState::new(10, 10);
|
||||
for t in &mut g.tiles {
|
||||
t.biome_label_id = "grassland".into();
|
||||
t.elevation = 0.5;
|
||||
}
|
||||
g
|
||||
};
|
||||
let mut g1 = make();
|
||||
let mut g2 = make();
|
||||
apply_seismic(&mut g1, (4, 4), 2, 0.05);
|
||||
apply_seismic(&mut g2, (4, 4), 2, 0.05);
|
||||
let elevations1: Vec<f32> = g1.tiles.iter().map(|t| t.elevation).collect();
|
||||
let elevations2: Vec<f32> = g2.tiles.iter().map(|t| t.elevation).collect();
|
||||
assert_eq!(elevations1, elevations2, "seismic must be deterministic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_impact_t1_t4_modifies_center_and_heat() {
|
||||
use mc_core::grid::GridState;
|
||||
let mut grid = GridState::new(12, 12);
|
||||
for t in &mut grid.tiles {
|
||||
t.biome_label_id = "grassland".into();
|
||||
t.elevation = 0.5;
|
||||
t.magic_heat_delta = 0.0;
|
||||
}
|
||||
// T1: elevation_delta < 0 → center becomes desert, elevation falls.
|
||||
let affected = apply_impact(
|
||||
&mut grid, (5, 5),
|
||||
/*tier=*/1,
|
||||
/*crater_radius=*/0, /*elevation_loss=*/0.0,
|
||||
/*global_aero=*/0.0, /*local_aero=*/0.0, /*aerosol_radius=*/0,
|
||||
/*elevation_delta=*/-0.1,
|
||||
/*crater_terrain=*/"mountains",
|
||||
/*heat_radius=*/1, /*heat_delta=*/0.02,
|
||||
);
|
||||
assert_eq!(affected, 1);
|
||||
let center = grid.tile(5, 5).unwrap();
|
||||
assert_eq!(center.biome_label_id, "desert", "low elev_delta → desert");
|
||||
assert!((center.elevation - 0.4).abs() < 1e-4, "elevation lowered");
|
||||
assert_eq!(center.quality, 1);
|
||||
// heat pulse injected within heat_radius
|
||||
assert!(
|
||||
grid.tiles.iter().any(|t| t.magic_heat_delta > 0.0),
|
||||
"heat pulse must propagate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_impact_t5_extinction_craters_and_injects_global_aerosol() {
|
||||
use mc_core::grid::GridState;
|
||||
let mut grid = GridState::new(16, 16);
|
||||
for t in &mut grid.tiles {
|
||||
t.biome_label_id = "jungle".into();
|
||||
t.elevation = 0.5;
|
||||
t.moisture = 0.8;
|
||||
t.quality = 3;
|
||||
t.sulfate_aerosol = 0.0;
|
||||
}
|
||||
let affected = apply_impact(
|
||||
&mut grid, (7, 7),
|
||||
/*tier=*/5,
|
||||
/*crater_radius=*/2, /*elevation_loss=*/0.3,
|
||||
/*global_aero=*/1.0, /*local_aero=*/0.0, /*aerosol_radius=*/0,
|
||||
/*elevation_delta=*/0.0,
|
||||
/*crater_terrain=*/"mountains",
|
||||
/*heat_radius=*/0, /*heat_delta=*/0.0,
|
||||
);
|
||||
assert!(affected >= 1, "crater must affect some land");
|
||||
// Global aerosol on every tile
|
||||
assert!(
|
||||
grid.tiles.iter().all(|t| t.sulfate_aerosol >= 1.0),
|
||||
"global aerosol must reach every tile"
|
||||
);
|
||||
// Biome collapse: jungle → grassland
|
||||
assert!(
|
||||
grid.tiles.iter().any(|t| t.biome_label_id == "grassland"),
|
||||
"jungle must collapse to grassland"
|
||||
);
|
||||
// Impact site → mana_node
|
||||
assert_eq!(
|
||||
grid.tile(7, 7).unwrap().biome_label_id,
|
||||
"mana_node",
|
||||
"T5 impact site → mana_node"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_tsunami_floods_land_skips_water() {
|
||||
use mc_core::grid::GridState;
|
||||
let mut grid = GridState::new(12, 12);
|
||||
for t in &mut grid.tiles {
|
||||
t.biome_label_id = "grassland".into();
|
||||
t.moisture = 0.3;
|
||||
t.quality = 3;
|
||||
t.reef_health = 1.0;
|
||||
}
|
||||
// Place coast center + an ocean tile in radius
|
||||
if let Some(t) = grid.tile_mut(5, 5) {
|
||||
t.biome_label_id = "coast".into();
|
||||
}
|
||||
if let Some(t) = grid.tile_mut(6, 5) {
|
||||
t.biome_label_id = "ocean".into();
|
||||
t.reef_health = 1.0;
|
||||
}
|
||||
let flooded = apply_tsunami(&mut grid, (5, 5), 2, 0.2, 1, true);
|
||||
assert!(flooded >= 1, "should flood some land tiles");
|
||||
// Land tile in radius should have boosted moisture
|
||||
let land = grid.tile(4, 5).unwrap();
|
||||
assert!(land.moisture > 0.3, "inland tile moisture boosted");
|
||||
// quality_reset = 1 applied to land tiles
|
||||
assert_eq!(land.quality, 1, "quality reset to 1");
|
||||
// water tile reef_health decreased (reef_destruction=true)
|
||||
let ocean = grid.tile(6, 5).unwrap();
|
||||
assert!((ocean.reef_health - 0.5).abs() < 1e-4, "reef health reduced by 0.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_seismic_fires_via_process_events() {
|
||||
use mc_core::grid::GridState;
|
||||
let mut grid = GridState::new(10, 10);
|
||||
for t in &mut grid.tiles {
|
||||
t.biome_label_id = "grassland".into();
|
||||
t.elevation = 0.5;
|
||||
}
|
||||
let mut configs = std::collections::BTreeMap::new();
|
||||
configs.insert(
|
||||
"seismic".to_string(),
|
||||
EventCategoryConfig {
|
||||
base_frequency: 1.0,
|
||||
severity_weights: vec![100],
|
||||
raw: serde_json::json!({
|
||||
"tiers": { "1": { "radius": 2, "elevation_delta": 0.05 } }
|
||||
}),
|
||||
},
|
||||
);
|
||||
let fired = process_events(&mut grid, &configs, 3, 42, 10);
|
||||
assert_eq!(fired.len(), 1);
|
||||
assert_eq!(fired[0].category, "seismic");
|
||||
assert!(grid.tiles.iter().any(|t| (t.elevation - 0.5).abs() > 1e-5),
|
||||
"elevation must have changed after seismic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_tsunami_requires_coast_center() {
|
||||
use mc_core::grid::GridState;
|
||||
// Grid with no coast tiles → tsunami should not fire (None from pick_matching_tile).
|
||||
let mut grid = GridState::new(8, 8);
|
||||
for t in &mut grid.tiles {
|
||||
t.biome_label_id = "grassland".into();
|
||||
}
|
||||
let mut configs = std::collections::BTreeMap::new();
|
||||
configs.insert(
|
||||
"tsunami".to_string(),
|
||||
EventCategoryConfig {
|
||||
base_frequency: 1.0,
|
||||
severity_weights: vec![100],
|
||||
raw: serde_json::json!({
|
||||
"tiers": { "1": { "inland_reach": 1 } }
|
||||
}),
|
||||
},
|
||||
);
|
||||
let fired = process_events(&mut grid, &configs, 1, 1, 10);
|
||||
assert!(fired.is_empty(), "tsunami must not fire without a coast tile");
|
||||
|
||||
// Grid with at least one coast tile + surrounding land → fires.
|
||||
let mut grid2 = GridState::new(8, 8);
|
||||
for t in &mut grid2.tiles {
|
||||
t.biome_label_id = "grassland".into();
|
||||
}
|
||||
if let Some(t) = grid2.tile_mut(4, 4) {
|
||||
t.biome_label_id = "coast".into();
|
||||
}
|
||||
let fired2 = process_events(&mut grid2, &configs, 1, 1, 10);
|
||||
assert_eq!(fired2.len(), 1, "tsunami should fire when coast tile exists");
|
||||
assert_eq!(fired2[0].category, "tsunami");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 94affd5ad32e852882e487100a9fb0dae3da7b84
|
||||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 474160ef178b5c526b0f3526fb0a9485046ad624
|
||||
Loading…
Add table
Reference in a new issue