diff --git a/src/simulator/api-gdext/src/lair.rs b/src/simulator/api-gdext/src/lair.rs new file mode 100644 index 00000000..95cd19ec --- /dev/null +++ b/src/simulator/api-gdext/src/lair.rs @@ -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, +} + +/// Stateless GDExtension wrapper around the lair assault resolver. +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdLair { + base: Base, +} + +#[godot_api] +impl IRefCounted for GdLair { + fn init(base: Base) -> 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 { + 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::(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, String> { + serde_json::from_str::>(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::::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::::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::::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}"); + } +}