feat(@mc-ai): add trailing ai detection logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-16 19:49:37 -07:00
parent 69ca69ee06
commit acaa6792f0

View file

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