From 1bdad8e49735f4c2c91cebd45ded3e6c779ad737 Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 26 Jun 2026 10:25:33 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=8C=AA=EF=B8=8F=20p3-26=20gap=201=20(cont.)=20=E2=80=94?= =?UTF-8?q?=20weather=20+=20climate=20effects=20(unit=20HP)=20in=20the=20h?= =?UTF-8?q?eadless=20turn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../p3-26-complete-headless-simulator.md | 2 +- .../games/age-of-dwarves/data/objectives.json | 8 +- src/simulator/Cargo.lock | 1 + src/simulator/crates/mc-turn/src/processor.rs | 112 +++++++++++++++++- 4 files changed, 117 insertions(+), 6 deletions(-) diff --git a/.project/objectives/p3-26-complete-headless-simulator.md b/.project/objectives/p3-26-complete-headless-simulator.md index 6d5e26a0..216d42fe 100644 --- a/.project/objectives/p3-26-complete-headless-simulator.md +++ b/.project/objectives/p3-26-complete-headless-simulator.md @@ -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`, diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json index 99613431..b6cd4d77 100644 --- a/public/games/age-of-dwarves/data/objectives.json +++ b/public/games/age-of-dwarves/data/objectives.json @@ -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": [ diff --git a/src/simulator/Cargo.lock b/src/simulator/Cargo.lock index 5f3712f7..7dd32c99 100644 --- a/src/simulator/Cargo.lock +++ b/src/simulator/Cargo.lock @@ -2043,6 +2043,7 @@ dependencies = [ "mc-ai", "mc-city", "mc-civics", + "mc-climate", "mc-combat", "mc-comms", "mc-core", diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index d90576eb..485d3a22 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -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 = 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 = 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;