From 968c8557637ea07d418b114efb46db797a192d06 Mon Sep 17 00:00:00 2001 From: autocommit Date: Thu, 16 Apr 2026 21:51:21 -0700 Subject: [PATCH] =?UTF-8?q?feat(simulator):=20=E2=9C=A8=20Add=20collectibl?= =?UTF-8?q?es=20module=20with=20new=20collectible=20entity=20management=20?= =?UTF-8?q?functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../crates/mc-core/src/collectibles.rs | 230 ++++++++++++++++++ src/simulator/crates/mc-core/src/lib.rs | 1 + 2 files changed, 231 insertions(+) create mode 100644 src/simulator/crates/mc-core/src/collectibles.rs diff --git a/src/simulator/crates/mc-core/src/collectibles.rs b/src/simulator/crates/mc-core/src/collectibles.rs new file mode 100644 index 00000000..a4a99297 --- /dev/null +++ b/src/simulator/crates/mc-core/src/collectibles.rs @@ -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 { + 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); + } + } +} diff --git a/src/simulator/crates/mc-core/src/lib.rs b/src/simulator/crates/mc-core/src/lib.rs index 3bf3c0bb..1ba885b1 100644 --- a/src/simulator/crates/mc-core/src/lib.rs +++ b/src/simulator/crates/mc-core/src/lib.rs @@ -1,3 +1,4 @@ pub mod algorithms; +pub mod collectibles; pub mod grid; pub mod perf;