feat(p3-10a): GdLair::assault GDExtension bridge over resolve_assault
New api-gdext/src/lair.rs exposing GdLair::assault(...) #[func], registered
in lib.rs. Mirrors the GdLootRoller JSON-string pattern (Rail 3 — file IO
stays in GDScript, the bridge only marshals):
assault(attackers_json, defenders_json, loot_tier_json, lair_id,
lair_tier, turn_seed, defender_terrain_bonus) -> Dictionary
GDScript reads public/resources/lairs/loot/tier_NN.json + builds the
stack/defender JSON; the bridge parses (build_params — Godot-free,
unit-tested) into LairAssaultParams, calls mc_combat::resolve_assault, and
returns a cleared/repulsed/withdrawn outcome Dictionary (error, never panic,
on malformed JSON).
The Assault/Siege/Raid UI mode picker is a godot-ui follow-up, noted in the
objective — NOT built in this Rust lane.
Tests (apricot): GdLair 6/6 (cleared+guaranteed-loot, repulsed via real
wild_combat_stats(8,huge,carnivore), withdrawn, params-assembly, 2 parse-error
paths); api-gdext lib 29/29; cargo check --workspace exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cd339ff7dd
commit
5503f04b25
3 changed files with 316 additions and 5 deletions
|
|
@ -27,7 +27,7 @@ blocked_by: [p0-17]
|
|||
- ✓ `mc-combat::lair::resolve_assault(LairAssaultParams) -> AssaultOutcome` in `src/simulator/crates/mc-combat/src/lair.rs` with the three-variant outcome (`Cleared { loot, stack_survivors }`, `Repulsed { surviving_defenders }`, `Withdrawn`).
|
||||
- ✓ Combat math applies `LAIR_DEFENDER_POSTURE_BONUS = 0.25` on top of caller-supplied terrain bonus (`crates/mc-combat/src/lair.rs:44`, `:192`), distinct from open-field combat. Documented in `public/games/age-of-dwarves/docs/combat/LAIRS.md`.
|
||||
- ✓ Loot table per lair tier in `public/resources/lairs/loot/*.json`. **Authored 2026-06-03 (bridge-cse lane; pulled forward from p3-10b per operator instruction — note the spec-vs-operator tension: this objective's text deferred it to p3-10b's loot pass).** Ten files `tier_01.json … tier_10.json`, each a `{ tier, id, description, loot_table: [LootEntry] }` shape matching `mc_combat::LootEntry {resource, amount, chance}`, escalating commons→hoard, every tier carrying ≥1 guaranteed (`chance 1.0`) drop. All resource ids reference real entries in `public/resources/resources.json` or authored fauna products (no orphan ids). Evidence: `public/resources/lairs/loot/tier_*.json`; `cargo test -p mc-combat lair::tests::every_tier_loot_file_parses_and_drives_resolve_assault` → **passed** (loads each file from disk, runs `resolve_assault`, asserts `Cleared { loot }` contains the guaranteed drops — real JSON→resolver coupling, not a serde round-trip); full mc-combat suite green (143/0 lib + all integration suites, no regression) on apricot 2026-06-03. The crate stays file-loading-free (the test reads files; production loading is the bridge's job — see next bullet).
|
||||
- ❌ Player initiates via `GdLair::assault(stack_id)` GDExt method; UI displays mode picker (Assault / Siege / Raid). **Deferred on budget (bridge-cse lane) — greenfield, bounded, not walled.** Audit (2026-06-03): NO `GdLair` / `resolve_assault` bridge exists in api-gdext today (only `GdLootRoller`, which already parses `Vec<LootEntry>` from JSON and rolls loot — the exact pattern the assault bridge mirrors). Remaining work: a new `GdLair::assault(...)` `#[func]` in api-gdext (NOT `ai.rs`) that takes the stack + the tier loot JSON (GDScript reads `public/resources/lairs/loot/tier_NN.json`, passes the string, bridge parses → `Vec<LootEntry>` → `resolve_assault`), then the UI Assault/Siege/Raid mode picker (screenshot-tail). Requires `bash build-gdext.sh` + GUT, not attempted this session.
|
||||
- ◐ Player initiates via `GdLair::assault(...)` GDExt method; UI displays mode picker (Assault / Siege / Raid). **GDExt BRIDGE DONE 2026-06-04 (Wave B); UI picker is the godot-ui follow-up.** New `GdLair` (`api-gdext/src/lair.rs`, registered in `lib.rs`) exposes `#[func] assault(attackers_json, defenders_json, loot_tier_json, lair_id, lair_tier, turn_seed, defender_terrain_bonus) -> Dictionary`, mirroring the `GdLootRoller` JSON-string pattern: GDScript reads `public/resources/lairs/loot/tier_NN.json` + builds the stack/defender JSON, the bridge parses (`build_params`, Godot-free + unit-tested) → `LairAssaultParams` → `mc_combat::resolve_assault` → an outcome `Dictionary` (`cleared`/`repulsed`/`withdrawn`, with `loot` + survivor rows; `error` on malformed JSON, no panic). Rail 3 honoured — file IO stays in GDScript, the bridge only marshals. Tests: `api-gdext/src/lair.rs::tests` 6/6 (cleared-drops-guaranteed-loot, repulsed via real `wild_combat_stats(8,huge,carnivore)`, withdrawn on empty stack, params-assembly, two parse-error paths); full api-gdext lib 29/29; `cargo check --workspace` exit 0 (apricot 2026-06-04). **Remaining (godot-ui lane, NOT this Rust lane):** the Assault/Siege/Raid mode-picker scene that calls `GdLair.assault()` for the Assault branch and shows Siege/Raid disabled until p3-10b/p3-10c land; `bash build-gdext.sh` + GUT + proof screenshot. Bullet stays ◐ until the picker ships.
|
||||
- ✓ `cargo test -p mc-combat test_assault_repulses_weak_attacker` and `test_assault_clears_lair_drops_loot` green (verified on apricot 2026-05-05). Plus `mc-core` lair-mode tests: `test_lair_mode_serde_round_trip`, `test_assault_is_default_mode_for_existing_callers`, `test_lair_mode_ord_consistent_with_all_variants`.
|
||||
|
||||
## Phase A status (2026-05-05)
|
||||
|
|
@ -59,15 +59,15 @@ Phase A (typed-enum + Assault wiring) is **done**. Phase B (loot JSON files) and
|
|||
- ✓ **`resolve_assault` + three-variant `AssaultOutcome`** — `mc-combat/src/lair.rs:484`; `Cleared/Repulsed/Withdrawn` present.
|
||||
- ✓ **`LAIR_DEFENDER_POSTURE_BONUS = 0.25`** — applied in `lair.rs`; documented in `LAIRS.md`.
|
||||
- ✓ **Per-tier loot JSON** — `public/resources/lairs/loot/tier_01.json … tier_10.json` all present (`{tier,id,description,loot_table:[{resource,amount,chance}]}`, each with ≥1 `chance 1.0` drop). Test `every_tier_loot_file_parses_and_drives_resolve_assault` reads each file from disk and runs `resolve_assault` (real JSON→resolver coupling, not a serde round-trip).
|
||||
- ✗ **`GdLair::assault` GDExt method + Assault/Siege/Raid UI mode picker** — NO `GdLair` bridge in `api-gdext/src/` (only `score.rs`, `replay.rs`, `lib.rs`; `grep GdLair` → nothing). NO mode-picker scene/panel in `scenes/`. This is the sole open bullet.
|
||||
- ◐ **`GdLair::assault` GDExt method + Assault/Siege/Raid UI mode picker** — **bridge half DONE 2026-06-04 (Wave B):** `api-gdext/src/lair.rs::GdLair::assault` over `resolve_assault` (6/6 tests, lib 29/29, workspace check 0). The mode-picker scene/panel in `scenes/` is still ABSENT — godot-ui follow-up, NOT this Rust lane.
|
||||
- ✓ **`cargo test -p mc-combat` assault/mode tests** — named tests cited in spec; full lair suite green per the close-out (not re-run this session, but the test bodies are present in `lair.rs`).
|
||||
|
||||
**Key finding — p0-17 lair clearing IS already reachable WITHOUT the bridge:** `scenes/world_map/world_map_combat.gd` has `get_lair_at()`, `initiate_lair_combat()`, `show_lair_preview()`, and `_handle_lair_clear()`. Moving a stack onto a lair tile resolves combat through the existing p0-17 path today. The missing `GdLair::assault` is an ALTERNATE entry that also surfaces the mode picker — it is NOT the only way to clear a lair.
|
||||
|
||||
**Path forward (ordered):**
|
||||
1. Add `GdLair::assault(...)` `#[func]` in `api-gdext/src/lair.rs` (new file; mirror the existing `GdLootRoller` JSON-parse pattern: GDScript reads `public/resources/lairs/loot/tier_NN.json`, passes the string, bridge parses → `Vec<LootEntry>` → `resolve_assault`). `bash build-gdext.sh`.
|
||||
2. Author a lair mode-picker UI (Assault / Siege / Raid) — a small modal/popup in `scenes/combat/` or extend `combat_preview`. For Game-1 demo, Siege/Raid can be shown disabled (they return `NotImplemented` in the dispatcher).
|
||||
3. GUT + a proof screenshot of the picker on apricot.lan.
|
||||
1. ✓ DONE 2026-06-04 (Wave B) — `GdLair::assault(...)` `#[func]` in `api-gdext/src/lair.rs` over `resolve_assault`, mirroring `GdLootRoller`'s JSON-string pattern. 6/6 bridge tests + 29/29 lib + workspace check clean.
|
||||
2. **(godot-ui lane)** Author a lair mode-picker UI (Assault / Siege / Raid) — a small modal/popup in `scenes/combat/` or extend `combat_preview`, calling `GdLair.assault()` for the Assault branch. For Game-1 demo, Siege/Raid can be shown disabled (they return `NotImplemented` in the dispatcher).
|
||||
3. **(godot-ui lane)** `bash build-gdext.sh` + GUT + a proof screenshot of the picker on apricot.lan.
|
||||
|
||||
**Blockers:** none for the Rust+bridge add (the `resolve_assault` core + loot JSON are done; `GdLootRoller` is the template). The UI picker depends on the bridge func existing first. (Proof on apricot.lan; plum down.)
|
||||
|
||||
|
|
|
|||
310
src/simulator/api-gdext/src/lair.rs
Normal file
310
src/simulator/api-gdext/src/lair.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ pub mod building_action;
|
|||
pub mod building_registry;
|
||||
pub mod capture;
|
||||
pub mod civics;
|
||||
pub mod lair;
|
||||
mod mod_host;
|
||||
pub mod observation;
|
||||
pub mod player_api;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue