feat(mc-happiness): Introduce HappinessPool system for character tracking, behavior, and progression mechanics

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-16 17:49:18 -07:00
parent 46d16c902b
commit edd549e26c

View file

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