diff --git a/src/simulator/crates/mc-combat/src/lib.rs b/src/simulator/crates/mc-combat/src/lib.rs index bff35acd..0efeede4 100644 --- a/src/simulator/crates/mc-combat/src/lib.rs +++ b/src/simulator/crates/mc-combat/src/lib.rs @@ -2,6 +2,7 @@ pub mod bonuses; pub mod keywords; pub mod loot; pub mod promotions; +pub mod requirements; pub mod resolver; pub mod siege; pub mod wilds; diff --git a/src/simulator/crates/mc-combat/src/requirements.rs b/src/simulator/crates/mc-combat/src/requirements.rs new file mode 100644 index 00000000..e6296c8d --- /dev/null +++ b/src/simulator/crates/mc-combat/src/requirements.rs @@ -0,0 +1,111 @@ +use std::collections::BTreeMap; + +/// A required strategic resource was not available in the empire ledger. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MissingResource(pub String); + +impl std::fmt::Display for MissingResource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "missing strategic resource: {}", self.0) + } +} + +/// Returns `Ok(())` if every required resource has at least one unit in the +/// ledger, otherwise `Err(MissingResource)` for the first missing entry. +/// +/// `unit_reqs` is the `requires_resource` list from unit JSON (may be empty). +/// `ledger` maps resource ID → stockpile count (decremented on build, +/// credited on unit death — see `debit_resource` / `credit_resource`). +pub fn check_strategic_reqs( + unit_reqs: &[String], + ledger: &BTreeMap, +) -> Result<(), MissingResource> { + for req in unit_reqs { + let count = ledger.get(req.as_str()).copied().unwrap_or(0); + if count == 0 { + return Err(MissingResource(req.clone())); + } + } + Ok(()) +} + +/// Deduct one unit of each required resource from the ledger on successful +/// build. Saturates at zero (cannot go negative). +pub fn debit_resources(unit_reqs: &[String], ledger: &mut BTreeMap) { + for req in unit_reqs { + let entry = ledger.entry(req.clone()).or_insert(0); + *entry = entry.saturating_sub(1); + } +} + +/// Return one unit of each required resource to the ledger when the unit dies. +pub fn credit_resources(unit_reqs: &[String], ledger: &mut BTreeMap) { + for req in unit_reqs { + *ledger.entry(req.clone()).or_insert(0) += 1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ledger(pairs: &[(&str, u32)]) -> BTreeMap { + pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect() + } + + #[test] + fn no_reqs_always_passes() { + assert!(check_strategic_reqs(&[], &BTreeMap::new()).is_ok()); + } + + #[test] + fn missing_resource_returns_err() { + let reqs = vec!["iron_ore".to_string()]; + let result = check_strategic_reqs(&reqs, &BTreeMap::new()); + assert_eq!(result, Err(MissingResource("iron_ore".to_string()))); + } + + #[test] + fn present_resource_returns_ok() { + let reqs = vec!["iron_ore".to_string()]; + let ld = ledger(&[("iron_ore", 2)]); + assert!(check_strategic_reqs(&reqs, &ld).is_ok()); + } + + #[test] + fn debit_decrements_ledger() { + let reqs = vec!["iron_ore".to_string()]; + let mut ld = ledger(&[("iron_ore", 3)]); + debit_resources(&reqs, &mut ld); + assert_eq!(ld["iron_ore"], 2); + } + + #[test] + fn debit_saturates_at_zero() { + let reqs = vec!["iron_ore".to_string()]; + let mut ld = ledger(&[("iron_ore", 0)]); + debit_resources(&reqs, &mut ld); + assert_eq!(ld["iron_ore"], 0); + } + + #[test] + fn golden_build_then_kill_restores_iron() { + let reqs = vec!["iron_ore".to_string()]; + let mut ld = ledger(&[("iron_ore", 1)]); + + // Build cavalry: check then debit + assert!(check_strategic_reqs(&reqs, &ld).is_ok()); + debit_resources(&reqs, &mut ld); + assert_eq!(ld["iron_ore"], 0); + + // Unit is now alive, ledger at 0 — another build blocked + assert!(check_strategic_reqs(&reqs, &ld).is_err()); + + // Unit dies: credit returns the resource + credit_resources(&reqs, &mut ld); + assert_eq!(ld["iron_ore"], 1); + + // Can build again + assert!(check_strategic_reqs(&reqs, &ld).is_ok()); + } +} 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; diff --git a/src/simulator/crates/mc-happiness/src/pool.rs b/src/simulator/crates/mc-happiness/src/pool.rs index 3284fdef..9a2df4a4 100644 --- a/src/simulator/crates/mc-happiness/src/pool.rs +++ b/src/simulator/crates/mc-happiness/src/pool.rs @@ -1,6 +1,7 @@ //! Happiness pool calculation, racial growth tiers, and Golden Age logic. use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; // ── Constants (match GDScript Happiness class) ───────────────────────── @@ -142,8 +143,9 @@ pub struct HappinessInput { pub ascension_active: bool, /// Total happiness from buildings (already summed by GDScript from DataLoader). pub building_happiness: i32, - /// Number of unique improved luxury resources. - pub unique_luxury_count: i32, + /// IDs of improved luxury resources owned by this player. Duplicates are + /// ignored — only unique luxury types grant happiness. + pub owned_luxuries: BTreeSet, /// Racial growth tier string (e.g., "balanced", "expansionist"). pub growth_tier: String, } @@ -175,6 +177,13 @@ pub struct GoldenAgeState { // ── Core functions ───────────────────────────────────────────────────── +/// Happiness contributed by unique luxury resources. +/// Each distinct luxury ID contributes `config.luxury_happiness`; duplicates +/// are ignored because `owned_luxuries` is a `BTreeSet`. +pub fn happiness_from_luxuries(owned_luxuries: &BTreeSet, config: &HappinessConfig) -> i32 { + owned_luxuries.len() as i32 * config.luxury_happiness +} + /// Calculate the full happiness breakdown for a player. pub fn calculate_happiness(input: &HappinessInput, config: &HappinessConfig) -> HappinessBreakdown { let tier = GrowthTier::parse_or_default(&input.growth_tier); @@ -193,7 +202,7 @@ pub fn calculate_happiness(input: &HappinessInput, config: &HappinessConfig) -> }; let base_unhappiness = city_unhappiness + citizen_unhappiness + war_weariness + ascension_penalty; - let luxury_happiness = input.unique_luxury_count * config.luxury_happiness; + let luxury_happiness = happiness_from_luxuries(&input.owned_luxuries, config); let total = (input.building_happiness + luxury_happiness) - base_unhappiness; let status = HappinessStatus::from_total(total); @@ -277,7 +286,7 @@ mod tests { units_in_enemy_territory: 0, ascension_active: false, building_happiness: 10, - unique_luxury_count: 2, + owned_luxuries: BTreeSet::from(["silk".into(), "gold_ore".into()]), growth_tier: "balanced".to_string(), } } @@ -458,7 +467,7 @@ mod tests { units_in_enemy_territory: 0, ascension_active: false, building_happiness: 0, - unique_luxury_count: 0, + owned_luxuries: BTreeSet::new(), growth_tier: String::new(), }; @@ -542,6 +551,34 @@ mod tests { assert!((get_combat_modifier(5) - 1.0).abs() < f64::EPSILON); } + // ── Luxury happiness tests ───────────────────────────────────────────── + + #[test] + fn luxury_happiness_empty_set_yields_zero() { + let config = HappinessConfig::default(); + assert_eq!(happiness_from_luxuries(&BTreeSet::new(), &config), 0); + } + + #[test] + fn luxury_happiness_three_unique_luxuries() { + let config = HappinessConfig::default(); + let luxuries = BTreeSet::from(["silk".into(), "amber".into(), "deepstone".into()]); + // 3 unique * LUXURY_HAPPINESS(4) = 12 + assert_eq!(happiness_from_luxuries(&luxuries, &config), 12); + } + + #[test] + fn luxury_happiness_duplicate_counted_once() { + let config = HappinessConfig::default(); + // BTreeSet deduplicates — inserting "silk" twice still yields one entry. + let mut luxuries = BTreeSet::new(); + luxuries.insert("silk".to_string()); + luxuries.insert("silk".to_string()); + luxuries.insert("amber".to_string()); + // 2 unique * 4 = 8, not 3 * 4 = 12 + assert_eq!(happiness_from_luxuries(&luxuries, &config), 8); + } + /// The breakdown carries both modifiers — make sure they are wired up /// from the raw helpers rather than being hardcoded/stale. #[test] @@ -557,7 +594,7 @@ mod tests { units_in_enemy_territory: 0, ascension_active: false, building_happiness: 0, - unique_luxury_count: 0, + owned_luxuries: BTreeSet::new(), growth_tier: "balanced".to_string(), }; let result = calculate_happiness(&input, &config);