diff --git a/src/simulator/crates/mc-ai/src/tactical/movement.rs b/src/simulator/crates/mc-ai/src/tactical/movement.rs index 50423897..5b8fa527 100644 --- a/src/simulator/crates/mc-ai/src/tactical/movement.rs +++ b/src/simulator/crates/mc-ai/src/tactical/movement.rs @@ -123,7 +123,7 @@ pub(crate) fn decide_movement( decide_military_action( unit, me, &enemy_units, &enemy_city_positions, own_mil_count, enemy_mil_count, weights, rng, - state.difficulty_threshold_mult, + state.difficulty_threshold_mult, is_trailing, ) } else { None // deployed but no target: hold @@ -142,6 +142,7 @@ pub(crate) fn decide_movement( weights, rng, state.difficulty_threshold_mult, + is_trailing, ); if primary.is_some() { primary @@ -385,6 +386,35 @@ fn count_military(units: &[TacticalUnit]) -> usize { units.iter().filter(|u| is_military(u)).count() } +/// Whether `me` is the trailing AI on this turn. +/// +/// `true` iff (a) at least one alive rival already has 2+ cities (gate that +/// filters early-game equal-start ties where everyone has 1 city), AND +/// (b) `me`'s total population is the minimum among all alive players +/// (including `me`). Used by `decide_military_action` to gate sole-city +/// turtling so only the genuine underdog goes defensive — without this gate +/// every clan at 1 city turtles and the whole map stalls at tier_peak=1. +fn compute_is_trailing(state: &TacticalState, me: &TacticalPlayerState) -> bool { + let total_pop = |p: &TacticalPlayerState| -> u32 { + p.cities.iter().map(|c| c.population).sum() + }; + let my_pop = total_pop(me); + let mut anyone_multi_city = false; + let mut anyone_below_me = false; + for (i, p) in state.players.iter().enumerate() { + if i == me.index as usize || p.cities.is_empty() { + continue; + } + if p.cities.len() >= 2 { + anyone_multi_city = true; + } + if total_pop(p) < my_pop { + anyone_below_me = true; + } + } + anyone_multi_city && !anyone_below_me +} + fn count_own_military_at(me: &TacticalPlayerState, pos: (i32, i32)) -> usize { me.units .iter() @@ -500,6 +530,7 @@ fn decide_military_action( weights: &ScoringWeights, _rng: &mut XorShift64, difficulty_threshold_mult: f32, + is_trailing: bool, ) -> Option { let hp_frac = unit.hp as f32 / unit.hp_max.max(1) as f32; let nearest_enemy = nearest_enemy_unit(unit.hex, enemy_units); @@ -560,14 +591,16 @@ fn decide_military_action( } else { retreat_hp_fraction }; - // p1-29d — when we have exactly one city AND the enemy field outnumbers - // our own, units stay much more cautious: retreat at full HP and never - // press forward into a likely losing engagement. Composes additively on - // top of the personality-axis-driven base so aggressive clans still - // engage when they have parity, but a trailing AI no longer feeds the - // leader's kill count before being able to mass a real defense force. + // p1-29d — sole-city turtling for the *trailing* AI only (`is_trailing` + // is computed by `decide_movement` from full TacticalState: lowest total + // population AND at least one rival already multi-city). Without the + // is_trailing gate, every clan at 1 city in early game qualifies and the + // entire map turtles → no one expands → tier_peak=1 universal + // (huge-map batch 20260516_191254). When the gate fires, retreat HP + // threshold goes up by +0.30 (cap 0.90) so the trailing player stops + // feeding the leader's kill count before massing a real defense. let sole_city_threatened = - me.cities.len() == 1 && enemy_mil_count >= own_mil_count.max(1); + is_trailing && me.cities.len() == 1 && enemy_mil_count >= own_mil_count.max(1); let base_retreat_hp = if sole_city_threatened { (base_retreat_hp + 0.30).min(0.90) } else {