fix(@projects/@magic-civilization): 🛡️ p3-26 B2 — siege-suppress city healing (besieged cities don't heal)

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) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-27 03:44:34 -04:00
parent 91095be232
commit 7f4b69eac1

View file

@ -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() {