From 7f4b69eac11c1e542c200ea6686f10579768f10e Mon Sep 17 00:00:00 2001 From: Natalie Date: Sat, 27 Jun 2026 03:44:34 -0400 Subject: [PATCH] =?UTF-8?q?fix(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20p3-26=20B2=20=E2=80=94=20siege-suppress?= =?UTF-8?q?=20city=20healing=20(besieged=20cities=20don't=20heal)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression fix: the B2 healing phase ran end-of-turn BEFORE siege and healed cities unconditionally — so a besieged city healed (e.g. 30→50 hp) before the same turn's siege resolved, defeating captures (last_survivor_via_capture got empty events: a 30-hp city + 3 attackers @15 = 45 dmg no longer captured after healing to 50). Bisected to this session (passed at e926345ad; the healing phase introduced the bug). Fix: process_healing_phase now snapshots all units' tiles and SKIPS healing any city with an enemy unit on its tile (under siege) — real-time siege-suppress matching the live game's intent (its `last_attacked_turn` window). Un-besieged cities still heal. Tests: besieged_city_does_not_heal (new) + last_survivor_via_capture (restored) + healing 11/0. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/simulator/crates/mc-turn/src/healing.rs | 62 ++++++++++++++++++++- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/src/simulator/crates/mc-turn/src/healing.rs b/src/simulator/crates/mc-turn/src/healing.rs index 1b466b8a..c626c5ac 100644 --- a/src/simulator/crates/mc-turn/src/healing.rs +++ b/src/simulator/crates/mc-turn/src/healing.rs @@ -65,7 +65,19 @@ const CITY_HEAL_PER_TURN: i32 = 20; /// This function does **not** call `step()` or any other phase — the parent /// wires the call at the correct slot in the turn sequence. pub fn process_healing_phase(state: &mut GameState) { - for player in &mut state.players { + // Snapshot every unit's (owner, tile) up front so city healing can skip + // BESIEGED cities — a city with an enemy unit on its tile is under siege and + // must NOT heal (else end-of-turn healing would undo the same turn's siege + // damage, e.g. heal a 30-hp city to 50 before a 45-dmg siege resolves). + // Mirrors the live game's siege-suppress intent via real-time occupancy. + let unit_tiles: Vec<(usize, i32, i32)> = state + .players + .iter() + .enumerate() + .flat_map(|(pi, p)| p.units.iter().map(move |u| (pi, u.col, u.row))) + .collect(); + + for (pi, player) in state.players.iter_mut().enumerate() { // Snapshot city positions for garrison detection; `player` is borrowed // mutably below for units, so we can't hold a reference to // `player.city_positions` at the same time. @@ -102,9 +114,17 @@ pub fn process_healing_phase(state: &mut GameState) { unit.hp = (unit.hp + heal_amount).min(unit.max_hp); } - // ── City healing ────────────────────────────────────────────────── - for city in &mut player.cities { + // ── City healing (siege-suppressed) ─────────────────────────────── + for (ci, city) in player.cities.iter_mut().enumerate() { if city.hp > 0 && city.hp < city.max_hp { + let besieged = player.city_positions.get(ci).copied().is_some_and(|(cc, cr)| { + unit_tiles + .iter() + .any(|&(opi, uc, ur)| opi != pi && uc == cc && ur == cr) + }); + if besieged { + continue; // under siege → no heal + } city.hp = (city.hp + CITY_HEAL_PER_TURN).min(city.max_hp); } } @@ -290,6 +310,42 @@ mod tests { ); } + /// A city with an enemy unit on its tile is under siege and must NOT heal + /// (so end-of-turn healing can't undo the same turn's siege damage). + #[test] + fn besieged_city_does_not_heal() { + let mut p0 = PlayerState { + city_positions: vec![(3, 3)], + ..PlayerState::default() + }; + let mut city = CityState::default(); + city.hp = 80; + city.max_hp = 200; + p0.cities.push(city); + + let mut p1 = PlayerState { + player_index: 1, + ..PlayerState::default() + }; + p1.units.push(MapUnit { + col: 3, + row: 3, + hp: 60, + max_hp: 60, + ..MapUnit::default() + }); + + let mut state = GameState::default(); + state.players.push(p0); + state.players.push(p1); + process_healing_phase(&mut state); + + assert_eq!( + state.players[0].cities[0].hp, 80, + "besieged city (enemy unit on tile) must not heal" + ); + } + /// A city at max HP does not overheal. #[test] fn full_hp_city_is_not_overhealed() {