feat(game-engine): Introduce siege mechanics with damage accumulation, per-turn healing, and attacked city state tracking in City entities

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-16 14:45:33 -07:00
parent 7f6e3ad6e2
commit fd0097b838
2 changed files with 56 additions and 11 deletions

View file

@ -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).

View file

@ -182,6 +182,11 @@ pub struct City {
/// Populated by GDScript at game load from JSON `effects` arrays.
#[serde(default)]
building_yields: HashMap<String, CityYields>,
/// 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<u32>,
}
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);