diff --git a/src/simulator/crates/mc-ai/src/tactical/production.rs b/src/simulator/crates/mc-ai/src/tactical/production.rs index 0fb6ed2c..b8c5c671 100644 --- a/src/simulator/crates/mc-ai/src/tactical/production.rs +++ b/src/simulator/crates/mc-ai/src/tactical/production.rs @@ -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 = + (1..=own_units).map(|i| warrior(i, (1, 1))).collect(); + let attacker_units: Vec = + (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: