feat(tactical): Implement sole-city economy strategy to prioritize production for a single critical city during threats

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-27 20:26:00 -07:00
parent 2637b79e15
commit f0ae52a746

View file

@ -78,6 +78,22 @@ const DOMINANCE_FACTOR: f32 = 2.0;
/// many turns slots walls in before the general fallback.
const CAPITAL_WALLS_MIN_AGE_TURNS: u32 = 20;
/// (p1-29e) Sole-city economy break-out. A *threatened* sole-city AI hits
/// `Posture::Threatened` and returns `melee_id` at step 1 every turn, so it
/// never reaches the step-7 building scorer — RL divergence mining of
/// `duel-v4-encfix-s7` plus apricot batch `20260516_183534` confirmed the
/// trailing AI (P1) built ZERO buildings in 10/10 p1-29d seeds, capping it at
/// `tier_peak = 1`. The trained policy's winning economy lever is a production
/// building (it picked `forge` across 600 probed decisions and *never* a
/// science building — so the prior p1-29b science uplift was both misdirected
/// and unreachable here). These two values let the trailing AI interleave
/// economy: build at least `SOLE_CITY_ECON_MIN_DEFENDERS` defenders first (so
/// it is never left undefended), then slot production/infrastructure buildings
/// until the city holds `SOLE_CITY_ECON_TARGET` of them, before resuming the
/// normal military/expansion ladder.
const SOLE_CITY_ECON_MIN_DEFENDERS: u32 = 2;
const SOLE_CITY_ECON_TARGET: usize = 2;
/// Unit-side fallback ids. Building selection is catalog-driven via
/// [`pick_building_from_catalog`] (p1-42); these constants only cover the
/// unit-side fallbacks that survive when `unit_catalog` is empty (legacy
@ -293,6 +309,30 @@ fn pick_for_city(
dominance_factor_t,
);
// 0. (p1-29e) Sole-city economy break-out — see SOLE_CITY_ECON_* docs.
// Interject ONE production/infrastructure building for a threatened
// sole-city AI once a minimal defender floor is met, so it escapes the
// step-1 perpetual-military loop and can scale toward tier 2. Gated on
// `sole_city_threatened`, so multi-city and unthreatened players are
// completely unaffected — no regression to the existing ladder.
if sole_city_threatened
&& own_mil >= SOLE_CITY_ECON_MIN_DEFENDERS
&& city.buildings.len() < SOLE_CITY_ECON_TARGET
{
if let Some(id) = pick_building_from_catalog(
city,
player,
building_catalog,
weights,
axes,
BuildingPosture::Production,
sole_city_threatened,
&player.building_priors,
) {
return id;
}
}
// 1. Threat preemption (GDScript Priority 0-A).
if posture == Posture::Threatened {
return melee_id.into();
@ -1316,6 +1356,87 @@ mod tests {
assert_eq!(first_item(&out), ids::WARRIOR);
}
// ── Sole-city economy break-out (p1-29e) ────────────────────────────
/// A *threatened sole-city* AI with the minimal defender floor met and
/// fewer than SOLE_CITY_ECON_TARGET buildings must escape the step-1
/// perpetual-military loop and slot a production building. Without this,
/// P1 built ZERO buildings in 10/10 p1-29d seeds (capped at tier_peak=1).
fn econ_breakout_state(
own_units: u32,
buildings: &[&str],
cities: u32,
) -> TacticalState {
let defender_units: Vec<TacticalUnit> =
(1..=own_units).map(|i| warrior(i, (1, 1))).collect();
let attacker_units: Vec<TacticalUnit> =
(100..105).map(|i| warrior(i, (9, 9))).collect();
let mut city_list =
vec![city(10, (1, 1), 3, buildings, &[], true)];
for c in 1..cities {
city_list.push(city(10 + c, (5 + c as i32, 5), 2, buildings, &[], false));
}
let mut s = state(
0,
50,
vec![
player(0, "ironhold", defender_units, city_list),
player(1, "blackhammer", attacker_units, Vec::new()),
],
);
s.building_catalog = ladder_catalog();
s
}
#[test]
fn sole_city_econ_breakout_builds_production_when_threatened() {
// own_mil=2 (>= floor), 1 building (< target=2), sole city, threatened
// by 5 attackers → break-out fires → forge (lone production entry).
let s = econ_breakout_state(2, &["walls"], 1);
let out = decide_production(&s, &weights(), &mut rng(), None);
assert_eq!(
first_item(&out),
"forge",
"threatened sole-city AI must break out of the military loop to \
build economy (p1-29e)"
);
}
#[test]
fn sole_city_econ_breakout_stops_at_target() {
// 2 buildings already (== target) → break-out does NOT fire; the
// step-1 threat preemption resumes and the city builds a defender.
let s = econ_breakout_state(2, &["walls", "forge"], 1);
let out = decide_production(&s, &weights(), &mut rng(), None);
assert_eq!(first_item(&out), ids::WARRIOR);
}
#[test]
fn sole_city_econ_breakout_requires_min_defenders() {
// own_mil=1 (< floor=2) → AI is too undefended to spend on economy;
// threat preemption builds a defender first.
let s = econ_breakout_state(1, &["walls"], 1);
let out = decide_production(&s, &weights(), &mut rng(), None);
assert_eq!(first_item(&out), ids::WARRIOR);
}
#[test]
fn multi_city_threatened_unaffected_by_econ_breakout() {
// 2 cities → sole_city_threatened=false → break-out skipped; threat
// preemption builds a defender (no economy interjection). Pins that
// the patch never touches multi-city players.
let s = econ_breakout_state(2, &["walls"], 2);
let out = decide_production(&s, &weights(), &mut rng(), None);
for a in &out {
if let Action::EnqueueBuild { item_id, .. } = a {
assert_eq!(
item_id, ids::WARRIOR,
"multi-city threatened players must still build military"
);
}
}
}
#[test]
fn production_axis_8_slots_forge_after_mil_floor() {
// deepforge (production=8) with the legacy ladder catalog injected: