feat(@projects/@magic-civilization): 🌡️ p3-26 gap 1 (start) — climate physics ticks in the headless turn

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) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-26 08:25:01 -04:00
parent f9593c4d29
commit e9409f22cd
2 changed files with 59 additions and 0 deletions

View file

@ -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" }

View file

@ -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<usize>` 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<f32> = 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<f32> = 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;