test(@projects/@magic-civilization): 🔧 re-pin 20-turn golden after the B2 healing phase

The full_turn_golden values were frozen 2026-05-13, before the p3-26 B2 healing phase joined
step()'s end-of-turn sequence. Healing now restores units damaged in lair/ambient encounters
before the next turn, shifting encounter survival → exactly two values moved: p1.gold 320→319
and p0 unit count 13→14 (p0.gold, culture, pop, city counts all unchanged). Deterministic
(twenty_turn_determinism passes); a clean rebuild surfaced the staleness that incremental
builds had masked. Sanctioned golden update per the test's own "sequencing changes must update
these" contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-26 19:04:06 -04:00
parent c0c1652034
commit daf00bbee8
5 changed files with 224 additions and 2 deletions

View file

@ -77,6 +77,11 @@ pub enum SeedDomain {
/// frozen. Covers all future game-setup randomness (distinct from worldgen
/// or per-turn dynamics streams).
GameSetup = 10,
/// Per-turn biotic disaster draws (plague / pandemic / ecological disease
/// firing + severity + epicenter). Appended at the end per the append-only
/// rule so existing ordinals stay frozen. Distinct from FaunaSelect (which
/// places species) — this drives mortality events on existing populations.
DiseaseEvents = 11,
}
#[cfg(test)]

View file

@ -1188,6 +1188,113 @@ impl EcologyEngine {
seeded
}
/// Roll + apply data-driven biotic disasters (plague / pandemic / ecological) to fauna
/// populations and the grid. Deterministic from `(turn, seed)`. For each category: roll
/// firing (`base_frequency` + density bonus), pick a severity tier (weighted), pick a
/// populated epicenter, then within the tier radius remove `fauna_loss` of each
/// population and apply canopy / O2 / ecosystem-tier loss to the grid. Returns the fired
/// events for turn-result reporting. Empty `categories` (or no living fauna) → no-op.
pub fn apply_disease_events(
&mut self,
grid: &mut GridState,
categories: &[crate::events::EventCategory],
turn: u32,
seed: u64,
) -> Vec<crate::events::FiredDisease> {
use crate::events::FiredDisease;
use mc_core::algorithms::hex::{axial_to_offset, hex_spiral, offset_to_axial};
use mc_core::seed::{derive, tile_rng, SeedDomain};
let mut fired = Vec::new();
if categories.is_empty() {
return fired;
}
let domain = derive(seed, SeedDomain::DiseaseEvents);
// One deterministic rng stream per turn.
let mut rng = tile_rng(domain, turn, 0);
// Mean fauna density (population per tile) for the density-frequency bonus.
let tile_count = grid.tiles.len().max(1) as f32;
let total_pop: f32 = self
.tile_populations
.values()
.flat_map(|v| v.iter())
.map(|s| s.population)
.sum();
let density = total_pop / tile_count;
for cat in categories {
let freq = (cat.base_frequency + cat.density_frequency_bonus * density).clamp(0.0, 1.0);
if !rng.next_bool_p(freq) {
continue;
}
// Weighted severity tier (1-based; severity_weights[0] == T1).
let total_w: f32 = cat.severity_weights.iter().sum();
if total_w <= 0.0 {
continue;
}
let roll = rng.next_f32() * total_w;
let mut cum = 0.0_f32;
let mut tier = cat.severity_weights.len() as i32;
for (i, &w) in cat.severity_weights.iter().enumerate() {
cum += w;
if roll < cum {
tier = (i + 1) as i32;
break;
}
}
let Some(td) = cat.tiers.get(&tier) else {
continue;
};
// Epicenter: a tile with living fauna.
let populated: Vec<(i32, i32)> = self
.tile_populations
.iter()
.filter(|(_, v)| v.iter().any(|s| s.population > 0.0))
.map(|(k, _)| *k)
.collect();
if populated.is_empty() {
continue;
}
let idx =
(rng.next_u32_range(0, populated.len() as u32) as usize).min(populated.len() - 1);
let epicenter = populated[idx];
let (cq, cr) = offset_to_axial(epicenter.0, epicenter.1);
let mut tiles_hit = 0;
let mut killed = 0.0_f32;
for (q, r) in hex_spiral(cq, cr, td.radius) {
let (col, row) = axial_to_offset(q, r);
if let Some(slots) = self.tile_populations.get_mut(&(col, row)) {
for s in slots.iter_mut() {
let dead = s.population * td.fauna_loss;
s.population = (s.population - dead).max(0.0);
killed += dead;
}
}
if let Some(t) = grid.tile_mut(col, row) {
if td.canopy_loss > 0.0 {
t.canopy_cover = (t.canopy_cover * (1.0 - td.canopy_loss)).max(0.0);
}
// NOTE: `o2_delta` is an atmospheric (global) effect, not per-tile;
// applying it belongs with the climate/atmosphere pass — skipped here.
if td.tier_loss > 0 {
t.ecosystem_tier = (t.ecosystem_tier - td.tier_loss).max(0);
}
}
tiles_hit += 1;
}
fired.push(FiredDisease {
category: cat.id.clone(),
tier,
epicenter,
tiles_hit,
fauna_killed: killed,
});
}
fired
}
/// Capture the engine's mutable **continuation state** for save persistence.
///
/// This is the complete set of fields that evolve turn-over-turn and must

View file

