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;