diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 37c79902..de5b6f97 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -2046,3 +2046,203 @@ fn turn_result_to_dict(result: &mc_turn::TurnResult, post_turn: u32) -> Dictiona d.set("fauna_combat_log", log); d } + +// ── GdCombatResolver ──────────────────────────────────────────────────── +// +// Stateless wrapper around `mc_combat::CombatResolver`. Accepts attacker/ +// defender stat dicts + optional city context, returns a result dict. The +// GDScript `combat_resolver.gd` wrapper converts Unit objects into these +// dicts and writes the result back to the units. +// +// Input dict shape (attacker/defender): +// { hp: int, max_hp: int, attack: int, defense: int, +// ranged_attack: int, range: int, movement: int, +// keywords: Array[String] } +// +// Params dict shape: +// { combat_type: String ("melee"|"ranged"|"siege"), +// attacker_flanking_allies: int, defender_flanking_allies: int, +// defender_terrain_defense: float, defender_fortification: float, +// city_hp: int (or -1 for no city), city_wall_tier: int, +// city_has_garrison: bool, attacker_is_siege: bool } +// +// Result dict shape matches `mc_combat::CombatResult` field-by-field. + +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdCombatResolver { + base: Base, +} + +#[godot_api] +impl IRefCounted for GdCombatResolver { + fn init(base: Base) -> Self { + Self { base } + } +} + +fn dict_get_i64(d: &Dictionary, key: &str, default: i64) -> i64 { + d.get(key) + .and_then(|v| v.try_to::().ok()) + .unwrap_or(default) +} + +fn dict_get_f64(d: &Dictionary, key: &str, default: f64) -> f64 { + d.get(key) + .and_then(|v| v.try_to::().ok()) + .unwrap_or(default) +} + +fn dict_get_bool(d: &Dictionary, key: &str, default: bool) -> bool { + d.get(key) + .and_then(|v| v.try_to::().ok()) + .unwrap_or(default) +} + +fn dict_get_string(d: &Dictionary, key: &str, default: &str) -> String { + d.get(key) + .and_then(|v| v.try_to::().ok()) + .map(|g| g.to_string()) + .unwrap_or_else(|| default.to_string()) +} + +fn unit_stats_from_dict(d: &Dictionary) -> mc_combat::UnitStats { + mc_combat::UnitStats { + hp: dict_get_i64(d, "hp", 0) as i32, + max_hp: dict_get_i64(d, "max_hp", 1) as i32, + attack: dict_get_i64(d, "attack", 0) as i32, + defense: dict_get_i64(d, "defense", 0) as i32, + ranged_attack: dict_get_i64(d, "ranged_attack", 0) as i32, + range: dict_get_i64(d, "range", 0) as i32, + movement: dict_get_i64(d, "movement", 0) as i32, + } +} + +fn keywords_from_dict(d: &Dictionary) -> Vec { + let Some(v) = d.get("keywords") else { + return Vec::new(); + }; + let Ok(arr) = v.try_to::>() else { + return Vec::new(); + }; + let mut out = Vec::with_capacity(arr.len()); + for i in 0..arr.len() { + if let Some(s) = arr.get(i) { + if let Some(kw) = mc_combat::Keyword::from_str(&s.to_string()) { + out.push(kw); + } + } + } + out +} + +fn combat_type_from_str(s: &str) -> mc_combat::CombatType { + match s { + "ranged" => mc_combat::CombatType::Ranged, + "siege" => mc_combat::CombatType::Siege, + _ => mc_combat::CombatType::Melee, + } +} + +#[godot_api] +impl GdCombatResolver { + /// Resolve a combat engagement. See module docs for dict shapes. + #[func] + fn resolve( + attacker: Dictionary, + defender: Dictionary, + params: Dictionary, + ) -> Dictionary { + let attacker_stats = unit_stats_from_dict(&attacker); + let defender_stats = unit_stats_from_dict(&defender); + let attacker_keywords = keywords_from_dict(&attacker); + let defender_keywords = keywords_from_dict(&defender); + + let combat_type_s = dict_get_string(¶ms, "combat_type", "melee"); + let combat_type = combat_type_from_str(&combat_type_s); + + let attacker_bonuses = mc_combat::CombatBonuses { + flanking_allies: dict_get_i64(¶ms, "attacker_flanking_allies", 0) as i32, + ..Default::default() + }; + let defender_bonuses = mc_combat::CombatBonuses { + flanking_allies: dict_get_i64(¶ms, "defender_flanking_allies", 0) as i32, + terrain_defense: dict_get_f64(¶ms, "defender_terrain_defense", 0.0) as f32, + fortification: dict_get_f64(¶ms, "defender_fortification", 0.0) as f32, + ..Default::default() + }; + + let city_hp_raw = dict_get_i64(¶ms, "city_hp", -1); + let city_hp = if city_hp_raw >= 0 { + Some(city_hp_raw as i32) + } else { + None + }; + + let combat_params = mc_combat::CombatParams { + attacker: attacker_stats, + defender: defender_stats, + combat_type, + attacker_keywords, + defender_keywords, + attacker_bonuses, + defender_bonuses, + city_hp, + city_wall_tier: dict_get_i64(¶ms, "city_wall_tier", 0) as i32, + city_has_garrison: dict_get_bool(¶ms, "city_has_garrison", false), + attacker_is_siege: dict_get_bool(¶ms, "attacker_is_siege", false), + }; + + let result = mc_combat::CombatResolver::resolve(&combat_params); + + let mut d = Dictionary::new(); + d.set("defender_damage", result.defender_damage as i64); + d.set("attacker_damage", result.attacker_damage as i64); + d.set( + "attacker_killed", + matches!(result.attacker_outcome, mc_combat::CombatOutcome::Killed), + ); + d.set( + "defender_killed", + matches!(result.defender_outcome, mc_combat::CombatOutcome::Killed), + ); + d.set("attacker_hp", result.attacker_hp as i64); + d.set("defender_hp", result.defender_hp as i64); + d.set("city_damage", result.city_damage as i64); + d.set("city_hp_remaining", result.city_hp_remaining as i64); + d.set("attacker_xp", result.attacker_xp as i64); + d.set("defender_xp", result.defender_xp as i64); + d.set("life_drain_heal", result.life_drain_heal as i64); + d + } + + /// XP threshold for a given promotion level. Returns -1 if beyond max. + #[func] + fn xp_threshold(level: i64) -> i64 { + mc_combat::xp_threshold(level as i32) + .map(|x| x as i64) + .unwrap_or(-1) + } + + /// HP healed when a unit promotes. + #[func] + fn heal_on_promote(max_hp: i64) -> i64 { + mc_combat::heal_on_promote(max_hp as i32) as i64 + } + + /// Derive wild creature stats (tier, size, diet) — same formula as the resolver test. + #[func] + fn wild_combat_stats(tier: i64, size: GString, diet: GString) -> Dictionary { + let stats = + mc_combat::wild_combat_stats(tier as i32, &size.to_string(), &diet.to_string()); + let mut d = Dictionary::new(); + d.set("hp", stats.hp as i64); + d.set("max_hp", stats.max_hp as i64); + d.set("attack", stats.attack as i64); + d.set("defense", stats.defense as i64); + d.set("ranged_attack", stats.ranged_attack as i64); + d.set("range", stats.range as i64); + d.set("movement", stats.movement as i64); + d + } +}