feat(city-defense): population HP scaling + garrison combat strength boost

- City max_hp scales with population: base 200 + 10 per pop (pop 1 = 210)
- max_hp recalculated on growth/starvation, building bonuses preserved
- Garrison unit's attack adds to city bombard combat strength
- Renamed DEFAULT_CITY_HP → BASE_CITY_HP, added HP_PER_POP constant

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-04-12 18:22:25 -07:00
parent 9bbd80a426
commit 08abd276c5
4 changed files with 45 additions and 17 deletions

View file

@ -318,10 +318,17 @@ func _count_flanking(attacker: RefCounted, near_def: Array) -> int:
## Terrain defense bonus from the tile the unit stands on.
## Compute city bombard retaliation damage.
## City strength = population * 3 + building bombard bonuses.
## City strength = population * 3 + garrison attack + building bombard bonuses.
## Damage = 15 * (city_strength / attacker_strength), clamped [5, 30].
func _compute_city_bombard(city: RefCounted, attacker: RefCounted) -> int:
var city_str: float = float(city.population) * 3.0
# Garrison unit boosts city combat strength
var primary: Dictionary = GameState.get_primary_layer()
var garrison: RefCounted = CombatUtilsScript.get_garrison(
city.position, primary.get("units", [])
)
if garrison != null and garrison is UnitScript:
city_str += float(garrison.attack)
# Add castle bombard bonus if present
if city.has_building("castle"):
var bdata: Dictionary = DataLoader.get_building("castle")

View file

@ -102,8 +102,11 @@ impl TileYield {
/// Food consumed per citizen per turn (Civ5 standard).
pub const FOOD_PER_POP: f64 = 2.0;
/// Default city HP (Civ5: 200).
pub const DEFAULT_CITY_HP: u32 = 200;
/// Base city HP before population scaling.
pub const BASE_CITY_HP: u32 = 200;
/// HP gained per population point.
pub const HP_PER_POP: u32 = 10;
/// Civ5 growth formula: 15 + 6*(pop-1) + floor((pop-1)^1.8).
/// Returns the food threshold for the next population point.
@ -181,8 +184,8 @@ impl Default for City {
culture_stored: 0.0,
culture_expansions: 0,
owned_tiles: Vec::new(),
hp: DEFAULT_CITY_HP,
max_hp: DEFAULT_CITY_HP,
hp: BASE_CITY_HP + HP_PER_POP, // pop 1 at founding
max_hp: BASE_CITY_HP + HP_PER_POP,
focus: CityFocus::Default,
worked_tiles: Vec::new(),
buildings: Vec::new(),
@ -230,8 +233,8 @@ impl City {
culture_stored: 0.0,
culture_expansions: 0,
owned_tiles: vec![position],
hp: DEFAULT_CITY_HP,
max_hp: DEFAULT_CITY_HP,
hp: BASE_CITY_HP + HP_PER_POP, // pop 1 at founding
max_hp: BASE_CITY_HP + HP_PER_POP,
focus: CityFocus::Default,
worked_tiles: vec![position],
buildings: Vec::new(),
@ -327,6 +330,7 @@ impl City {
// Growth: population increases, surplus food carries over
self.food_stored -= self.growth_threshold();
self.population += 1;
self.recalc_max_hp();
// Clamp negative carried food to 0
if self.food_stored < 0.0 {
self.food_stored = 0.0;
@ -337,6 +341,7 @@ impl City {
if self.food_stored < 0.0 && self.population > 1 {
// Starvation: lose a citizen, reset food to 0
self.population -= 1;
self.recalc_max_hp();
self.food_stored = 0.0;
return -1;
}
@ -443,6 +448,21 @@ impl City {
// ── Defense ────────────────────────────────────────────────────
/// Adjust max_hp after population change (+10 HP per pop).
/// Building bonuses (applied via set_max_hp) are preserved as the excess
/// above the population-based value.
fn recalc_max_hp(&mut self) {
let new_base = BASE_CITY_HP + self.population * HP_PER_POP;
if new_base > self.max_hp {
// Growth: increase max and heal the difference
let delta = new_base - self.max_hp;
self.max_hp = new_base;
self.hp = (self.hp + delta).min(self.max_hp);
}
// Starvation: don't reduce max_hp below new_base (building bonuses stay)
// HP is clamped on next heal/damage
}
pub fn take_damage(&mut self, damage: u32) {
self.hp = self.hp.saturating_sub(damage);
}
@ -582,7 +602,7 @@ mod tests {
assert_eq!(city.position, (5, 5));
assert_eq!(city.population, 1);
assert_eq!(city.food_stored, 0.0);
assert_eq!(city.hp, DEFAULT_CITY_HP);
assert_eq!(city.hp, BASE_CITY_HP + HP_PER_POP); // pop 1
assert_eq!(city.owned_tiles, vec![(5, 5)]);
assert_eq!(city.worked_tiles, vec![(5, 5)]);
}
@ -743,16 +763,17 @@ mod tests {
#[test]
fn defense_mechanics() {
let mut city = City::found("Ironhold", (5, 5), true, 1);
assert_eq!(city.hp, DEFAULT_CITY_HP);
let initial_hp = BASE_CITY_HP + HP_PER_POP; // 210 at pop 1
assert_eq!(city.hp, initial_hp);
city.take_damage(50);
assert_eq!(city.hp, DEFAULT_CITY_HP - 50);
assert_eq!(city.hp, initial_hp - 50);
city.heal(20);
assert_eq!(city.hp, DEFAULT_CITY_HP - 30);
assert_eq!(city.hp, initial_hp - 30);
// Heal doesn't exceed max
city.heal(1000);
assert_eq!(city.hp, DEFAULT_CITY_HP);
assert_eq!(city.hp, initial_hp);
// Destroy
city.take_damage(DEFAULT_CITY_HP);
city.take_damage(initial_hp);
assert!(city.is_destroyed());
}

View file

@ -12,7 +12,7 @@ pub use production::{
// Re-export city types
pub use city::{
City, CityFocus, CityYields, TileYield,
FOOD_PER_POP, DEFAULT_CITY_HP,
FOOD_PER_POP, BASE_CITY_HP, HP_PER_POP,
growth_threshold, culture_expansion_threshold,
};

View file

@ -5,8 +5,8 @@
//! Siege units get a bonus against cities.
//! Capture: city HP reaches 0 AND a melee unit occupies the tile → ownership transfers.
/// Default city HP.
pub const DEFAULT_CITY_HP: i32 = 200;
/// Base city HP (before population scaling and building bonuses).
pub const BASE_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;
@ -52,7 +52,7 @@ pub fn siege_city_bonus() -> f32 {
/// Total city HP including wall tiers.
pub fn city_total_hp(wall_tier: i32) -> i32 {
DEFAULT_CITY_HP + wall_tier.clamp(0, 3) * WALL_HP_PER_TIER
BASE_CITY_HP + wall_tier.clamp(0, 3) * WALL_HP_PER_TIER
}
/// Compute how ranged damage is split between city HP and garrison HP.