refactor(combat): ♻️ Streamline CombatResolver and Siege logic with utility functions in combat_utils.gd and resolver.rs

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-15 20:15:04 -07:00
parent 4fa33313e2
commit 2ba740d0e6
4 changed files with 65 additions and 23 deletions

View file

@ -255,8 +255,8 @@ func _build_city_dict(city: RefCounted, all_units: Array) -> Dictionary:
support += 1
return {
"hp": city.city_hp,
"max_hp": city.city_hp,
"hp": city.hp,
"max_hp": city.max_hp,
"attack": 0,
"defense": 0,
"ranged_attack": 0,
@ -339,7 +339,7 @@ func _compute_city_bombard(city: RefCounted, attacker: RefCounted) -> int:
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)
return clampi(roundi(10.0 * ratio), 3, 20)
func _get_terrain_defense(unit: RefCounted, game_map: RefCounted) -> int:

View file

@ -86,6 +86,9 @@ static func handle_unit_death(unit: RefCounted, killer: RefCounted, all_units: A
if tile != null:
ItemSystemScript.drop_all_loot(unit, tile)
if unit.owner == -1 and killer != null and killer is UnitScript and killer.owner >= 0:
_roll_wild_creature_loot(unit, killer)
all_units.erase(unit)
if unit.owner >= 0 and unit.owner < GameState.players.size():
@ -135,6 +138,17 @@ static func capture_city(
EventBus.player_eliminated.emit(old_owner)
static func _roll_wild_creature_loot(victim: RefCounted, killer: RefCounted) -> void:
if killer.owner < 0 or killer.owner >= GameState.players.size():
return
var killer_player: RefCounted = GameState.players[killer.owner]
var creature_type: String = victim.type_id if victim.type_id != "" else victim.unit_id
var turn_seed: int = GameState.game_rng.seed ^ GameState.turn_number
var killer_id: int = hash(killer.id)
var victim_id: int = hash(victim.id)
ItemSystemScript.roll_fauna_drops(creature_type, killer_player, turn_seed, killer_id, victim_id)
## Destroy the High Archon of a player (capital capture penalty).
static func _destroy_high_archon(player: RefCounted, all_units: Array) -> void:
for unit: RefCounted in player.units:

View file

@ -790,6 +790,34 @@ mod tests {
);
}
#[test]
fn melee_can_break_walled_city_over_sustained_assault() {
// Simulate repeated melee hits on a tier-1 walled city. The combined damage
// from multiple warriors must outpace 10 HP/turn regen so capture is reachable.
let mut city_hp = siege::city_total_hp(1); // 250
let mut hits = 0;
let max_hits = 50;
while city_hp > 0 && hits < max_hits {
let params = CombatParams {
attacker: warrior_stats(),
defender: warrior_stats(),
combat_type: CombatType::Melee,
city_hp: Some(city_hp),
city_wall_tier: 1,
city_has_garrison: true,
..Default::default()
};
let result = CombatResolver::resolve(&params);
city_hp = result.city_hp_remaining;
hits += 1;
}
assert_eq!(
city_hp, 0,
"sustained melee assault should reduce walled city to 0 HP within {} hits",
max_hits
);
}
// ── Damaged attacker deals less damage ────────────────────────────────
#[test]

View file

@ -12,10 +12,10 @@ pub const BASE_CITY_HP: i32 = 200;
pub const WALL_HP_PER_TIER: i32 = 50;
/// Siege unit bonus vs city HP (applied as positive modifier to siege damage).
const SIEGE_CITY_BONUS: f32 = 1.50;
const SIEGE_CITY_BONUS: f32 = 2.00;
/// City heals this much HP per turn (Civ5 standard).
pub const CITY_HEAL_PER_TURN: i32 = 20;
/// City heals this much HP per turn.
pub const CITY_HEAL_PER_TURN: i32 = 10;
/// Ranged attacks vs city reduce city HP, not garrison HP.
/// This fraction of ranged damage goes to city HP when a garrison is present.
@ -23,25 +23,25 @@ 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.
/// Scales by tier: 0=1.0, 1=0.75 (walls), 2=0.60 (castle).
/// Scales by tier: 0=1.0, 1=0.85 (walls), 2=0.75 (castle).
pub fn melee_wall_penalty(wall_tier: i32) -> f32 {
match wall_tier {
0 => 1.0,
1 => 0.75,
_ => 0.60, // tier 2+ (castle)
1 => 0.85,
_ => 0.75,
}
}
/// 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].
/// Uses a simplified damage formula: 10 * (city / attacker) clamped to [3, 20].
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
(10.0 * ratio).round().clamp(3.0, 20.0) as i32
}
/// Compute the bonus multiplier for siege units attacking a city.
@ -90,28 +90,28 @@ mod tests {
#[test]
fn melee_penalty_scales_by_tier() {
assert!((melee_wall_penalty(0) - 1.0).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
assert!((melee_wall_penalty(1) - 0.85).abs() < 0.001);
assert!((melee_wall_penalty(2) - 0.75).abs() < 0.001);
assert!((melee_wall_penalty(3) - 0.75).abs() < 0.001);
}
#[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);
// Equal strength: 10 damage
assert_eq!(city_bombard_damage(10.0, 10.0), 10);
// City twice as strong: 20 (capped)
assert_eq!(city_bombard_damage(20.0, 10.0), 20);
// City half strength: 5
assert_eq!(city_bombard_damage(5.0, 10.0), 5);
// Very weak city: clamped to 3
assert_eq!(city_bombard_damage(1.0, 10.0), 3);
// Zero strength: 0
assert_eq!(city_bombard_damage(0.0, 10.0), 0);
}
#[test]
fn siege_bonus() {
assert!((siege_city_bonus() - 1.50).abs() < 0.001);
assert!((siege_city_bonus() - 2.00).abs() < 0.001);
}
#[test]