feat(simulator): ✨ Add collectibles module with new collectible entity management functionality
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
8a96cf3550
commit
968c855763
2 changed files with 231 additions and 0 deletions
230
src/simulator/crates/mc-core/src/collectibles.rs
Normal file
230
src/simulator/crates/mc-core/src/collectibles.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod algorithms;
|
||||
pub mod collectibles;
|
||||
pub mod grid;
|
||||
pub mod perf;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue