diff --git a/src/simulator/crates/mc-ai/src/tactical/production.rs b/src/simulator/crates/mc-ai/src/tactical/production.rs index b8c5c671..cd6bb670 100644 --- a/src/simulator/crates/mc-ai/src/tactical/production.rs +++ b/src/simulator/crates/mc-ai/src/tactical/production.rs @@ -86,12 +86,28 @@ const CAPITAL_WALLS_MIN_AGE_TURNS: u32 = 20; /// `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; +/// and unreachable here). These values let the trailing AI interleave economy: +/// slot production/infrastructure buildings until the city holds +/// `SOLE_CITY_ECON_TARGET` of them, before resuming the normal +/// military/expansion ladder. +/// +/// **`SOLE_CITY_ECON_MIN_DEFENDERS = 0` is deliberate — do NOT "fix" it back +/// to 2.** The original `>= 2` floor (p1-29e first pass) was *inert*: apricot +/// re-baseline `20260529_185955` showed the trailing AI's `own_mil` snapshot is +/// **0 in 10/10 seeds** at every recorded turn. P1 fights via very-transient +/// units that exist only between snapshots, so a live `own_mil` count is never +/// `>= 2` at a `pick_for_city` decision for the exact population this break-out +/// targets — the gate never fired (p1-29d / p1-29e, 2026-05-29). Requiring a +/// standing-defender floor and gating on a live snapshot are therefore +/// mutually exclusive here; the snapshot wins (the floor cannot be observed), +/// so the floor is dropped to 0. A threatened sole-city AI under `< target` +/// buildings now break-outs regardless of its instantaneous unit count. The +/// `own_mil >= SOLE_CITY_ECON_MIN_DEFENDERS` clause is consequently +/// always-true; it is retained (rather than deleted) so the threshold remains a +/// single documented knob if a future persistent "defenders produced this game" +/// counter is threaded through the bridge (the only way to honour the original +/// "never left undefended" intent without a live snapshot). +const SOLE_CITY_ECON_MIN_DEFENDERS: u32 = 0; const SOLE_CITY_ECON_TARGET: usize = 2; /// Unit-side fallback ids. Building selection is catalog-driven via @@ -1412,12 +1428,22 @@ mod tests { } #[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); + fn sole_city_econ_breakout_fires_with_zero_standing_defenders() { + // own_mil=0: this is the EXACT population the break-out targets — + // apricot re-baseline 20260529_185955 showed the trailing AI's `mil` + // snapshot is 0 in 10/10 seeds (it fights via transient units between + // snapshots). The prior `own_mil >= 2` floor made the break-out inert + // here; with the floor dropped to 0 the break-out MUST fire and slot + // production instead of looping on military forever (p1-29e/p1-29d). + let s = econ_breakout_state(0, &["walls"], 1); let out = decide_production(&s, &weights(), &mut rng(), None); - assert_eq!(first_item(&out), ids::WARRIOR); + assert_eq!( + first_item(&out), + "forge", + "a threatened sole-city AI with a 0-unit `mil` snapshot is the \ + target population for the break-out — it must still escape the \ + military loop and build economy (floor lowered 2→0, p1-29e)" + ); } #[test]