diff --git a/src/simulator/tests/integration/Cargo.toml b/src/simulator/tests/integration/Cargo.toml new file mode 100644 index 00000000..8d1711c7 --- /dev/null +++ b/src/simulator/tests/integration/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "mc-golden-tests" +version = "0.1.0" +edition = "2021" + +# Integration-level golden tests spanning mc-core → mc-city → mc-happiness → mc-combat. +# Each test file is a Rust integration test (in tests/) — requires the crates to be +# compiled as libraries, which they are. + +[dev-dependencies] +mc-core = { path = "../../crates/mc-core" } +mc-city = { path = "../../crates/mc-city" } +mc-happiness = { path = "../../crates/mc-happiness" } +mc-combat = { path = "../../crates/mc-combat" } diff --git a/src/simulator/tests/integration/src/lib.rs b/src/simulator/tests/integration/src/lib.rs new file mode 100644 index 00000000..f970969c --- /dev/null +++ b/src/simulator/tests/integration/src/lib.rs @@ -0,0 +1 @@ +// Stub — tests live in tests/*.rs integration test files. diff --git a/src/simulator/tests/integration/tests/biome_yield_golden.rs b/src/simulator/tests/integration/tests/biome_yield_golden.rs new file mode 100644 index 00000000..0214f167 --- /dev/null +++ b/src/simulator/tests/integration/tests/biome_yield_golden.rs @@ -0,0 +1,78 @@ +//! Golden test: biome×quality → tile_collectibles → city get_yields +//! +//! Frozen vector (seed=7, biome="temperate_forest", quality=3): +//! hardwood qty=4 (roll=0.3898 < 0.70 → hit) +//! wild_game qty=1 (roll=0.0168 < 0.50 → hit) +//! mushrooms (roll=0.9008 < 0.35 → miss) +//! +//! If this test breaks, a change to the collectibles table or SplitMix64 +//! sequence altered deterministic output — update the frozen vector and +//! document why in the commit message. + +use mc_city::city::{City, TileYield, CITY_CENTER_BASELINE_FOOD, CITY_CENTER_BASELINE_PRODUCTION}; +use mc_core::collectibles::{tile_collectibles, CollectibleRoll, SplitMix64}; + +const SEED: u64 = 7; +const BIOME: &str = "temperate_forest"; +const QUALITY: u8 = 3; + +fn frozen_rolls() -> Vec { + vec![ + CollectibleRoll { resource_id: "hardwood".into(), quantity: 4, quality: QUALITY }, + CollectibleRoll { resource_id: "wild_game".into(), quantity: 1, quality: QUALITY }, + ] +} + +#[test] +fn tile_collectibles_matches_frozen_vector() { + let mut rng = SplitMix64::new(SEED); + let rolls = tile_collectibles(BIOME, QUALITY, &mut rng); + + assert_eq!( + rolls, frozen_rolls(), + "seed={SEED} biome={BIOME} quality={QUALITY}: roll vector changed — update frozen_rolls() if intentional" + ); +} + +#[test] +fn city_get_yields_folds_collectibles_correctly() { + // quality_mult = 1 + (3-1)*0.2 = 1.4 + // hardwood → Production: 4 * 1.4 = 5.6 + // wild_game → Food: 1 * 1.4 = 1.4 + let tile = TileYield { + coord: (0, 0), + food: 0.0, + production: 0.0, + gold: 0.0, + culture: 0.0, + science: 0.0, + collectibles: frozen_rolls(), + }; + + let mut city = City::new("khazad"); + city.worked_tiles = vec![(0, 0)]; + + let yields = city.get_yields(&[tile]); + + let expected_food = CITY_CENTER_BASELINE_FOOD + 1.4; + let expected_prod = CITY_CENTER_BASELINE_PRODUCTION + 5.6; + + assert!( + (yields.food - expected_food).abs() < 1e-9, + "food: expected {expected_food}, got {}", yields.food + ); + assert!( + (yields.production - expected_prod).abs() < 1e-9, + "production: expected {expected_prod}, got {}", yields.production + ); +} + +#[test] +fn determinism_same_seed_same_rolls() { + let mut rng_a = SplitMix64::new(SEED); + let mut rng_b = SplitMix64::new(SEED); + assert_eq!( + tile_collectibles(BIOME, QUALITY, &mut rng_a), + tile_collectibles(BIOME, QUALITY, &mut rng_b) + ); +} diff --git a/src/simulator/tests/integration/tests/happiness_luxury_golden.rs b/src/simulator/tests/integration/tests/happiness_luxury_golden.rs new file mode 100644 index 00000000..246cc73e --- /dev/null +++ b/src/simulator/tests/integration/tests/happiness_luxury_golden.rs @@ -0,0 +1,60 @@ +//! Golden test: luxury happiness calculation. +//! +//! Frozen expectation: +//! owned_luxuries = {"furs", "salt", "wine"} (3 unique entries) +//! LUXURY_HAPPINESS = 4 per unique luxury +//! expected = 3 * 4 = 12 +//! +//! Note: these IDs are deposit-level IDs as referenced by biome collectible +//! tables (furs from boreal_forest, salt from desert). Concept-resolution +//! (deposit_id → resource concept) is pending a future concept_resource field +//! in resources.json — for now happiness lookups use deposit IDs directly. + +use mc_happiness::pool::{happiness_from_luxuries, HappinessConfig, LUXURY_HAPPINESS}; +use std::collections::BTreeSet; + +fn owned() -> BTreeSet { + BTreeSet::from(["furs".to_string(), "salt".to_string(), "wine".to_string()]) +} + +#[test] +fn three_luxuries_yield_twelve_happiness() { + let config = HappinessConfig::default(); + // Frozen: 3 unique * LUXURY_HAPPINESS(4) = 12 + assert_eq!(LUXURY_HAPPINESS, 4, "LUXURY_HAPPINESS constant changed — update frozen expectation"); + let result = happiness_from_luxuries(&owned(), &config); + assert_eq!(result, 12, "happiness_from_luxuries({{furs, salt, wine}}) must equal 12"); +} + +#[test] +fn empty_luxuries_yield_zero() { + let config = HappinessConfig::default(); + assert_eq!(happiness_from_luxuries(&BTreeSet::new(), &config), 0); +} + +#[test] +fn duplicate_luxury_counted_once() { + let config = HappinessConfig::default(); + // BTreeSet deduplicates — inserting "furs" twice still counts as 1 + let mut luxuries = BTreeSet::new(); + luxuries.insert("furs".to_string()); + luxuries.insert("furs".to_string()); + luxuries.insert("salt".to_string()); + // 2 unique * 4 = 8 + assert_eq!(happiness_from_luxuries(&luxuries, &config), 8); +} + +#[test] +fn happiness_scales_linearly_with_unique_count() { + let config = HappinessConfig::default(); + for n in 0_usize..=6 { + let luxuries: BTreeSet = + (0..n).map(|i| format!("luxury_{i}")).collect(); + let expected = (n as i32) * config.luxury_happiness; + assert_eq!( + happiness_from_luxuries(&luxuries, &config), + expected, + "n={n} luxuries should give {expected} happiness" + ); + } +} diff --git a/src/simulator/tests/integration/tests/strategic_gate_golden.rs b/src/simulator/tests/integration/tests/strategic_gate_golden.rs new file mode 100644 index 00000000..c859fb70 --- /dev/null +++ b/src/simulator/tests/integration/tests/strategic_gate_golden.rs @@ -0,0 +1,62 @@ +//! Golden test: strategic resource gate — build cavalry (requires iron), +//! debit, second build fails, unit dies → iron credited → build succeeds. +//! +//! Tests check_strategic_reqs / debit_resources / credit_resources from +//! mc-combat::requirements across the full lifecycle. + +use mc_combat::requirements::{check_strategic_reqs, credit_resources, debit_resources, MissingResource}; +use std::collections::BTreeMap; + +fn ledger(pairs: &[(&str, u32)]) -> BTreeMap { + pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect() +} + +const IRON: &str = "iron_ore"; + +#[test] +fn cavalry_build_debit_block_kill_credit_cycle() { + let reqs = vec![IRON.to_string()]; + let mut ld = ledger(&[(IRON, 1)]); + + // Check: iron available → build allowed + assert!(check_strategic_reqs(&reqs, &ld).is_ok(), "iron present, build should be allowed"); + + // Debit: consume iron on build + debit_resources(&reqs, &mut ld); + assert_eq!(ld[IRON], 0, "iron debited to 0 after build"); + + // Second build blocked: ledger at zero + assert_eq!( + check_strategic_reqs(&reqs, &ld), + Err(MissingResource(IRON.to_string())), + "second cavalry build must be blocked while first is alive" + ); + + // Unit dies: iron returned + credit_resources(&reqs, &mut ld); + assert_eq!(ld[IRON], 1, "iron credited back on unit death"); + + // Build allowed again + assert!(check_strategic_reqs(&reqs, &ld).is_ok(), "iron restored, build should succeed again"); +} + +#[test] +fn no_reqs_never_blocks() { + let ld = BTreeMap::new(); + assert!(check_strategic_reqs(&[], &ld).is_ok()); +} + +#[test] +fn missing_resource_error_names_resource() { + let reqs = vec!["horses".to_string()]; + let err = check_strategic_reqs(&reqs, &BTreeMap::new()).unwrap_err(); + assert_eq!(err, MissingResource("horses".to_string())); +} + +#[test] +fn debit_saturates_at_zero() { + let reqs = vec![IRON.to_string()]; + let mut ld = ledger(&[(IRON, 0)]); + debit_resources(&reqs, &mut ld); + assert_eq!(ld[IRON], 0, "debit must not underflow past zero"); +}