From 08abd276c5602fa7005346ee5c732602a09dfd3c Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 12 Apr 2026 18:22:25 -0700 Subject: [PATCH] feat(city-defense): population HP scaling + garrison combat strength boost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../src/modules/combat/combat_resolver.gd | 9 +++- src/simulator/crates/mc-city/src/city.rs | 45 ++++++++++++++----- src/simulator/crates/mc-city/src/lib.rs | 2 +- src/simulator/crates/mc-combat/src/siege.rs | 6 +-- 4 files changed, 45 insertions(+), 17 deletions(-) diff --git a/src/game/engine/src/modules/combat/combat_resolver.gd b/src/game/engine/src/modules/combat/combat_resolver.gd index 51437a57..513087e0 100644 --- a/src/game/engine/src/modules/combat/combat_resolver.gd +++ b/src/game/engine/src/modules/combat/combat_resolver.gd @@ -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") diff --git a/src/simulator/crates/mc-city/src/city.rs b/src/simulator/crates/mc-city/src/city.rs index 01baf719..2654596d 100644 --- a/src/simulator/crates/mc-city/src/city.rs +++ b/src/simulator/crates/mc-city/src/city.rs @@ -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()); } diff --git a/src/simulator/crates/mc-city/src/lib.rs b/src/simulator/crates/mc-city/src/lib.rs index 94cbc192..97f4165a 100644 --- a/src/simulator/crates/mc-city/src/lib.rs +++ b/src/simulator/crates/mc-city/src/lib.rs @@ -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, }; diff --git a/src/simulator/crates/mc-combat/src/siege.rs b/src/simulator/crates/mc-combat/src/siege.rs index 3c8c1d88..64c70483 100644 --- a/src/simulator/crates/mc-combat/src/siege.rs +++ b/src/simulator/crates/mc-combat/src/siege.rs @@ -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.