From 00aa2ef601f6e7a7d69b5d5401e0278fd289c2e5 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 12 Apr 2026 18:01:45 -0700 Subject: [PATCH] feat(city-defense): city healing, tiered wall penalties, bombard damage function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cities heal 20 HP per turn (heal_per_turn in Rust, exposed via GDExtension, called from turn_processor after unit healing) - Wall penalty scales by tier: none=1.0, walls=0.75, castle=0.60 - Added city_bombard_damage() function: 15 * (city_str/attacker_str), [5,30] - Fixed "fortress"→"castle" references in combat_resolver.gd and combat_preview.gd - Fixed defender.city_hp→defender.hp already done in prior commit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../engine/scenes/combat/combat_preview.gd | 2 +- src/game/engine/src/autoloads/turn_manager.gd | 1 + src/game/engine/src/entities/city.gd | 6 +++ .../src/modules/combat/combat_resolver.gd | 4 +- .../src/modules/management/turn_processor.gd | 6 +++ src/simulator/api-gdext/src/lib.rs | 6 +++ src/simulator/crates/mc-city/src/city.rs | 9 ++++ src/simulator/crates/mc-combat/src/siege.rs | 49 ++++++++++++++----- 8 files changed, 69 insertions(+), 14 deletions(-) diff --git a/src/game/engine/scenes/combat/combat_preview.gd b/src/game/engine/scenes/combat/combat_preview.gd index 6a6af2fa..536ef542 100644 --- a/src/game/engine/scenes/combat/combat_preview.gd +++ b/src/game/engine/scenes/combat/combat_preview.gd @@ -248,7 +248,7 @@ func _build_context_dict() -> Dictionary: var city_has_garrison: bool = false if _defender != null and not _defender is UnitScript and _lair_defender_dict.is_empty(): city_hp = _defender.city_hp - if _defender.has_building("fortress"): + if _defender.has_building("castle"): city_wall_tier = 2 elif _defender.has_building("walls"): city_wall_tier = 1 diff --git a/src/game/engine/src/autoloads/turn_manager.gd b/src/game/engine/src/autoloads/turn_manager.gd index 22a656f1..a76776c2 100644 --- a/src/game/engine/src/autoloads/turn_manager.gd +++ b/src/game/engine/src/autoloads/turn_manager.gd @@ -199,6 +199,7 @@ func end_turn() -> void: proc._process_golden_age(player, game_map) proc._process_mana(player, game_map) proc._process_healing(player) + proc._process_city_healing(player) proc._process_improvements(player) proc._process_loot_decay() proc._process_spell_system(player) diff --git a/src/game/engine/src/entities/city.gd b/src/game/engine/src/entities/city.gd index 4b34f43f..a42a1a54 100644 --- a/src/game/engine/src/entities/city.gd +++ b/src/game/engine/src/entities/city.gd @@ -255,6 +255,12 @@ func add_production(production: float) -> void: _gd_city.call("add_production", production) +## Heal the city by the standard per-turn amount (20 HP). Skips destroyed cities. +func heal_per_turn() -> void: + if _gd_city != null: + _gd_city.call("heal_per_turn") + + ## Get food surplus (food yield - consumption). func get_food_surplus(tile_yields_json: String) -> float: if _gd_city == null: diff --git a/src/game/engine/src/modules/combat/combat_resolver.gd b/src/game/engine/src/modules/combat/combat_resolver.gd index 2271c90b..b9383d51 100644 --- a/src/game/engine/src/modules/combat/combat_resolver.gd +++ b/src/game/engine/src/modules/combat/combat_resolver.gd @@ -233,7 +233,7 @@ func _build_defender_dict( ## Build a city as a defender dict. func _build_city_dict(city: RefCounted, all_units: Array) -> Dictionary: var wall_tier: int = 0 - if city.has_building("fortress"): + if city.has_building("castle"): wall_tier = 2 elif city.has_building("walls"): wall_tier = 1 @@ -282,7 +282,7 @@ func _build_context( if defender is CityScript: city_hp = defender.hp - if defender.has_building("fortress"): + if defender.has_building("castle"): city_wall_tier = 2 elif defender.has_building("walls"): city_wall_tier = 1 diff --git a/src/game/engine/src/modules/management/turn_processor.gd b/src/game/engine/src/modules/management/turn_processor.gd index e1d2c45b..4211225a 100644 --- a/src/game/engine/src/modules/management/turn_processor.gd +++ b/src/game/engine/src/modules/management/turn_processor.gd @@ -211,6 +211,12 @@ func _process_growth(player: RefCounted) -> void: # Player c.process_growth(tile_json) +func _process_city_healing(player: RefCounted) -> void: + for city_ref: Variant in player.cities: + if city_ref is CityScript: + (city_ref as CityScript).heal_per_turn() + + func _process_healing(player: RefCounted) -> void: # Player var game_map: RefCounted = GameState.get_game_map() # GameMap if game_map == null: diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 07d043af..319f1a6b 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -1214,6 +1214,12 @@ impl GdCity { fn heal(&mut self, amount: i64) { self.inner.heal(amount.max(0) as u32); } + + /// Heal the city by the standard per-turn amount (20 HP). Skips destroyed cities. + #[func] + fn heal_per_turn(&mut self) { + self.inner.heal_per_turn(); + } } // ── Private helpers for GdCity ────────────────────────────────────────── diff --git a/src/simulator/crates/mc-city/src/city.rs b/src/simulator/crates/mc-city/src/city.rs index ea077c61..01baf719 100644 --- a/src/simulator/crates/mc-city/src/city.rs +++ b/src/simulator/crates/mc-city/src/city.rs @@ -451,6 +451,15 @@ impl City { self.hp = (self.hp + amount).min(self.max_hp); } + /// Heal the city by the standard per-turn amount (20 HP). + /// Skips destroyed cities (HP == 0). + pub fn heal_per_turn(&mut self) { + const HEAL_PER_TURN: u32 = 20; + if self.hp > 0 && self.hp < self.max_hp { + self.heal(HEAL_PER_TURN); + } + } + pub fn is_destroyed(&self) -> bool { self.hp == 0 } diff --git a/src/simulator/crates/mc-combat/src/siege.rs b/src/simulator/crates/mc-combat/src/siege.rs index 8f741dd3..3c8c1d88 100644 --- a/src/simulator/crates/mc-combat/src/siege.rs +++ b/src/simulator/crates/mc-combat/src/siege.rs @@ -11,27 +11,39 @@ pub const DEFAULT_CITY_HP: i32 = 200; /// HP added per wall tier (3 tiers: ancient walls, medieval walls, renaissance walls). pub const WALL_HP_PER_TIER: i32 = 50; -/// Melee penalty when attacking a walled city (applied as negative modifier to attacker strength). -const MELEE_WALL_PENALTY: f32 = 0.33; - /// Siege unit bonus vs city HP (applied as positive modifier to siege damage). const SIEGE_CITY_BONUS: f32 = 1.50; +/// City heals this much HP per turn (Civ5 standard). +pub const CITY_HEAL_PER_TURN: i32 = 20; + /// Ranged attacks vs city reduce city HP, not garrison HP. /// This fraction of ranged damage goes to city HP when a garrison is present. const RANGED_CITY_HP_FRACTION: f32 = 0.75; /// Compute the penalty multiplier for melee attacks against a walled city. /// Returns a value < 1.0 that the attacker's effective strength is multiplied by. -/// `wall_tier` is 0 (no walls) to 3. +/// Scales by tier: 0=1.0, 1=0.75 (walls), 2=0.60 (castle). pub fn melee_wall_penalty(wall_tier: i32) -> f32 { - if wall_tier <= 0 { - 1.0 - } else { - 1.0 - MELEE_WALL_PENALTY + match wall_tier { + 0 => 1.0, + 1 => 0.75, + _ => 0.60, // tier 2+ (castle) } } +/// Compute city bombard retaliation damage against a melee attacker. +/// `city_strength` = population * 3 + building bonuses. +/// `attacker_strength` = attacker's effective combat strength. +/// Uses a simplified damage formula: 15 * (city / attacker) clamped to [5, 30]. +pub fn city_bombard_damage(city_strength: f32, attacker_strength: f32) -> i32 { + if city_strength <= 0.0 || attacker_strength <= 0.0 { + return 0; + } + let ratio = city_strength / attacker_strength.max(1.0); + (15.0 * ratio).round().clamp(5.0, 30.0) as i32 +} + /// Compute the bonus multiplier for siege units attacking a city. /// Returns a value > 1.0 applied to the siege unit's damage against city HP. pub fn siege_city_bonus() -> f32 { @@ -76,10 +88,25 @@ mod tests { } #[test] - fn melee_penalty_with_walls() { + fn melee_penalty_scales_by_tier() { assert!((melee_wall_penalty(0) - 1.0).abs() < 0.001); - assert!((melee_wall_penalty(1) - 0.67).abs() < 0.001); - assert!((melee_wall_penalty(3) - 0.67).abs() < 0.001); + assert!((melee_wall_penalty(1) - 0.75).abs() < 0.001); + assert!((melee_wall_penalty(2) - 0.60).abs() < 0.001); + assert!((melee_wall_penalty(3) - 0.60).abs() < 0.001); // caps at tier 2 + } + + #[test] + fn city_bombard_damage_values() { + // Equal strength: 15 damage + assert_eq!(city_bombard_damage(10.0, 10.0), 15); + // City twice as strong: 30 (capped) + assert_eq!(city_bombard_damage(20.0, 10.0), 30); + // City half strength: 8 + assert_eq!(city_bombard_damage(5.0, 10.0), 8); + // Very weak city: clamped to 5 + assert_eq!(city_bombard_damage(1.0, 10.0), 5); + // Zero strength: 0 + assert_eq!(city_bombard_damage(0.0, 10.0), 0); } #[test]