feat(@projects/@magic-civilization): add strategic resource validation logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-16 21:48:10 -07:00
parent 1ecc896a9e
commit 3caf0fef32
5 changed files with 386 additions and 6 deletions

View file

@ -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;

View file

@ -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<String, u32>,
) -> 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<String, u32>) {
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<String, u32>) {
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<String, u32> {
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());
}
}

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;

View file

@ -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<String>,
/// 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<String>, 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);