feat(@projects/@magic-civilization): ✨ add strategic resource validation logic
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
1ecc896a9e
commit
3caf0fef32
5 changed files with 386 additions and 6 deletions
|
|
@ -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;
|
||||
|
|
|
|||
111
src/simulator/crates/mc-combat/src/requirements.rs
Normal file
111
src/simulator/crates/mc-combat/src/requirements.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue