feat(api-gdext): Introduce Lair module with Rust implementation to extend simulator API capabilities

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-04 15:56:03 -07:00
parent d37234557b
commit db42dd867a

View file

@ -0,0 +1,310 @@
//! p3-10a — `GdLair` GDExtension bridge over `mc_combat::lair::resolve_assault`.
//!
//! Presentation-only wrapper (Rail 3): GDScript reads the authored JSON
//! (attacker stack, lair defenders, the tier loot table from
//! `public/resources/lairs/loot/tier_NN.json`), passes the strings in, and the
//! bridge parses → `LairAssaultParams` → `mc_combat::resolve_assault` → a
//! Godot `Dictionary` describing the outcome. The bridge does NO IO of its own;
//! file reads stay on the GDScript side, exactly mirroring `GdLootRoller`.
//!
//! The combat math, posture bonus, and loot rolling all live in `mc-combat`
//! (`resolve_assault`, `LAIR_DEFENDER_POSTURE_BONUS`) — the source of truth.
//! This file only marshals data across the FFI boundary.
//!
//! The Assault / Siege / Raid UI mode picker is a godot-ui follow-up (a small
//! modal that calls `assault()` for the Assault branch and shows Siege/Raid
//! disabled until p3-10b/p3-10c land their dispatcher branches). It is NOT
//! built here — this objective owns only the Rust bridge.
use godot::prelude::*;
use mc_combat::{
resolve_assault, AssaultOutcome, LairAssaultParams, LairDefender, LootEntry, StackUnit,
};
use mc_combat::resolver::UnitStats;
use mc_core::lair::LairId;
/// One attacker/defender combatant as authored on the GDScript side: a stable
/// `entity_id` (for reproducible loot seed mixing) plus the combat stat-line.
/// Wire shape: `{ "entity_id": u32, "stats": { hp, max_hp, attack, ... } }`.
#[derive(serde::Deserialize)]
struct CombatantDoc {
entity_id: u32,
stats: UnitStats,
}
/// The tier loot file shape (`tier_NN.json`): only `loot_table` is consumed
/// here; the other fields (`tier`, `id`, `description`) are documentation.
#[derive(serde::Deserialize)]
struct LootTierDoc {
#[serde(default)]
loot_table: Vec<LootEntry>,
}
/// Stateless GDExtension wrapper around the lair assault resolver.
#[derive(GodotClass)]
#[class(base=RefCounted)]
pub struct GdLair {
base: Base<RefCounted>,
}
#[godot_api]
impl IRefCounted for GdLair {
fn init(base: Base<RefCounted>) -> Self {
Self { base }
}
}
#[godot_api]
impl GdLair {
/// Resolve an Assault against a wild lair.
///
/// Inputs (all JSON strings except the scalars):
/// - `attackers_json`: array of `{entity_id, stats}` — the attacking stack
/// in engagement priority order.
/// - `defenders_json`: array of `{entity_id, stats}` — the lair defenders
/// (typically built from `mc_combat::wild_combat_stats(tier, size, diet)`
/// on the GDScript side, or a future `GdWildStats` helper).
/// - `loot_tier_json`: the full `tier_NN.json` document; the bridge pulls
/// `loot_table` out of it (empty table → no loot, never an error).
/// - `lair_id`: content id for the outcome payload (UI / event log).
/// - `lair_tier`: 1..=10.
/// - `turn_seed`: per-turn deterministic seed (GDScript mixes from `turn`).
/// - `defender_terrain_bonus`: extra terrain defense on top of the built-in
/// `LAIR_DEFENDER_POSTURE_BONUS` (e.g. `0.25` for forest; `0.0` on plain).
///
/// Returns a `Dictionary`:
/// - `outcome`: `"cleared" | "repulsed" | "withdrawn"`.
/// - on `cleared`: `loot` (Array of `{resource, amount}`),
/// `stack_survivors` (Array of `{entity_id, hp, max_hp, attack, defense}`).
/// - on `repulsed`: `surviving_defenders` (same survivor shape).
/// - on a JSON parse error: `{ outcome: "error", message: String }`.
#[func]
#[allow(clippy::too_many_arguments)]
fn assault(
attackers_json: GString,
defenders_json: GString,
loot_tier_json: GString,
lair_id: GString,
lair_tier: i64,
turn_seed: i64,
defender_terrain_bonus: f64,
) -> Dictionary {
let params = match build_params(
&attackers_json.to_string(),
&defenders_json.to_string(),
&loot_tier_json.to_string(),
&lair_id.to_string(),
lair_tier as i32,
turn_seed.max(0) as u64,
defender_terrain_bonus as f32,
) {
Ok(p) => p,
Err(msg) => return error_dict(&msg),
};
outcome_to_dict(resolve_assault(params))
}
}
/// Pure (Godot-free) parse + assembly of `LairAssaultParams` from the wire
/// JSON. Split out from the `#[func]` so the load-bearing marshalling logic is
/// unit-testable without a live Godot runtime (the `Dictionary`/`Array`
/// conversions in `outcome_to_dict` stay GUT-covered).
#[allow(clippy::too_many_arguments)]
fn build_params(
attackers_json: &str,
defenders_json: &str,
loot_tier_json: &str,
lair_id: &str,
lair_tier: i32,
turn_seed: u64,
defender_terrain_bonus: f32,
) -> Result<LairAssaultParams, String> {
let attackers = parse_combatants(attackers_json, "attackers")?
.into_iter()
.map(|c| StackUnit { entity_id: c.entity_id, stats: c.stats })
.collect();
let defenders = parse_combatants(defenders_json, "defenders")?
.into_iter()
.map(|c| LairDefender { entity_id: c.entity_id, stats: c.stats })
.collect();
let loot_table = serde_json::from_str::<LootTierDoc>(loot_tier_json)
.map_err(|e| format!("loot_tier parse error: {e}"))?
.loot_table;
Ok(LairAssaultParams {
lair_id: LairId::new(lair_id),
lair_tier,
turn_seed,
attackers,
defenders,
loot_table,
defender_terrain_bonus,
})
}
/// Parse a combatant array, tagging the error with which side failed.
fn parse_combatants(raw: &str, side: &str) -> Result<Vec<CombatantDoc>, String> {
serde_json::from_str::<Vec<CombatantDoc>>(raw)
.map_err(|e| format!("{side} parse error: {e}"))
}
fn error_dict(message: &str) -> Dictionary {
godot_error!("GdLair::assault: {message}");
let mut d = Dictionary::new();
d.set("outcome", "error");
d.set("message", GString::from(message));
d
}
/// Survivor row shape shared by `stack_survivors` and `surviving_defenders`.
fn stack_survivor_dict(entity_id: u32, s: &UnitStats) -> Dictionary {
let mut d = Dictionary::new();
d.set("entity_id", entity_id as i64);
d.set("hp", s.hp as i64);
d.set("max_hp", s.max_hp as i64);
d.set("attack", s.attack as i64);
d.set("defense", s.defense as i64);
d
}
fn outcome_to_dict(outcome: AssaultOutcome) -> Dictionary {
let mut d = Dictionary::new();
match outcome {
AssaultOutcome::Cleared { loot, stack_survivors } => {
d.set("outcome", "cleared");
let mut loot_arr = Array::<Dictionary>::new();
for drop in loot {
let mut row = Dictionary::new();
row.set("resource", GString::from(drop.resource));
row.set("amount", drop.amount as i64);
loot_arr.push(&row);
}
d.set("loot", loot_arr);
let mut surv = Array::<Dictionary>::new();
for u in stack_survivors {
surv.push(&stack_survivor_dict(u.entity_id, &u.stats));
}
d.set("stack_survivors", surv);
}
AssaultOutcome::Repulsed { surviving_defenders } => {
d.set("outcome", "repulsed");
let mut defs = Array::<Dictionary>::new();
for u in surviving_defenders {
defs.push(&stack_survivor_dict(u.entity_id, &u.stats));
}
d.set("surviving_defenders", defs);
}
AssaultOutcome::Withdrawn => {
d.set("outcome", "withdrawn");
}
}
d
}
#[cfg(test)]
mod tests {
//! Godot's runtime is unavailable during `cargo test`, so these cover the
//! pure marshalling half (`build_params`) end-to-end into the resolver.
//! `outcome_to_dict` (Godot `Dictionary`/`Array`) is GUT-covered at the
//! phase gate. The wire JSON here mirrors what `world_map_combat.gd` will
//! pass: `[{entity_id, stats:{...}}]` stacks + a `tier_NN.json` loot doc.
use super::*;
fn strong_attacker() -> &'static str {
r#"[{"entity_id":1,"stats":{"hp":80,"max_hp":80,"attack":40,"defense":10,"ranged_attack":0,"range":0,"movement":2}}]"#
}
fn weak_attacker() -> &'static str {
r#"[{"entity_id":1,"stats":{"hp":5,"max_hp":5,"attack":1,"defense":0,"ranged_attack":0,"range":0,"movement":2}}]"#
}
fn lone_defender() -> &'static str {
r#"[{"entity_id":99,"stats":{"hp":20,"max_hp":20,"attack":6,"defense":2,"ranged_attack":0,"range":0,"movement":1}}]"#
}
/// A genuinely tough defender, built from the real `wild_combat_stats`
/// (T8 huge carnivore — the same fixture `mc-combat`'s own
/// `test_assault_repulses_weak_attacker` uses), serialized to the wire
/// shape the bridge parses. Guarantees a repulse against a weak stack.
fn tough_defender_json() -> String {
let stats = mc_combat::wild_combat_stats(8, "huge", "carnivore");
format!(
r#"[{{"entity_id":99,"stats":{}}}]"#,
serde_json::to_string(&stats).unwrap()
)
}
// tier_01.json shape — flint is the guaranteed (chance 1.0) drop.
fn tier1_loot() -> &'static str {
r#"{"tier":1,"id":"lair_loot_tier_1","loot_table":[{"resource":"flint","amount":2,"chance":1.0},{"resource":"hides","amount":1,"chance":0.6}]}"#
}
#[test]
fn build_params_assembles_from_wire_json() {
let p = build_params(
strong_attacker(), lone_defender(), tier1_loot(),
"test_lair", 1, 42, 0.25,
)
.expect("params build");
assert_eq!(p.lair_id.as_str(), "test_lair");
assert_eq!(p.lair_tier, 1);
assert_eq!(p.attackers.len(), 1);
assert_eq!(p.attackers[0].entity_id, 1);
assert_eq!(p.attackers[0].stats.attack, 40);
assert_eq!(p.defenders.len(), 1);
assert_eq!(p.loot_table.len(), 2);
assert!((p.defender_terrain_bonus - 0.25).abs() < f32::EPSILON);
}
#[test]
fn strong_stack_clears_lair_and_drops_guaranteed_loot() {
let p = build_params(
strong_attacker(), lone_defender(), tier1_loot(),
"test_lair", 1, 7, 0.0,
)
.unwrap();
match resolve_assault(p) {
AssaultOutcome::Cleared { loot, stack_survivors } => {
assert!(
loot.iter().any(|d| d.resource == "flint" && d.amount == 2),
"the guaranteed (chance 1.0) flint drop must be present: {loot:?}",
);
assert!(!stack_survivors.is_empty(), "strong attacker survives the clear");
}
other => panic!("expected Cleared, got {other:?}"),
}
}
#[test]
fn weak_stack_is_repulsed() {
let p = build_params(
weak_attacker(), &tough_defender_json(), tier1_loot(),
"test_lair", 8, 7, 0.0,
)
.unwrap();
match resolve_assault(p) {
AssaultOutcome::Repulsed { surviving_defenders } => {
assert!(!surviving_defenders.is_empty(), "defender survives a repulse");
}
other => panic!("expected Repulsed, got {other:?}"),
}
}
#[test]
fn empty_stack_withdraws() {
let p = build_params("[]", lone_defender(), tier1_loot(), "test_lair", 1, 7, 0.0).unwrap();
assert_eq!(resolve_assault(p), AssaultOutcome::Withdrawn);
}
#[test]
fn malformed_attacker_json_is_an_error_not_a_panic() {
let err = build_params("not json", lone_defender(), tier1_loot(), "x", 1, 0, 0.0)
.expect_err("malformed attacker JSON must error");
assert!(err.starts_with("attackers parse error"), "got: {err}");
}
#[test]
fn malformed_loot_json_is_an_error() {
let err = build_params(strong_attacker(), lone_defender(), "{", "x", 1, 0, 0.0)
.expect_err("malformed loot JSON must error");
assert!(err.starts_with("loot_tier parse error"), "got: {err}");
}
}