From 9bbd80a42663560035f935b0cb5fe9f9b17d7b22 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 12 Apr 2026 18:04:28 -0700 Subject: [PATCH] feat(city-defense): city bombard retaliation + building HP bonuses - City bombard: melee attackers take 5-30 damage based on city population and castle bombard bonus (city_str = pop*3 + castle bonus) - Building HP bonuses: when walls/castle complete, increase city max_hp and heal by hp_bonus value from building data - Castle data: added city_bombard_strength: 12, city_bombard_range: 2 Combined with prior commit's city healing (20 HP/turn) and tiered wall penalties (walls=0.75x, castle=0.60x), cities now require sustained multi-turn sieges instead of 1-2 turn captures. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../resources/buildings/defense_special.json | 8 ++++++ .../src/modules/combat/combat_resolver.gd | 28 +++++++++++++++++++ .../src/modules/management/turn_processor.gd | 12 ++++++++ 3 files changed, 48 insertions(+) diff --git a/public/resources/buildings/defense_special.json b/public/resources/buildings/defense_special.json index 38a03bdf..dd6e74fe 100644 --- a/public/resources/buildings/defense_special.json +++ b/public/resources/buildings/defense_special.json @@ -91,6 +91,14 @@ "type": "ranged_defense", "value": 8, "scope": "city" + }, + { + "type": "city_bombard_strength", + "value": 12 + }, + { + "type": "city_bombard_range", + "value": 2 } ], "requires_building": "walls", diff --git a/src/game/engine/src/modules/combat/combat_resolver.gd b/src/game/engine/src/modules/combat/combat_resolver.gd index b9383d51..51437a57 100644 --- a/src/game/engine/src/modules/combat/combat_resolver.gd +++ b/src/game/engine/src/modules/combat/combat_resolver.gd @@ -143,6 +143,16 @@ func _apply_resolve_results( if infusion_system != null: infusion_system.on_unit_killed(defender) + # City bombard retaliation: city deals damage to melee attackers + if defender is CityScript and not attacker_killed: + var bombard_dmg: int = _compute_city_bombard(defender, attacker) + if bombard_dmg > 0: + attacker.hp = maxi(0, attacker.hp - bombard_dmg) + result["city_bombard_damage"] = bombard_dmg + if attacker.hp <= 0: + attacker_killed = true + CombatUtilsScript.handle_unit_death(attacker, defender, all_units) + if result.get("captured", false) and defender is CityScript: CombatUtilsScript.capture_city(defender, attacker, defender.owner, all_units) @@ -307,6 +317,24 @@ 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. +## 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 + # Add castle bombard bonus if present + if city.has_building("castle"): + var bdata: Dictionary = DataLoader.get_building("castle") + for effect: Dictionary in bdata.get("effects", []): + if effect.get("type", "") == "city_bombard_strength": + city_str += float(effect.get("value", 0)) + if city_str <= 0.0: + return 0 + var atk_str: float = float(attacker.attack) if attacker is UnitScript else 10.0 + var ratio: float = city_str / maxf(atk_str, 1.0) + return clampi(roundi(15.0 * ratio), 5, 30) + + func _get_terrain_defense(unit: RefCounted, game_map: RefCounted) -> int: if game_map == null: return 0 diff --git a/src/game/engine/src/modules/management/turn_processor.gd b/src/game/engine/src/modules/management/turn_processor.gd index 4211225a..0b323b0c 100644 --- a/src/game/engine/src/modules/management/turn_processor.gd +++ b/src/game/engine/src/modules/management/turn_processor.gd @@ -88,6 +88,7 @@ func _process_production(player: RefCounted) -> void: # Player EventBus.city_unit_completed.emit(city_ref, unit) elif item_type == "building": c.add_building(item_id) + _apply_building_bonuses(c, item_id) EventBus.city_building_completed.emit(city_ref, item_id) elif item_type == "item": var i_data: Dictionary = DataLoader.get_item(item_id) @@ -211,6 +212,17 @@ func _process_growth(player: RefCounted) -> void: # Player c.process_growth(tile_json) +func _apply_building_bonuses(city: CityScript, building_id: String) -> void: + var bdata: Dictionary = DataLoader.get_building(building_id) + var effects: Array = bdata.get("effects", []) + for effect: Dictionary in effects: + var etype: String = effect.get("type", "") + var value: int = int(effect.get("value", 0)) + if etype == "hp_bonus" and value > 0: + city.set_max_hp(city.max_hp + value) + city.heal(value) + + func _process_city_healing(player: RefCounted) -> void: for city_ref: Variant in player.cities: if city_ref is CityScript: