perf(tactical): Improve early-game AI aggression by optimizing dominance thresholds and production logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-26 14:52:10 -07:00
parent d49b4c0fa6
commit 9006ab3bb1
3 changed files with 40 additions and 16 deletions

View file

@ -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

View file

@ -161,6 +161,14 @@ pub struct TacticalUnit {
pub patrol_order: Option<Vec<(i32, i32)>>,
}
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).
///

View file

@ -75,11 +75,15 @@ pub fn derived_defense(axes: &BTreeMap<String, i32>) -> 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<String, i32>) -> 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<String, i32> = 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.52.5).
assert!(df_low >= 1.49 && df_low <= 2.51);
assert!(df_high >= 1.49 && df_high <= 2.51);
}
#[test]