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) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-04-12 18:04:28 -07:00
parent 00aa2ef601
commit 9bbd80a426
3 changed files with 48 additions and 0 deletions

View file

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

View file

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

View file

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