From edd549e26caf49de2341f78962efeb9293bbb00d Mon Sep 17 00:00:00 2001 From: autocommit Date: Thu, 16 Apr 2026 17:49:18 -0700 Subject: [PATCH] =?UTF-8?q?feat(mc-happiness):=20=E2=9C=A8=20Introduce=20H?= =?UTF-8?q?appinessPool=20system=20for=20character=20tracking,=20behavior,?= =?UTF-8?q?=20and=20progression=20mechanics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/simulator/crates/mc-happiness/src/pool.rs | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/src/simulator/crates/mc-happiness/src/pool.rs b/src/simulator/crates/mc-happiness/src/pool.rs index 0a9c3ed5..3284fdef 100644 --- a/src/simulator/crates/mc-happiness/src/pool.rs +++ b/src/simulator/crates/mc-happiness/src/pool.rs @@ -436,4 +436,134 @@ mod tests { assert!(!triggered); assert_eq!(state.golden_age_progress, 50); // unchanged } + + // ── T11: tier distinctness, fallback, and modifier triggers ──────────── + + /// With a matched city-heavy input, the three canonical tiers produce + /// distinct totals. The city-scaling axis (city_unhappiness) orders as + /// concentrated (2.0x) > balanced (1.0x) > expansionist (0.5x), so the + /// total (which subtracts city_unhappiness) moves the opposite way: + /// concentrated_total < balanced_total < expansionist_total. + /// + /// This locks the design intent: more cities cost more happiness for + /// concentrated races, fewer for expansionists. + #[test] + fn tier_distinctness_city_heavy_scenario() { + let config = HappinessConfig::default(); + // 10 cities, only 10 citizens: citizen_mult variance is tiny compared + // to city_mult variance, so total ordering is dominated by city_mult. + let base = HappinessInput { + city_count: 10, + total_citizens: 10, + units_in_enemy_territory: 0, + ascension_active: false, + building_happiness: 0, + unique_luxury_count: 0, + growth_tier: String::new(), + }; + + let concentrated = calculate_happiness( + &HappinessInput { growth_tier: "concentrated".into(), ..base.clone() }, + &config, + ) + .total; + let balanced = calculate_happiness( + &HappinessInput { growth_tier: "balanced".into(), ..base.clone() }, + &config, + ) + .total; + let expansionist = calculate_happiness( + &HappinessInput { growth_tier: "expansionist".into(), ..base.clone() }, + &config, + ) + .total; + + assert_ne!(concentrated, balanced, "concentrated should differ from balanced"); + assert_ne!(balanced, expansionist, "balanced should differ from expansionist"); + assert_ne!(concentrated, expansionist, "concentrated should differ from expansionist"); + // Intended ordering for city-count scaling of happiness total. + assert!( + concentrated < balanced, + "concentrated ({concentrated}) should be less than balanced ({balanced}) in city-heavy setup" + ); + assert!( + balanced < expansionist, + "balanced ({balanced}) should be less than expansionist ({expansionist}) in city-heavy setup" + ); + } + + /// Unknown / malformed tier strings must fall back to Balanced — never to + /// any other tier. Regressing this would silently shift a whole race's + /// happiness curve if a typo lands in races.json. + #[test] + fn unknown_tier_falls_back_to_balanced() { + assert_eq!(GrowthTier::parse_or_default("gibberish"), GrowthTier::Balanced); + assert_eq!(GrowthTier::parse_or_default(""), GrowthTier::Balanced); + assert_eq!(GrowthTier::parse_or_default("Balanced"), GrowthTier::Balanced); // case-sensitive + assert_eq!(GrowthTier::parse_or_default("EXPANSIONIST"), GrowthTier::Balanced); + assert_eq!(GrowthTier::parse_or_default("expansionist "), GrowthTier::Balanced); + // Known lower-case variants do NOT fall back. + assert_eq!(GrowthTier::parse_or_default("expansionist"), GrowthTier::Expansionist); + assert_eq!(GrowthTier::parse_or_default("concentrated"), GrowthTier::Concentrated); + assert_eq!(GrowthTier::parse_or_default("settled"), GrowthTier::Settled); + assert_eq!(GrowthTier::parse_or_default("roaming"), GrowthTier::Roaming); + // The fallback is explicitly Balanced — *not* Expansionist (which is + // the variant listed first in the enum). + assert_ne!(GrowthTier::parse_or_default("bogus"), GrowthTier::Expansionist); + } + + /// Growth modifier drops to 0.50 ("halted growth") once happiness is + /// meaningfully negative but above the revolt threshold. -10 is exactly + /// the revolt boundary, so its modifier should still be 0.50 (unhappy), + /// not 0.0 (revolt). Locks the piecewise boundary. + #[test] + fn growth_mod_triggers_at_negative_happiness() { + // Anchor point from the task: -10 → 0.50. + assert!((get_growth_modifier(-10) - 0.50).abs() < f64::EPSILON); + // One above the threshold is still unhappy. + assert!((get_growth_modifier(-1) - 0.50).abs() < f64::EPSILON); + // One below the threshold switches to full revolt (0.0). + assert!((get_growth_modifier(-11) - 0.0).abs() < f64::EPSILON); + // Positive is the Ecstatic/Happy multiplier (1.25), not 1.0. + assert!((get_growth_modifier(1) - 1.25).abs() < f64::EPSILON); + // Exactly zero is neutral. + assert!((get_growth_modifier(0) - 1.0).abs() < f64::EPSILON); + } + + /// Combat modifier is 0.75 whenever happiness is strictly negative and + /// 1.0 otherwise. The task anchors "happiness < 0 → 0.75". + #[test] + fn combat_mod_triggers_below_zero_happiness() { + assert!((get_combat_modifier(-1) - 0.75).abs() < f64::EPSILON); + assert!((get_combat_modifier(-10) - 0.75).abs() < f64::EPSILON); + assert!((get_combat_modifier(-100) - 0.75).abs() < f64::EPSILON); + // Zero is not negative; combat stays at full strength. + assert!((get_combat_modifier(0) - 1.0).abs() < f64::EPSILON); + assert!((get_combat_modifier(5) - 1.0).abs() < f64::EPSILON); + } + + /// The breakdown carries both modifiers — make sure they are wired up + /// from the raw helpers rather than being hardcoded/stale. + #[test] + fn breakdown_surfaces_modifier_triggers() { + let config = HappinessConfig::default(); + // Deliberately construct a -10 happiness scenario: + // city_unhappiness = 3 * 3 * 1.0 = 9 + // citizen_unhappiness = 4 * 1 * 1.0 = 4 + // total = 0 + 0 - (9+4) = -13 → growth 0.0 (revolt zone), combat 0.75. + let input = HappinessInput { + city_count: 3, + total_citizens: 4, + units_in_enemy_territory: 0, + ascension_active: false, + building_happiness: 0, + unique_luxury_count: 0, + growth_tier: "balanced".to_string(), + }; + let result = calculate_happiness(&input, &config); + assert!(result.total < 0); + assert!((result.combat_modifier - 0.75).abs() < f64::EPSILON); + // Below revolt threshold: growth fully halted. + assert!((result.growth_modifier - 0.0).abs() < f64::EPSILON); + } }