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:
parent
e9409f22cd
commit
1bdad8e497
4 changed files with 117 additions and 6 deletions
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
1
src/simulator/Cargo.lock
generated
1
src/simulator/Cargo.lock
generated
|
|
@ -2043,6 +2043,7 @@ dependencies = [
|
|||
"mc-ai",
|
||||
"mc-city",
|
||||
"mc-civics",
|
||||
"mc-climate",
|
||||
"mc-combat",
|
||||
"mc-comms",
|
||||
"mc-core",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue