diff --git a/public/resources/lairs/loot/tier_01.json b/public/resources/lairs/loot/tier_01.json new file mode 100644 index 00000000..256a5b95 --- /dev/null +++ b/public/resources/lairs/loot/tier_01.json @@ -0,0 +1,9 @@ +{ + "tier": 1, + "id": "lair_loot_tier_1", + "description": "Tier-1 lair clear loot — scavenged commons. Guaranteed flint plus a chance of hides. Used as the tier default when a lair has no dominant-species loot_table (p3-10a).", + "loot_table": [ + { "resource": "flint", "amount": 2, "chance": 1.0 }, + { "resource": "hides", "amount": 1, "chance": 0.6 } + ] +} diff --git a/public/resources/lairs/loot/tier_02.json b/public/resources/lairs/loot/tier_02.json new file mode 100644 index 00000000..6c69bd0e --- /dev/null +++ b/public/resources/lairs/loot/tier_02.json @@ -0,0 +1,10 @@ +{ + "tier": 2, + "id": "lair_loot_tier_2", + "description": "Tier-2 lair clear loot — basic materials. Guaranteed stone plus chances of timber and hides (p3-10a).", + "loot_table": [ + { "resource": "stone", "amount": 3, "chance": 1.0 }, + { "resource": "timber", "amount": 2, "chance": 0.7 }, + { "resource": "hides", "amount": 2, "chance": 0.5 } + ] +} diff --git a/public/resources/lairs/loot/tier_03.json b/public/resources/lairs/loot/tier_03.json new file mode 100644 index 00000000..d6363098 --- /dev/null +++ b/public/resources/lairs/loot/tier_03.json @@ -0,0 +1,10 @@ +{ + "tier": 3, + "id": "lair_loot_tier_3", + "description": "Tier-3 lair clear loot — worked goods and pelts. Guaranteed hardwood plus chances of furs and bear pelt (p3-10a).", + "loot_table": [ + { "resource": "hardwood", "amount": 3, "chance": 1.0 }, + { "resource": "furs", "amount": 2, "chance": 0.65 }, + { "resource": "bear_pelt", "amount": 1, "chance": 0.4 } + ] +} diff --git a/public/resources/lairs/loot/tier_04.json b/public/resources/lairs/loot/tier_04.json new file mode 100644 index 00000000..d78fcae2 --- /dev/null +++ b/public/resources/lairs/loot/tier_04.json @@ -0,0 +1,10 @@ +{ + "tier": 4, + "id": "lair_loot_tier_4", + "description": "Tier-4 lair clear loot — base metals. Guaranteed copper plus chances of iron and spider silk (p3-10a).", + "loot_table": [ + { "resource": "copper", "amount": 2, "chance": 1.0 }, + { "resource": "iron", "amount": 1, "chance": 0.55 }, + { "resource": "spider_silk", "amount": 1, "chance": 0.45 } + ] +} diff --git a/public/resources/lairs/loot/tier_05.json b/public/resources/lairs/loot/tier_05.json new file mode 100644 index 00000000..520756de --- /dev/null +++ b/public/resources/lairs/loot/tier_05.json @@ -0,0 +1,10 @@ +{ + "tier": 5, + "id": "lair_loot_tier_5", + "description": "Tier-5 lair clear loot — refined metals and coin. Guaranteed iron plus chances of gold and troll bone (p3-10a).", + "loot_table": [ + { "resource": "iron", "amount": 3, "chance": 1.0 }, + { "resource": "gold", "amount": 2, "chance": 0.6 }, + { "resource": "troll_bone", "amount": 1, "chance": 0.4 } + ] +} diff --git a/public/resources/lairs/loot/tier_06.json b/public/resources/lairs/loot/tier_06.json new file mode 100644 index 00000000..457ef32e --- /dev/null +++ b/public/resources/lairs/loot/tier_06.json @@ -0,0 +1,10 @@ +{ + "tier": 6, + "id": "lair_loot_tier_6", + "description": "Tier-6 lair clear loot — fine stone and exotics. Guaranteed marble plus chances of amber and harpy feather (p3-10a).", + "loot_table": [ + { "resource": "marble", "amount": 2, "chance": 1.0 }, + { "resource": "amber", "amount": 1, "chance": 0.55 }, + { "resource": "harpy_feather", "amount": 1, "chance": 0.45 } + ] +} diff --git a/public/resources/lairs/loot/tier_07.json b/public/resources/lairs/loot/tier_07.json new file mode 100644 index 00000000..b4cc5925 --- /dev/null +++ b/public/resources/lairs/loot/tier_07.json @@ -0,0 +1,10 @@ +{ + "tier": 7, + "id": "lair_loot_tier_7", + "description": "Tier-7 lair clear loot — precious materials. Guaranteed gold plus chances of ivory and wyvern scale (p3-10a).", + "loot_table": [ + { "resource": "gold", "amount": 4, "chance": 1.0 }, + { "resource": "ivory", "amount": 1, "chance": 0.55 }, + { "resource": "wyvern_scale", "amount": 1, "chance": 0.4 } + ] +} diff --git a/public/resources/lairs/loot/tier_08.json b/public/resources/lairs/loot/tier_08.json new file mode 100644 index 00000000..7677fa6a --- /dev/null +++ b/public/resources/lairs/loot/tier_08.json @@ -0,0 +1,10 @@ +{ + "tier": 8, + "id": "lair_loot_tier_8", + "description": "Tier-8 lair clear loot — gemstones and rare hides. Guaranteed gems plus chances of obsidian glass and wyvern scale (p3-10a).", + "loot_table": [ + { "resource": "gems", "amount": 2, "chance": 1.0 }, + { "resource": "obsidian_glass", "amount": 1, "chance": 0.55 }, + { "resource": "wyvern_scale", "amount": 2, "chance": 0.5 } + ] +} diff --git a/public/resources/lairs/loot/tier_09.json b/public/resources/lairs/loot/tier_09.json new file mode 100644 index 00000000..eecf932c --- /dev/null +++ b/public/resources/lairs/loot/tier_09.json @@ -0,0 +1,10 @@ +{ + "tier": 9, + "id": "lair_loot_tier_9", + "description": "Tier-9 lair clear loot — hoard-grade. Guaranteed gems and gold plus a chance of a second gem cache (p3-10a).", + "loot_table": [ + { "resource": "gems", "amount": 3, "chance": 1.0 }, + { "resource": "gold", "amount": 6, "chance": 1.0 }, + { "resource": "gems", "amount": 2, "chance": 0.5 } + ] +} diff --git a/public/resources/lairs/loot/tier_10.json b/public/resources/lairs/loot/tier_10.json new file mode 100644 index 00000000..c7fe28e3 --- /dev/null +++ b/public/resources/lairs/loot/tier_10.json @@ -0,0 +1,10 @@ +{ + "tier": 10, + "id": "lair_loot_tier_10", + "description": "Tier-10 lair clear loot — apex hoard. Guaranteed large gems and gold caches plus a high chance of obsidian glass (p3-10a).", + "loot_table": [ + { "resource": "gems", "amount": 5, "chance": 1.0 }, + { "resource": "gold", "amount": 10, "chance": 1.0 }, + { "resource": "obsidian_glass", "amount": 3, "chance": 0.75 } + ] +} diff --git a/src/simulator/crates/mc-combat/src/lair.rs b/src/simulator/crates/mc-combat/src/lair.rs index c3c0648c..20c5e0a4 100644 --- a/src/simulator/crates/mc-combat/src/lair.rs +++ b/src/simulator/crates/mc-combat/src/lair.rs @@ -770,6 +770,94 @@ mod tests { let params = assault_params(vec![], vec![wild_combat_stats(3, "medium", "carnivore")], sample_loot()); assert_eq!(resolve_assault(params), AssaultOutcome::Withdrawn); } + + // ── p3-10a: per-tier loot JSON drives resolve_assault ─────────────────── + + /// Thin deserialize wrapper matching the authored per-tier loot files at + /// `public/resources/lairs/loot/tier_NN.json`. Production loading is the + /// GDExtension bridge's job (mc-combat stays file-loading-free per + /// `loot.rs`); this struct exists only so the test can read the canonical + /// files and prove they parse + drive the resolver. + #[derive(Deserialize)] + struct TierLootFile { + tier: u32, + loot_table: Vec, + } + + /// Walk up from CARGO_MANIFEST_DIR (`crates/mc-combat`) to the repo root, + /// then into `public/resources/lairs/loot`. Mirrors the test-only path + /// resolution `mc-civics::CivicCatalog::workspace_default_path` uses. + fn loot_dir() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("walk up to src/simulator") + .join("public/resources/lairs/loot") + } + + /// Every authored tier file (1..=10) must (a) parse into a non-empty + /// `Vec`, (b) declare its own `tier`, and (c) when fed to + /// `resolve_assault` against a weak defender by a winning stack, produce a + /// `Cleared` outcome whose rolled loot contains the guaranteed + /// (`chance == 1.0`) drops. This exercises the real JSON→resolver coupling + /// (not a hollow serde round-trip) — the definition of done for the + /// per-tier loot bullet. + #[test] + fn every_tier_loot_file_parses_and_drives_resolve_assault() { + let dir = loot_dir(); + for tier in 1u32..=10 { + let path = dir.join(format!("tier_{tier:02}.json")); + let raw = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + let file: TierLootFile = serde_json::from_str(&raw) + .unwrap_or_else(|e| panic!("parse {}: {e}", path.display())); + + assert_eq!(file.tier, tier, "{} declares wrong tier", path.display()); + assert!( + !file.loot_table.is_empty(), + "{} has an empty loot_table", + path.display() + ); + + // Collect the guaranteed (chance == 1.0) resources — these MUST + // appear in every clear regardless of seed. + let guaranteed: Vec = file + .loot_table + .iter() + .filter(|e| e.chance >= 1.0) + .map(|e| e.resource.clone()) + .collect(); + assert!( + !guaranteed.is_empty(), + "{}: tier loot must include at least one guaranteed (chance 1.0) \ + drop so clears are never empty-handed", + path.display() + ); + + // 4 warriors vs one weak tier-2 herbivore → reliable clear (same + // shape as `test_assault_clears_lair_drops_loot`). + let attackers = vec![warrior(), warrior(), warrior(), warrior()]; + let defenders = vec![wild_combat_stats(2, "small", "herbivore")]; + let outcome = + resolve_assault(assault_params(attackers, defenders, file.loot_table)); + + match outcome { + AssaultOutcome::Cleared { loot, .. } => { + assert!( + !loot.is_empty(), + "tier {tier}: clear produced no loot despite guaranteed drops" + ); + for g in &guaranteed { + assert!( + loot.iter().any(|d| &d.resource == g), + "tier {tier}: guaranteed drop '{g}' missing from rolled loot {loot:?}" + ); + } + } + other => panic!("tier {tier}: expected Cleared, got {other:?}"), + } + } + } } // `AssaultOutcome` and `LairDefender` need PartialEq for test equality.