diff --git a/src/simulator/crates/mc-turn/Cargo.toml b/src/simulator/crates/mc-turn/Cargo.toml index 31f47772..63f05994 100644 --- a/src/simulator/crates/mc-turn/Cargo.toml +++ b/src/simulator/crates/mc-turn/Cargo.toml @@ -19,6 +19,7 @@ mc-combat = { path = "../mc-combat" } mc-economy = { path = "../mc-economy" } mc-civics = { path = "../mc-civics" } mc-trade = { path = "../mc-trade" } +mc-climate = { path = "../mc-climate" } mc-tech = { path = "../mc-tech" } mc-replay = { path = "../mc-replay" } mc-comms = { path = "../mc-comms" } diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index b705a698..d90576eb 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -491,6 +491,13 @@ impl TurnProcessor { // when a grid with lairs exists. self.process_fauna_encounters_inner(state, &mut result, true); + // Phase 5b: climate runtime (p3-26 gap 1). Tick the climate physics once per + // round so temperature / aerosol / precipitation evolve on the live grid in the + // headless sim — previously climate ran only in the GDScript live game. The grid + // carries the climate state across turns; default config matches the ecology + // bench (`ClimatePhysics::new("{}","[]","{}")`). + self.process_climate_phase(state); + // Phase 5a-sentry: wake sentrying units that have enemies in vision range (2 hex). // Runs after movement so positions are current; runs before PvP so the // now-awoken unit's state is consistent when combat checks fire. @@ -1007,6 +1014,21 @@ impl TurnProcessor { /// claim tiles — returning `Vec` from the pool is dropped here. /// The live game calls `mc_culture::CulturePool` via `GdCulture` and reacts /// to the ready list on the GDScript side. + /// p3-26 gap 1: tick the climate physics once per round on the live grid so + /// temperature / aerosol / precipitation evolve in the headless sim. The + /// physics operates on (and persists state in) `GridState`; a fresh + /// `ClimatePhysics` per round is fine — it is the operator, the grid is the + /// state. Default config (`"{}"`/`"[]"`/`"{}"`) matches the ecology bench; + /// `dt = 1.0` = one turn. No-op when there is no grid (pure unit-test states). + fn process_climate_phase(&self, state: &mut GameState) { + let turn = state.turn; + let seed = state.map_seed; + if let Some(grid) = state.grid.as_mut() { + let mut climate = mc_climate::physics::ClimatePhysics::new("{}", "[]", "{}"); + climate.process_step(grid, turn, seed, 1.0); + } + } + fn process_culture(&self, state: &mut GameState, pi: usize) { // Grid dims read before the &mut player borrow (needed for in-bounds // candidate filtering during border expansion). @@ -5796,6 +5818,42 @@ mod tests { ); } + #[test] + fn climate_phase_ticks_grid_deterministically() { + // p3-26 gap 1: the climate physics ticks the live grid each round, and is + // deterministic for a given (start, turn, seed). No-op without a grid. + use mc_core::grid::GridState; + let build = || { + let mut state = GameState::default(); + state.turn = 3; + state.map_seed = 99; + let mut grid = GridState::new(8, 8); + for t in &mut grid.tiles { + t.biome_label_id = "temperate_grassland".into(); + t.temperature = 0.5; + t.quality = 3; + } + state.grid = Some(grid); + state + }; + let processor = TurnProcessor::new(100); + + let mut a = build(); + processor.process_climate_phase(&mut a); + let after_a: Vec = a.grid.as_ref().unwrap().tiles.iter().map(|t| t.temperature).collect(); + + let mut b = build(); + processor.process_climate_phase(&mut b); + let after_b: Vec = b.grid.as_ref().unwrap().tiles.iter().map(|t| t.temperature).collect(); + + assert_eq!(after_a, after_b, "climate tick must be deterministic for same seed+turn"); + assert_eq!(after_a.len(), 64, "all tiles processed"); + + // No grid → no panic. + let mut empty = GameState::default(); + processor.process_climate_phase(&mut empty); + } + #[test] fn processor_is_deterministic() { use mc_core::grid::GridState;