feat(@projects/@magic-civilization): 🌪️ p3-26 gap 1 (cont.) — weather + climate effects (unit HP) in the headless turn

Extends the headless climate phase from physics-only to the full per-turn chain mirroring
the live game's _process_climate (climate → weather → effects):

- process_climate_phase now: ClimatePhysics::process_step → weather::derive_events
  (storms/heat-waves/blizzards, default thresholds = live GdWeatherPhysics) →
  apply_climate_effects.
- apply_climate_effects (extracted, testable): runs climate_effects::apply (tile effects +
  per-unit hp_loss) then fans hp_loss onto MapUnit.hp as max(0, hp - hp_loss) — exactly
  climate_effects.gd. movement_penalty surfaced but not applied to units (matches live).

Tests: apply_climate_effects_fans_hp_loss_onto_units (deterministic — unit in heat-wave
radius loses HP, unit outside unharmed) + the determinism test; mc-turn 337/0, no
regression. Gap 1 remaining: marine_harvest (ocean_dead_fraction → climate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-26 10:25:33 -04:00
parent e9409f22cd
commit 1bdad8e497
4 changed files with 117 additions and 6 deletions

View file

@ -25,7 +25,7 @@ expansion, tech/science, fauna encounters, combat/siege, diplomacy. Verified liv
## Acceptance (sequenced; each gap closed in bounded, cargo+e2e-verified increments)
- [ ] **Gap 1 — Climate / environment runtime.** Port the live per-turn chain
- [~] **Gap 1 — Climate / environment runtime.** STARTED 2026-06-26: `mc-turn::process_climate_phase` now ticks `ClimatePhysics::process_step` + `weather::derive_events` + `climate_effects::apply` (tile effects + unit HP loss, mirroring `climate_effects.gd`: `hp=max(0,hp-loss)`) each round on `state.grid`. Tests: `climate_phase_ticks_grid_deterministically`, `apply_climate_effects_fans_hp_loss_onto_units`; mc-turn 337/0. **Remaining:** `marine_harvest` (ocean_dead_fraction feeding climate). ORIG SPEC: Port the live per-turn chain
`marine_harvest → climate(ecology+climate physics) → weather → climate_effects`
(`turn_processor.gd::_process_climate`) into a `mc-turn` climate phase. The PHYSICS is
already Rust (`mc-climate`: `physics.process_step`, `climate_effects::apply`,

View file

@ -1,12 +1,12 @@
{
"generated_at": "2026-06-26T12:25:01Z",
"generated_at": "2026-06-26T14:25:33Z",
"totals": {
"missing": 0,
"partial": 3,
"oos": 31,
"in_progress": 0,
"partial": 3,
"stub": 0,
"done": 296,
"oos": 31,
"missing": 0,
"total": 330
},
"objectives": [

View file

@ -2043,6 +2043,7 @@ dependencies = [
"mc-ai",
"mc-city",
"mc-civics",
"mc-climate",
"mc-combat",
"mc-comms",
"mc-core",

View file

@ -1023,10 +1023,70 @@ impl TurnProcessor {
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() {
if state.grid.is_none() {
return;
}
// 1. Climate physics: evolve temperature / aerosol / precipitation on the grid.
{
let grid = state.grid.as_mut().unwrap();
let mut climate = mc_climate::physics::ClimatePhysics::new("{}", "[]", "{}");
climate.process_step(grid, turn, seed, 1.0);
}
// 2. Derive this round's weather events (storms / heat waves / blizzards) from
// the updated grid. Default thresholds match the live `GdWeatherPhysics`.
let events = mc_climate::weather::derive_events(
state.grid.as_ref().unwrap(),
&mc_climate::weather::WeatherThresholds::default(),
turn as i32,
seed,
);
if events.is_empty() {
return;
}
// 3. Apply tile + unit effects from this round's weather.
Self::apply_climate_effects(state, &events);
}
/// p3-26 gap 1: apply a round's weather events to the grid + fan per-unit HP loss
/// onto the units. Split out of `process_climate_phase` so the unit-effect wiring is
/// testable without the physics/RNG. Mirrors `climate_effects.gd`: `hp = max(0, hp -
/// hp_loss)`; `movement_penalty` is surfaced in the result but not applied to units
/// (the live game doesn't apply it either).
fn apply_climate_effects(
state: &mut GameState,
events: &[mc_climate::weather::WeatherEvent],
) {
if events.is_empty() || state.grid.is_none() {
return;
}
let units: Vec<mc_climate::climate_effects::UnitInput> = state
.players
.iter()
.flat_map(|p| p.units.iter())
.map(|u| mc_climate::climate_effects::UnitInput {
id: u.id as i64,
q: u.col,
r: u.row,
})
.collect();
let result =
mc_climate::climate_effects::apply(state.grid.as_mut().unwrap(), events, &units);
if result.unit_effects.is_empty() {
return;
}
let losses: std::collections::HashMap<i64, i32> = result
.unit_effects
.iter()
.filter(|e| e.hp_loss > 0)
.map(|e| (e.id, e.hp_loss))
.collect();
for p in &mut state.players {
for u in &mut p.units {
if let Some(&loss) = losses.get(&(u.id as i64)) {
u.hp = (u.hp - loss).max(0);
}
}
}
}
fn process_culture(&self, state: &mut GameState, pi: usize) {
@ -5854,6 +5914,56 @@ mod tests {
processor.process_climate_phase(&mut empty);
}
#[test]
fn apply_climate_effects_fans_hp_loss_onto_units() {
// p3-26 gap 1: a heat-wave event in a unit's radius reduces that unit's HP via
// the headless climate wiring; a unit outside the radius is untouched.
// Deterministic — a hand-built event, no physics/RNG.
use mc_core::grid::GridState;
let mut state = GameState::default();
state.grid = Some(GridState::new(12, 12));
let mut p = crate::game_state::PlayerState::default();
p.player_index = 0;
p.units.push(MapUnit {
id: 7,
col: 5,
row: 5,
hp: 60,
max_hp: 60,
unit_id: "warrior".into(),
..MapUnit::default()
});
p.units.push(MapUnit {
id: 8,
col: 0,
row: 0,
hp: 60,
max_hp: 60,
unit_id: "warrior".into(),
..MapUnit::default()
});
state.players.push(p);
let event = mc_climate::weather::WeatherEvent {
kind: "heat_wave".into(),
col: 5,
row: 5,
radius: 2,
severity: 1.0,
moisture_delta: -0.02,
temperature_delta: 0.02,
movement_penalty: 0.0,
unit_damage: 4,
vision_penalty: 0,
};
TurnProcessor::apply_climate_effects(&mut state, &[event]);
let u7 = state.players[0].units.iter().find(|u| u.id == 7).unwrap();
let u8 = state.players[0].units.iter().find(|u| u.id == 8).unwrap();
assert!(u7.hp < 60, "unit in the heat-wave radius must lose HP (got {})", u7.hp);
assert_eq!(u8.hp, 60, "unit outside the radius is unharmed");
}
#[test]
fn processor_is_deterministic() {
use mc_core::grid::GridState;