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:
parent
4131642d0b
commit
4884545694
11 changed files with 187 additions and 0 deletions
9
public/resources/lairs/loot/tier_01.json
Normal file
9
public/resources/lairs/loot/tier_01.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
10
public/resources/lairs/loot/tier_02.json
Normal file
10
public/resources/lairs/loot/tier_02.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
10
public/resources/lairs/loot/tier_03.json
Normal file
10
public/resources/lairs/loot/tier_03.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
10
public/resources/lairs/loot/tier_04.json
Normal file
10
public/resources/lairs/loot/tier_04.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
10
public/resources/lairs/loot/tier_05.json
Normal file
10
public/resources/lairs/loot/tier_05.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
10
public/resources/lairs/loot/tier_06.json
Normal file
10
public/resources/lairs/loot/tier_06.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
10
public/resources/lairs/loot/tier_07.json
Normal file
10
public/resources/lairs/loot/tier_07.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
10
public/resources/lairs/loot/tier_08.json
Normal file
10
public/resources/lairs/loot/tier_08.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
10
public/resources/lairs/loot/tier_09.json
Normal file
10
public/resources/lairs/loot/tier_09.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
10
public/resources/lairs/loot/tier_10.json
Normal file
10
public/resources/lairs/loot/tier_10.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue