feat(simulator): Implement per-tier loot resolution logic and define loot tables for tiers 01-10

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-03 23:28:32 -07:00
parent 4131642d0b
commit 4884545694
11 changed files with 187 additions and 0 deletions

View file

@ -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 }
]
}

View file

@ -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 }
]
}

View file

@ -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 }
]
}

View file

@ -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 }
]
}

View file

@ -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 }
]
}

View file

@ -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 }
]
}

View file

@ -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 }
]
}

View file

@ -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 }
]
}

View file

@ -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 }
]
}

View file

@ -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 }
]
}

View file

@ -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<LootEntry>,
}
/// 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<LootEntry>`, (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<String> = 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.