feat(city-defense): city healing, tiered wall penalties, bombard damage function

- 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) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-04-12 18:01:45 -07:00
parent 0dc1fa5db8
commit 00aa2ef601
8 changed files with 69 additions and 14 deletions

View file

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

View file

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

View file

@ -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:

View file

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

View file

@ -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:

View file

@ -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 ──────────────────────────────────────────

View file

@ -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
}

View file

@ -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]