feat(simulator): Add collectibles module with new collectible entity management functionality

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-16 21:51:21 -07:00
parent 8a96cf3550
commit 968c855763
2 changed files with 231 additions and 0 deletions

View file

@ -0,0 +1,230 @@
//! Tile collectible projection.
//!
//! Maps a biome + tile quality → a set of rolled `CollectibleRoll`s that a
//! player or lair encounter can yield. Pure function, no I/O, no JSON loading
//! — callers supply the biome_id string already stored on `TileState`.
//!
//! ## Determinism contract
//!
//! All rolls are derived from the caller-supplied `rng`. The caller must seed
//! the RNG from `(turn_seed, tile_col, tile_row)` so every projection is
//! byte-identical across WASM and GDExtension targets.
/// Each tile quality point above 1 adds this fraction to base_quantity.
/// Empirically in the 15-25% range for Civ5-equivalent collectible yields;
/// 0.2 (20%) sits in the middle and keeps math simple.
const TILE_QUALITY_TO_QUANTITY_MULTIPLIER: f32 = 0.2;
/// Minimum roll fraction to yield any collectible from an entry.
/// Entries with base_chance below this are effectively disabled for the biome.
const MIN_ROLL_THRESHOLD: f32 = 0.0;
/// Maximum collectible quantity per roll capped here to prevent single-tile
/// windfalls from warping economy. Matches the "≤12 raw" cap in game design.
const MAX_QUANTITY_PER_ROLL: u32 = 12;
/// One resolved collectible drop from a tile.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CollectibleRoll {
pub resource_id: String,
/// Units of the resource yielded on this roll.
pub quantity: u32,
/// Quality class of the yield (1..=10, mirrors tile quality scale).
pub quality: u8,
}
/// Collectible table entry for a single resource in a biome's pool.
struct Entry {
resource_id: &'static str,
/// Base drop probability in `[0.0, 1.0]` at tile_quality == 1.
base_chance: f32,
/// Base quantity at tile_quality == 1 before quality multiplier.
base_quantity: u32,
}
/// Returns the collectible table for a biome. Each entry rolls independently.
/// Empty slice means no collectibles for this biome (e.g. deep ocean).
fn biome_table(biome_id: &str) -> &'static [Entry] {
use crate::grid::biome_registry::normalize_biome_id;
match normalize_biome_id(biome_id) {
"tropical_rainforest" => &[
Entry { resource_id: "hardwood", base_chance: 0.80, base_quantity: 3 },
Entry { resource_id: "rare_herbs", base_chance: 0.55, base_quantity: 2 },
Entry { resource_id: "wild_game", base_chance: 0.40, base_quantity: 1 },
],
"temperate_forest" => &[
Entry { resource_id: "hardwood", base_chance: 0.70, base_quantity: 3 },
Entry { resource_id: "wild_game", base_chance: 0.50, base_quantity: 1 },
Entry { resource_id: "mushrooms", base_chance: 0.35, base_quantity: 2 },
],
"boreal_forest" | "montane_forest" => &[
Entry { resource_id: "softwood", base_chance: 0.75, base_quantity: 3 },
Entry { resource_id: "wild_game", base_chance: 0.45, base_quantity: 1 },
Entry { resource_id: "furs", base_chance: 0.30, base_quantity: 1 },
],
"temperate_grassland" => &[
Entry { resource_id: "grain", base_chance: 0.75, base_quantity: 4 },
Entry { resource_id: "wild_game", base_chance: 0.40, base_quantity: 1 },
],
"savanna" => &[
Entry { resource_id: "wild_game", base_chance: 0.65, base_quantity: 2 },
Entry { resource_id: "ivory", base_chance: 0.20, base_quantity: 1 },
],
"hills" => &[
Entry { resource_id: "stone", base_chance: 0.80, base_quantity: 4 },
Entry { resource_id: "iron_ore", base_chance: 0.30, base_quantity: 2 },
],
"mountains" => &[
Entry { resource_id: "stone", base_chance: 0.70, base_quantity: 3 },
Entry { resource_id: "iron_ore", base_chance: 0.45, base_quantity: 2 },
Entry { resource_id: "gems", base_chance: 0.15, base_quantity: 1 },
],
"desert" | "dust_plain" | "dune_field" => &[
Entry { resource_id: "salt", base_chance: 0.60, base_quantity: 2 },
Entry { resource_id: "rare_herbs", base_chance: 0.20, base_quantity: 1 },
],
"swamp" | "bog" => &[
Entry { resource_id: "peat", base_chance: 0.70, base_quantity: 3 },
Entry { resource_id: "rare_herbs", base_chance: 0.40, base_quantity: 2 },
],
"shallow_ocean" | "coral_reef" => &[
Entry { resource_id: "fish", base_chance: 0.80, base_quantity: 3 },
Entry { resource_id: "pearls", base_chance: 0.15, base_quantity: 1 },
],
_ => &[],
}
}
/// Project biome + tile_quality into a list of collectible rolls.
///
/// `tile_quality` is the `TileState.quality` field (1..=10 typical range).
/// `rng` must be seeded deterministically by the caller.
pub fn tile_collectibles(
biome_id: &str,
tile_quality: u8,
rng: &mut SplitMix64,
) -> Vec<CollectibleRoll> {
let table = biome_table(biome_id);
let mut out = Vec::with_capacity(table.len());
// Quality multiplier: each point above 1 boosts quantity by TQTQM.
let quality_mult = 1.0 + (tile_quality.saturating_sub(1) as f32) * TILE_QUALITY_TO_QUANTITY_MULTIPLIER;
for entry in table {
if entry.base_chance <= MIN_ROLL_THRESHOLD {
continue;
}
if rng.next_unit_f32() < entry.base_chance {
let quantity = ((entry.base_quantity as f32) * quality_mult).round() as u32;
out.push(CollectibleRoll {
resource_id: entry.resource_id.to_string(),
quantity: quantity.min(MAX_QUANTITY_PER_ROLL),
quality: tile_quality,
});
}
}
out
}
// ── SplitMix64 ────────────────────────────────────────────────────────────
//
// Inline copy — matches mc-combat/src/loot.rs exactly so results are
// identical when the same seed + sequence is used. Do NOT use rand crate;
// mc-core must stay dep-free of it for WASM compat.
pub struct SplitMix64 {
state: u64,
}
impl SplitMix64 {
pub fn new(seed: u64) -> Self {
Self { state: seed }
}
pub fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
splitmix_finalize(self.state)
}
/// Uniform `f32` in `[0.0, 1.0)` from the high 24 bits of next_u64.
pub fn next_unit_f32(&mut self) -> f32 {
let bits = (self.next_u64() >> 40) as u32;
(bits as f32) / ((1u32 << 24) as f32)
}
}
fn splitmix_finalize(mut z: u64) -> u64 {
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deterministic_same_seed_same_output() {
let mut rng1 = SplitMix64::new(0xDEAD_BEEF_1234_5678);
let mut rng2 = SplitMix64::new(0xDEAD_BEEF_1234_5678);
let a = tile_collectibles("tropical_rainforest", 5, &mut rng1);
let b = tile_collectibles("tropical_rainforest", 5, &mut rng2);
assert_eq!(a, b, "same seed must produce identical rolls");
}
#[test]
fn known_roll_tropical_rainforest_quality5() {
// Seed chosen to fix a known roll sequence; update if table changes.
let mut rng = SplitMix64::new(1);
let rolls = tile_collectibles("tropical_rainforest", 5, &mut rng);
// All entries have base_chance ≥ 0.40; at seed=1 we expect some drops.
assert!(!rolls.is_empty(), "tropical_rainforest q5 should yield drops at seed=1");
for roll in &rolls {
assert!(roll.quantity <= MAX_QUANTITY_PER_ROLL);
assert_eq!(roll.quality, 5);
}
}
#[test]
fn quality_multiplier_increases_quantity() {
// Pin the rng so both calls roll the same entries, only quantity differs.
// Use a seed where hardwood drops at both quality levels.
let seed = 2u64;
let mut rng_lo = SplitMix64::new(seed);
let mut rng_hi = SplitMix64::new(seed);
let low_q = tile_collectibles("temperate_forest", 1, &mut rng_lo);
let high_q = tile_collectibles("temperate_forest", 6, &mut rng_hi);
// Same RNG stream → same entries drop; high quality should have higher quantity.
if !low_q.is_empty() && !high_q.is_empty() {
let low_total: u32 = low_q.iter().map(|r| r.quantity).sum();
let high_total: u32 = high_q.iter().map(|r| r.quantity).sum();
assert!(high_total >= low_total, "q6 total {high_total} should be ≥ q1 total {low_total}");
}
}
#[test]
fn unknown_biome_yields_empty() {
let mut rng = SplitMix64::new(42);
let rolls = tile_collectibles("void_realm", 5, &mut rng);
assert!(rolls.is_empty());
}
#[test]
fn desert_alias_resolves() {
// "dune_field" normalizes through biome table to desert group.
let mut rng1 = SplitMix64::new(99);
let mut rng2 = SplitMix64::new(99);
let d1 = tile_collectibles("desert", 3, &mut rng1);
let d2 = tile_collectibles("dune_field", 3, &mut rng2);
// Both hit the same table branch so rolls are identical for the same seed.
assert_eq!(d1, d2);
}
#[test]
fn quantity_never_exceeds_cap() {
let mut rng = SplitMix64::new(777);
// tile_quality=10 is the max quality; check cap holds.
let rolls = tile_collectibles("mountains", 10, &mut rng);
for roll in rolls {
assert!(roll.quantity <= MAX_QUANTITY_PER_ROLL, "quantity {} exceeded cap", roll.quantity);
}
}
}

View file

@ -1,3 +1,4 @@
pub mod algorithms;
pub mod collectibles;
pub mod grid;
pub mod perf;