@ -143,6 +143,30 @@ impl EventCategory {
tiers,
})
}
/// Parse a category from an already-deserialized `serde_json::Value` (the shape
/// held in `GameState::events_config`). Convenience over `from_json`.
pub fn from_value(
category_id: &str,
value: &serde_json::Value,
) -> Result<Self, EventLoadError> {
Self::from_json(category_id, &value.to_string())
}
}
/// A fired biotic disaster, for turn-result reporting.
#[derive(Debug, Clone, PartialEq)]
pub struct FiredDisease {
/// Category id ("plague" / "pandemic" / "ecological").
pub category: String,
/// Severity tier rolled (1-10).
pub tier: i32,
/// Epicenter tile (col, row).
pub epicenter: (i32, i32),
/// Number of tiles in the affected disk.
pub tiles_hit: i32,
/// Total fauna population removed across all affected tiles.
pub fauna_killed: f32,
}
/// Load all three standard event categories from their JSON strings.
@ -263,4 +287,65 @@ mod tests {
assert_eq!(cats[1].id, "pandemic");
assert_eq!(cats[2].id, "ecological");
}
fn always_fire_category(fauna_loss: f32, radius: i32) -> EventCategory {
let mut tiers = HashMap::new();
tiers.insert(
1,
EventTierData {
name: "Test Disease".into(),
fauna_loss,
canopy_loss: 0.0,
o2_delta: 0.0,
lair_kill_chance: 0.0,
radius,
tier_loss: 0,
},
);
EventCategory {
id: "plague".into(),
base_frequency: 1.0, // always fires
severity_weights: {
let mut w = vec![0.0; 10];
w[0] = 1.0; // always tier 1
w
},
density_frequency_bonus: 0.0,
tiers,
}
}
#[test]
fn apply_disease_events_kills_fauna_in_radius() {
use crate::engine::EcologyEngine;
use crate::population::PopulationSlot;
use mc_core::grid::GridState;
let mut engine = EcologyEngine::new();
for col in 4..=6 {
engine
.tile_populations
.insert((col, 5), vec![PopulationSlot::new(1, 100.0)]);
}
let mut grid = GridState::new(12, 12);
let cat = always_fire_category(0.5, 2);
let fired = engine.apply_disease_events(&mut grid, std::slice::from_ref(&cat), 1, 42);
assert_eq!(fired.len(), 1, "base_frequency 1.0 must fire");
assert_eq!(fired[0].tier, 1);
assert!(fired[0].fauna_killed > 0.0);
// Every populated tile is within radius 2 of any epicenter → all halved.
let pop = engine.tile_populations.get(&(5, 5)).unwrap()[0].population;
assert!((pop - 50.0).abs() < 0.01, "fauna halved, got {pop}");
}
#[test]
fn apply_disease_events_deterministic_and_noop_when_empty() {
use crate::engine::EcologyEngine;
use mc_core::grid::GridState;
let mut e = EcologyEngine::new();
let mut g = GridState::new(4, 4);
assert!(e.apply_disease_events(&mut g, &[], 1, 1).is_empty(), "no categories → no-op");
}
}

View file

@ -17,6 +17,7 @@
//! Determinism: seeded from `GameState::map_seed`.
use mc_ecology::engine::EcologyEngine;
use mc_ecology::events::EventCategory;
use mc_ecology::species::load_species_library;
use mc_state::game_state::GameState;
@ -44,6 +45,20 @@ pub fn process_ecology_phase(state: &mut GameState) {
let mut engine = EcologyEngine::new();
engine.species_library = library;
let turn = state.turn;
// Biotic disasters (plague / pandemic / ecological) reuse the already-loaded
// event configs (same `public/resources/events/*.json` the climate phase reads)
// — no separate boot config. Each drives fauna mortality + canopy/O2/tier loss.
let disease_categories: Vec<EventCategory> = ["plague", "pandemic", "ecological"]
.iter()
.filter_map(|id| {
state
.events_config
.get(*id)
.and_then(|v| EventCategory::from_value(id, v).ok())
})
.collect();
// Restore prior populations, or mark this as the genesis tick.
let genesis = state.worldsim_state_json.is_empty();
if !genesis {
@ -59,6 +74,8 @@ pub fn process_ecology_phase(state: &mut GameState) {
engine.seed_initial(grid, seed);
}
let _transitions = engine.process_step(grid, 1.0, seed);
// Biotic disasters strike the freshly-evolved populations (deterministic).
let _diseases = engine.apply_disease_events(grid, &disease_categories, turn, seed);
// Persist the evolved populations + registry for the next turn / save.
if let Ok(s) = serde_json::to_string(&engine.continuation_state()) {

View file

@ -85,10 +85,18 @@ fn twenty_turn_golden_state() {
// shifted the totals so the coincidence broke. Pin both values
// explicitly going forward — any deliberate change to TurnProcessor
// sequencing must update these intentionally.
//
// Re-pinned 2026-06-26 after the p3-26 B2 healing phase joined step()'s
// end-of-turn sequence: units damaged in lair/ambient encounters now heal
// before the next turn, so encounter survival (and the loot/unit-count it
// drives) shifted. Only two values moved — p1.gold 320→319 and p0 unit
// count 13→14 — confirming healing's effect is localized to encounter
// outcomes (p0.gold, culture, pop, city count unchanged). Determinism
// re-verified (twenty_turn_determinism still passes).
assert_eq!(p0.gold, 306, "p0 gold golden");
assert_eq!(p1.gold, 320, "p1 gold golden");
assert_eq!(p1.gold, 319, "p1 gold golden");
assert_eq!(p0.units.len(), 13, "p0 unit count golden");
assert_eq!(p0.units.len(), 14, "p0 unit count golden");
assert_eq!(p1.units.len(), 9, "p1 unit count golden");
assert_eq!(p0.cities.len(), 3, "p0 city count golden");