From 2ba740d0e63646915e17c187a334bca8bcb2b7b5 Mon Sep 17 00:00:00 2001 From: autocommit Date: Wed, 15 Apr 2026 20:15:04 -0700 Subject: [PATCH] =?UTF-8?q?refactor(combat):=20=E2=99=BB=EF=B8=8F=20Stream?= =?UTF-8?q?line=20CombatResolver=20and=20Siege=20logic=20with=20utility=20?= =?UTF-8?q?functions=20in=20combat=5Futils.gd=20and=20resolver.rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../src/modules/combat/combat_resolver.gd | 6 +-- .../engine/src/modules/combat/combat_utils.gd | 14 +++++++ .../crates/mc-combat/src/resolver.rs | 28 +++++++++++++ src/simulator/crates/mc-combat/src/siege.rs | 40 +++++++++---------- 4 files changed, 65 insertions(+), 23 deletions(-) diff --git a/src/game/engine/src/modules/combat/combat_resolver.gd b/src/game/engine/src/modules/combat/combat_resolver.gd index 513087e0..015065a2 100644 --- a/src/game/engine/src/modules/combat/combat_resolver.gd +++ b/src/game/engine/src/modules/combat/combat_resolver.gd @@ -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: diff --git a/src/game/engine/src/modules/combat/combat_utils.gd b/src/game/engine/src/modules/combat/combat_utils.gd index 04ee1d40..a6b56d18 100644 --- a/src/game/engine/src/modules/combat/combat_utils.gd +++ b/src/game/engine/src/modules/combat/combat_utils.gd @@ -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: diff --git a/src/simulator/crates/mc-combat/src/resolver.rs b/src/simulator/crates/mc-combat/src/resolver.rs index 84257f0f..9e315f71 100644 --- a/src/simulator/crates/mc-combat/src/resolver.rs +++ b/src/simulator/crates/mc-combat/src/resolver.rs @@ -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(¶ms); + 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] diff --git a/src/simulator/crates/mc-combat/src/siege.rs b/src/simulator/crates/mc-combat/src/siege.rs index 64c70483..a545237a 100644 --- a/src/simulator/crates/mc-combat/src/siege.rs +++ b/src/simulator/crates/mc-combat/src/siege.rs @@ -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]