diff --git a/src/simulator/crates/mc-ai/src/tactical/production.rs b/src/simulator/crates/mc-ai/src/tactical/production.rs index d13a8978..8826869d 100644 --- a/src/simulator/crates/mc-ai/src/tactical/production.rs +++ b/src/simulator/crates/mc-ai/src/tactical/production.rs @@ -22,7 +22,7 @@ use std::time::Instant; use crate::evaluator::ScoringWeights; use crate::mcts::XorShift64; -use super::{Action, TacticalCity, TacticalPlayerState, TacticalState}; +use super::{Action, TacticalCity, TacticalPlayerState, TacticalState, TacticalUnit}; /// Gold floor required before the AI commits to a rush-buy assault unit. /// @@ -63,7 +63,16 @@ const CULTURE_AXIS_MONUMENT_THRESHOLD: u8 = 4; /// Aggression multiplier above which the player counts as dominant over the /// opposing field (mirrors GDScript DOMINANCE_FACTOR). -const DOMINANCE_FACTOR: f32 = 1.25; +/// +/// Bumped 2026-04-26 from 1.25 → 2.0 to slow rush-domination dynamics. +/// Pre-bump observation (warcouncil cycle-3 wonder6 batch): 5/10 games ended +/// at T48-T121 via early-domination before tier-3 tech research could complete, +/// leaving median peak_unit_tier at 2 and tier_peak_gap at 5-6 (one AI +/// monopolizes tech tree). At 2.0× the AI requires real military superiority +/// before committing to attack, giving losers more time to develop and +/// closing the symmetry gap. Composes with personality `aggression` axis +/// (blackhammer agg=9 still rushes via apply_axes military scaling). +const DOMINANCE_FACTOR: f32 = 2.0; /// Capital walls interject: non-threatened 1-city capital older than this /// many turns slots walls in before the general fallback. @@ -276,7 +285,7 @@ fn pick_for_city( // founders — mirrors `city_index == 0` gate. let expansion_target = (axes.expansion as u32 / 3).clamp(1, 5); if city_count < expansion_target && city.is_capital { - let founder_count = player.units.iter().filter(|u| is_founder(&u.kind)).count() as u32; + let founder_count = player.units.iter().filter(|u| is_founder(u)).count() as u32; if founder_count == 0 { return ids::FOUNDER.into(); } @@ -406,16 +415,16 @@ fn count_military_units(player: &TacticalPlayerState) -> u32 { player .units .iter() - .filter(|u| is_military(&u.kind)) + .filter(|u| is_military(u)) .count() as u32 } -fn is_military(kind: &str) -> bool { - !matches!(kind, "founder" | "settler" | "worker") +fn is_military(unit: &TacticalUnit) -> bool { + !unit.is_founder() && unit.kind != "worker" } -fn is_founder(kind: &str) -> bool { - matches!(kind, "founder" | "settler") +fn is_founder(unit: &TacticalUnit) -> bool { + unit.is_founder() } /// Max military count across all opponents. GDScript uses total enemy diff --git a/src/simulator/crates/mc-ai/src/tactical/state.rs b/src/simulator/crates/mc-ai/src/tactical/state.rs index 1028a6a4..1792603c 100644 --- a/src/simulator/crates/mc-ai/src/tactical/state.rs +++ b/src/simulator/crates/mc-ai/src/tactical/state.rs @@ -161,6 +161,14 @@ pub struct TacticalUnit { pub patrol_order: Option>, } +impl TacticalUnit { + /// True when this unit can found a city. Checks the data-driven flag first; + /// falls back to kind-string matching for test fixtures that omit the flag. + pub fn is_founder(&self) -> bool { + self.can_found_city || matches!(self.kind.as_str(), "settler" | "founder") + } +} + /// Specification for a producible military unit — carries enough data for the /// production layer to select tier-appropriate units as tech unlocks (p0-39). /// diff --git a/src/simulator/crates/mc-ai/src/tactical/thresholds.rs b/src/simulator/crates/mc-ai/src/tactical/thresholds.rs index b8b455a0..95979ac8 100644 --- a/src/simulator/crates/mc-ai/src/tactical/thresholds.rs +++ b/src/simulator/crates/mc-ai/src/tactical/thresholds.rs @@ -75,11 +75,15 @@ pub fn derived_defense(axes: &BTreeMap) -> i32 { /// to capital-assault commitment. Aggressive clans commit earlier; cautious /// clans wait for real superiority. /// -/// Range: `axis=1` → 1.80 (very cautious), `axis=5` → 1.50 (post-p0-37+39 -/// tempo baseline), `axis=10` → 1.15 (rush-happy). Baseline raised 2026-04-18 -/// from 1.25 so games reach T250+ — tier-3+ tech chains need the runway. +/// Range: `axis=1` → 2.5 (very cautious), `axis=5` → 2.0 (baseline), +/// `axis=10` → 1.5 (rush-happy). Baseline raised 2026-04-26 from 1.50 to 2.0 +/// (and rush-happy floor from 1.15 → 1.5) per warcouncil cycle-3 finding: +/// games still ended T48-T121 in 50% of seeds because even rush-happy +/// blackhammer (agg=9) needed only ~1.2× superiority before committing, +/// triggering rush-domination before tier-3 tech could research. Higher +/// thresholds give losers economic + tech runway. pub fn dominance_factor(axes: &BTreeMap) -> f32 { - lerp_axis(axis(axes, "aggression"), 1.80, 1.50, 1.15) + lerp_axis(axis(axes, "aggression"), 2.5, 2.0, 1.5) } /// Hex radius within which a unit bypasses stray-unit chasing to march on @@ -191,7 +195,9 @@ mod tests { #[test] fn dominance_factor_baseline_matches_historical() { let a = axes(&[("aggression", 5)]); - assert!((dominance_factor(&a) - 1.50).abs() < 1e-6); + // Baseline raised 2026-04-26 from 1.50 → 2.0 (warcouncil cycle-3 + // rush-domination dampening). See dominance_factor() docs. + assert!((dominance_factor(&a) - 2.0).abs() < 1e-6); } #[test] @@ -242,7 +248,7 @@ mod tests { #[test] fn empty_axes_match_historical_baseline() { let empty: BTreeMap = BTreeMap::new(); - assert!((dominance_factor(&empty) - 1.50).abs() < 1e-6); + assert!((dominance_factor(&empty) - 2.0).abs() < 1e-6); assert_eq!(capital_approach_hex(&empty), 16); assert!((retreat_hp_fraction(&empty) - 0.40).abs() < 1e-6); assert_eq!(defensive_chase_range(&empty), 12); @@ -304,8 +310,9 @@ mod tests { let insane_high = axes(&[("aggression", 999)]); let df_low = dominance_factor(&insane_low); let df_high = dominance_factor(&insane_high); - assert!(df_low >= 1.14 && df_low <= 1.81); - assert!(df_high >= 1.14 && df_high <= 1.81); + // Range bumped 2026-04-26 alongside dominance_factor lerp (1.5–2.5). + assert!(df_low >= 1.49 && df_low <= 2.51); + assert!(df_high >= 1.49 && df_high <= 2.51); } #[test]