feat(api-gdext): Introduce physics engine integration traits and methods for GDExtension API simulations

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-08 22:30:35 -07:00
parent 57e798f7f8
commit 6e17c474d2

View file

@ -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<RefCounted>,
}
#[godot_api]
impl IRefCounted for GdCombatResolver {
fn init(base: Base<RefCounted>) -> Self {
Self { base }
}
}
fn dict_get_i64(d: &Dictionary, key: &str, default: i64) -> i64 {
d.get(key)
.and_then(|v| v.try_to::<i64>().ok())
.unwrap_or(default)
}
fn dict_get_f64(d: &Dictionary, key: &str, default: f64) -> f64 {
d.get(key)
.and_then(|v| v.try_to::<f64>().ok())
.unwrap_or(default)
}
fn dict_get_bool(d: &Dictionary, key: &str, default: bool) -> bool {
d.get(key)
.and_then(|v| v.try_to::<bool>().ok())
.unwrap_or(default)
}
fn dict_get_string(d: &Dictionary, key: &str, default: &str) -> String {
d.get(key)
.and_then(|v| v.try_to::<GString>().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<mc_combat::Keyword> {
let Some(v) = d.get("keywords") else {
return Vec::new();
};
let Ok(arr) = v.try_to::<Array<GString>>() 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(&params, "combat_type", "melee");
let combat_type = combat_type_from_str(&combat_type_s);
let attacker_bonuses = mc_combat::CombatBonuses {
flanking_allies: dict_get_i64(&params, "attacker_flanking_allies", 0) as i32,
..Default::default()
};
let defender_bonuses = mc_combat::CombatBonuses {
flanking_allies: dict_get_i64(&params, "defender_flanking_allies", 0) as i32,
terrain_defense: dict_get_f64(&params, "defender_terrain_defense", 0.0) as f32,
fortification: dict_get_f64(&params, "defender_fortification", 0.0) as f32,
..Default::default()
};
let city_hp_raw = dict_get_i64(&params, "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(&params, "city_wall_tier", 0) as i32,
city_has_garrison: dict_get_bool(&params, "city_has_garrison", false),
attacker_is_siege: dict_get_bool(&params, "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
}
}