From e9409f22cd713bb8e17842af594520b256ff57b8 Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 26 Jun 2026 08:25:01 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=8C=A1=EF=B8=8F=20p3-26=20gap=201=20(start)=20=E2=80=94?= =?UTF-8?q?=20climate=20physics=20ticks=20in=20the=20headless=20turn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headless mc-turn ran no climate (live-game GDScript only). Now it does: - mc-turn deps mc-climate (no cycle; mc-climate is lower-level). - TurnProcessor::process_climate_phase ticks mc_climate::physics::ClimatePhysics once per round on state.grid (process_step(grid, turn, map_seed, dt=1.0)). Default config ("{}"/"[]"/"{}") matches the ecology bench; the grid carries climate state across turns; fresh-processor-per-turn is safe (physics is the operator, grid is the state). No-op without a grid. - Called in step() right after fauna (world-level end-of-round phase). First slice of gap 1 — temperature/aerosol/precipitation now evolve on the live grid in self-play. Still to come: the weather + climate_effects (unit HP) + marine_harvest chain. Verified: climate_phase_ticks_grid_deterministically (determinism + no-grid no-op); mc-turn 336/0 (no regression — climate phase runs in every step() with a grid). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/simulator/crates/mc-turn/Cargo.toml | 1 + src/simulator/crates/mc-turn/src/processor.rs | 58 +++++++++++++++++++ 2 files changed, 59 insertions(+) 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;