diff --git a/src/game/engine/src/entities/city.gd b/src/game/engine/src/entities/city.gd index 9027cbb7..2e9fbb69 100644 --- a/src/game/engine/src/entities/city.gd +++ b/src/game/engine/src/entities/city.gd @@ -306,10 +306,17 @@ func refresh_turn() -> void: has_bombarded = false -## Heal the city by the standard per-turn amount (20 HP). Skips destroyed cities. -func heal_per_turn() -> void: +## Heal the city by the standard per-turn amount (20 HP). Skips destroyed cities +## and cities attacked in the last few turns (so siege damage accumulates). +func heal_per_turn(current_turn: int) -> void: if _gd_city != null: - _gd_city.call("heal_per_turn") + _gd_city.call("heal_per_turn", current_turn) + + +## Mark the city as having taken combat damage on `turn`. Gates heal_per_turn. +func mark_attacked(turn: int) -> void: + if _gd_city != null: + _gd_city.call("mark_attacked", turn) ## Get food surplus (food yield - consumption). diff --git a/src/simulator/crates/mc-city/src/city.rs b/src/simulator/crates/mc-city/src/city.rs index 9f400a5f..1198cccd 100644 --- a/src/simulator/crates/mc-city/src/city.rs +++ b/src/simulator/crates/mc-city/src/city.rs @@ -182,6 +182,11 @@ pub struct City { /// Populated by GDScript at game load from JSON `effects` arrays. #[serde(default)] building_yields: HashMap, + + /// Last turn this city took combat damage. Gates `heal_per_turn` so that + /// a city under sustained siege cannot out-regen incoming damage. + #[serde(default)] + pub last_attacked_turn: Option, } impl Default for City { @@ -205,6 +210,7 @@ impl Default for City { buildings: Vec::new(), queues: HashMap::new(), building_yields: HashMap::new(), + last_attacked_turn: None, } } } @@ -255,6 +261,7 @@ impl City { buildings: Vec::new(), queues: HashMap::new(), building_yields: HashMap::new(), + last_attacked_turn: None, } } @@ -515,16 +522,29 @@ impl City { self.hp = (self.hp + amount).min(self.max_hp); } - /// Heal the city by the standard per-turn amount (20 HP, was 10). - /// Raised with the melee-city-damage fraction in resolver.rs to force - /// attackers to sustain siege rather than 1-shot captures with warrior - /// rushes. Further bumps to 23/26 regressed results. - /// Skips destroyed cities (HP == 0). - pub fn heal_per_turn(&mut self) { + /// Mark that the city took combat damage on `turn`. Used to gate + /// `heal_per_turn` so siege damage can accumulate across turns. + pub fn mark_attacked(&mut self, turn: u32) { + self.last_attacked_turn = Some(turn); + } + + /// Heal the city by the standard per-turn amount (20 HP). + /// Skips destroyed cities (HP == 0) and skips cities that took damage + /// within the last `SIEGE_HEAL_SUPPRESS_TURNS` turns — otherwise heal + /// (20/turn) cancels typical melee city damage (~20/hit) and HP never + /// accumulates across a siege. + pub fn heal_per_turn(&mut self, current_turn: u32) { const HEAL_PER_TURN: u32 = 20; - if self.hp > 0 && self.hp < self.max_hp { - self.heal(HEAL_PER_TURN); + const SIEGE_HEAL_SUPPRESS_TURNS: u32 = 3; + if self.hp == 0 || self.hp >= self.max_hp { + return; } + if let Some(last) = self.last_attacked_turn { + if current_turn.saturating_sub(last) < SIEGE_HEAL_SUPPRESS_TURNS { + return; + } + } + self.heal(HEAL_PER_TURN); } pub fn is_destroyed(&self) -> bool { @@ -849,6 +869,24 @@ mod tests { assert!(city.is_destroyed()); } + #[test] + fn heal_per_turn_skips_when_recently_attacked() { + let mut city = City::found("Ironhold", (5, 5), true, 1); + city.take_damage(100); + let hp_after_damage = city.hp; + // Attacked on turn 10 — heal on turns 10..=12 must be suppressed. + city.mark_attacked(10); + city.heal_per_turn(10); + assert_eq!(city.hp, hp_after_damage); + city.heal_per_turn(11); + assert_eq!(city.hp, hp_after_damage); + city.heal_per_turn(12); + assert_eq!(city.hp, hp_after_damage); + // Turn 13 (3 turns after attack) resumes healing. + city.heal_per_turn(13); + assert_eq!(city.hp, hp_after_damage + 20); + } + #[test] fn rename_city() { let mut city = City::found("Ironhold", (5, 5), true, 1);