diff --git a/.project/objectives/p1-29i-refound-suppression.md b/.project/objectives/p1-29i-refound-suppression.md new file mode 100644 index 00000000..25e22d18 --- /dev/null +++ b/.project/objectives/p1-29i-refound-suppression.md @@ -0,0 +1,88 @@ +--- +id: p1-29i-refound-suppression +title: "Refound-suppression / capture-stickiness lever — convert captures into eliminations" +priority: p1 +status: partial +scope: game1 +category: ai +owner: warcouncil +created: 2026-06-04 +updated_at: 2026-06-04 +blocked_by: [] +relates_to: [p1-29h-stateful-tactical-decisiveness, p1-29d-p1-survival] +--- + +## Why this exists + +p1-29h Phase 2 isolated the elimination wall: on the fair gridded two-`scripted:default` +duel (`mc-player-api/tests/p1_29h_gridded_elimination.rs`) the army-lock engages and +captures land (20 captures / 160 turns) but **0 eliminations** — the loser refounds before +the attacker can take the last city. p1-29h flagged refound-suppression / capture-stickiness +as the candidate lever and asked for a new objective if scope warranted. It does — this is it. + +## Diagnostic that justified the lever (2026-06-04) + +The combined `min_total_cities` proxy was broken (once both empires sprawl it only grows). +Added **per-player** minimum-city tracking to the harness probe. Result on the baseline +surface: `per_player_min_cities = [1, 1]` — BOTH empires ARE pressed down to a single city, +but neither hits 0 (never eliminated), then both rebuild to `final_cities = [15, 22]`. So the +loser reaches its capital-only state and instantly refounds. That `[1,1]`-but-no-kill signal +is what makes refound-suppression the correctly-targeted lever (vs. a targeting problem). + +## The lever (implemented, defaulted OFF) + +Data-driven (Rail 2) post-capture refound cooldown: +- `mc_core::CombatBalance::refound_suppression: RefoundSuppression { cooldown_turns: u32 }` + (`combat_balance.rs`), **default `0` = disabled** (zero live-balance change). Authored + block deliberately NOT yet added to `combat_balance.json` — no value is justified yet. +- `mc_turn::PlayerState::last_city_lost_turn: Option` stamped at the capture site in + `processor::process_siege` (where `cities_lost_total` is incremented). +- `processor::try_found_city` refuses to found a replacement while + `turn - last_city_lost_turn < cooldown_turns`. `cooldown_turns == 0` short-circuits + (no behaviour change). + +## Acceptance + +- ✓ Lever implemented, data-driven, defaulted off (no live-game impact until a value is + authored + justified). mc-core 262 lib, workspace check 0 (apricot 2026-06-04). +- ✓ Per-player elimination diagnostic added to the gridded harness; sweep harness + (`refound_suppression_lever_sweep`, `--ignored`) measures eliminations per cooldown value. +- ◐ **≥1 robust elimination on the fair surface** — **MEASURED, NOT YET ACHIEVED ROBUSTLY.** + Single-seed sweep (160t): cooldown `0→0 elim`, `5→1 elim` (slot 1 eliminated, final + `[41,0]`), `10→0`, `20→0`, `40→0`. The lone elimination at cd=5 is **NOT credited as + convergence**: (a) non-monotone (more suppression → fewer, not more, eliminations — no + coherent lever mechanism), (b) the *winner* swings slot-to-slot across the sweep (cd0 + `[15,22]`, cd5 `[41,0]`, cd10 `[2,31]`) = chaotic-snowball perturbation, not a lever + response, (c) it fails p1-29h's own literal gate (`min_total_cities < start` — the combined + proxy never dipped). This is a single-seed spike, i.e. luck/timing, exactly the + "don't fake an elimination" the brief forbids. Honest verdict pending a multi-seed / + geometry ensemble. +- ☐ Re-score p1-29d as converged — **NOT done; explicitly withheld** pending robust + multi-seed evidence. p1-29d stays unconverged. + +## Honest result (2026-06-04) + +The lever **demonstrably suppresses refounding** (founds trend down as cooldown rises: +38→39→37→34→34) — the mechanism works. But suppression ALONE does not robustly convert a +capture into an elimination on the fair surface: the one elimination observed is single-seed +noise, not a converged outcome. Per the brief, this measured-negative + the working mechanism +(defaulted off, no degenerate value forced) is the deliverable. The bullet stays open; do not +author a non-zero `combat_balance.json` value without robust multi-seed support. + +## Source-of-truth rails + +- **Rust crate**: `mc-turn::processor` owns the refound gate + loss-turn stamp; `mc-core` + owns the `RefoundSuppression` tunable. +- **JSON path**: `public/games/age-of-dwarves/data/combat_balance.json` — + `refound_suppression.cooldown_turns` (NOT yet authored; default 0 governs). + +## Out of scope + +- Authoring a live-game cooldown value before multi-seed convergence is proven. +- The targeting lock itself (p1-29h, done) and the learned-controller track (p1-29f/g). + +## References + +- `.project/objectives/p1-29h-stateful-tactical-decisiveness.md` +- `.project/objectives/p1-29d-p1-survival.md` +- `src/simulator/crates/mc-player-api/tests/p1_29h_gridded_elimination.rs` — diagnostic + sweep. diff --git a/src/simulator/crates/mc-core/src/combat_balance.rs b/src/simulator/crates/mc-core/src/combat_balance.rs index de475b0b..e08c5e7b 100644 --- a/src/simulator/crates/mc-core/src/combat_balance.rs +++ b/src/simulator/crates/mc-core/src/combat_balance.rs @@ -74,6 +74,37 @@ pub struct CombatBalance { /// 158 invented per-unit numbers. #[serde(default)] pub quality_deltas: QualityDeltas, + /// p1-29i — post-capture refound suppression. After an empire loses a city + /// to capture, it cannot found a *replacement* city for `cooldown_turns` + /// turns. This is the capture-stickiness lever the p1-29h Phase-2 + /// measurement isolated: on the fair gridded duel both empires get pressed + /// down to a single city (`per_player_min_cities = [1, 1]`) but neither is + /// ever eliminated because the loser instantly refounds, so 20 captures + /// produce 0 eliminations (pure churn). A short cooldown gives the attacker + /// a window to press the now-undefended capital before the loser rebuilds. + /// `cooldown_turns == 0` disables the lever entirely (baseline behaviour). + #[serde(default)] + pub refound_suppression: RefoundSuppression, +} + +/// p1-29i — post-capture refound-suppression tunables. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct RefoundSuppression { + /// Number of turns after a city *loss* (capture by another player) during + /// which the losing empire may not found a replacement city. `0` disables + /// the lever (no suppression — preserves pre-p1-29i behaviour). + #[serde(default = "default_refound_cooldown_turns")] + pub cooldown_turns: u32, +} + +fn default_refound_cooldown_turns() -> u32 { + 0 +} + +impl Default for RefoundSuppression { + fn default() -> Self { + Self { cooldown_turns: default_refound_cooldown_turns() } + } } /// p2-57c — additive combat-stat deltas applied per production-quality band. @@ -204,6 +235,7 @@ impl Default for CombatBalance { low_worker_pool_threshold: default_low_worker_pool_threshold(), solo_city_grace: SoloCityGrace::default(), quality_deltas: QualityDeltas::default(), + refound_suppression: RefoundSuppression::default(), } } } diff --git a/src/simulator/crates/mc-core/src/lib.rs b/src/simulator/crates/mc-core/src/lib.rs index 6ace11c0..66a807c9 100644 --- a/src/simulator/crates/mc-core/src/lib.rs +++ b/src/simulator/crates/mc-core/src/lib.rs @@ -37,7 +37,8 @@ pub mod worker; pub use building::{BuildingEntity, Placement}; pub use city_action::{CityAction, CityId}; pub use combat_balance::{ - parse_combat_balance, CombatBalance, QualityDeltas, SoloCityGrace, StatDelta, + parse_combat_balance, CombatBalance, QualityDeltas, RefoundSuppression, SoloCityGrace, + StatDelta, }; pub use damage_channel::{ChannelDamageBundle, DamageChannel}; pub use diplomacy::{AgreementType, MechanicKey}; diff --git a/src/simulator/crates/mc-player-api/tests/p1_29h_gridded_elimination.rs b/src/simulator/crates/mc-player-api/tests/p1_29h_gridded_elimination.rs index ea3e4a57..bb4f84db 100644 --- a/src/simulator/crates/mc-player-api/tests/p1_29h_gridded_elimination.rs +++ b/src/simulator/crates/mc-player-api/tests/p1_29h_gridded_elimination.rs @@ -153,12 +153,19 @@ fn passive_ender(state: &mut GameState) -> u8 { /// Build the gridded fair surface: two aggressive combatants in close contact at /// war, plus a passive ender. Real grid → real vision. fn build_gridded_duel(attacker_warriors: i32) -> (GameState, u8) { + build_gridded_duel_with_cooldown(attacker_warriors, 0) +} + +/// As [`build_gridded_duel`] but sets the p1-29i post-capture refound cooldown +/// (`0` = lever disabled / baseline). +fn build_gridded_duel_with_cooldown(attacker_warriors: i32, refound_cooldown: u32) -> (GameState, u8) { let mut state = GameState::default(); state.turn = 1; state.units_catalog = build_runtime_units_catalog(); state.ai_unit_catalog = build_unit_catalog(); state.ai_building_catalog = build_building_catalog(); state.ai_difficulty_threshold_mult = 1.0; + state.combat_balance.refound_suppression.cooldown_turns = refound_cooldown; state.grid = Some(flat_grid(24, 24, "grassland")); // Two combatants 5 tiles apart on the same row — within a few turns' @@ -200,13 +207,23 @@ struct Probe { min_total_cities: usize, /// Starting combined city count (for the dip comparison). start_total_cities: usize, + /// p1-29i diagnostic: lowest city count seen for EACH combatant slot + /// individually (combined `min_total_cities` is a broken proxy — once both + /// sprawl, the combined total only grows). This is the real + /// "is the loser ever pressed near zero?" signal. + per_player_min_cities: [usize; 2], } /// Drive the gridded fair surface for `max_turns`, recording engagement. fn drive(max_turns: u32) -> Probe { + drive_with_cooldown(max_turns, 0) +} + +/// As [`drive`] but with the p1-29i post-capture refound cooldown applied. +fn drive_with_cooldown(max_turns: u32, refound_cooldown: u32) -> Probe { use mc_player_api::wire::Event; - let (mut state, ender) = build_gridded_duel(4); + let (mut state, ender) = build_gridded_duel_with_cooldown(4, refound_cooldown); let mut probe = Probe::default(); let combatants = [0u8, 1u8]; probe.start_total_cities = combatants @@ -214,6 +231,10 @@ fn drive(max_turns: u32) -> Probe { .map(|&c| state.players[c as usize].cities.len()) .sum(); probe.min_total_cities = probe.start_total_cities; + probe.per_player_min_cities = [ + state.players[0].cities.len(), + state.players[1].cities.len(), + ]; for _ in 0..max_turns { // Re-assert war each loop (defensive against a peace flip). @@ -243,6 +264,10 @@ fn drive(max_turns: u32) -> Probe { .map(|&c| state.players[c as usize].cities.len()) .sum(); probe.min_total_cities = probe.min_total_cities.min(total_cities); + for (i, &c) in combatants.iter().enumerate() { + let n = state.players[c as usize].cities.len(); + probe.per_player_min_cities[i] = probe.per_player_min_cities[i].min(n); + } // Vision sanity — the whole point of the grid. let vs = mc_vision::compute_vision(&state, &mc_vision::VisionCatalog::default(), None); @@ -285,7 +310,8 @@ fn report(tag: &str, probe: &Probe) { eprintln!( "p1-29h {tag}: visible_tiles_max={} ever_committed={} committed_with_target={} \ captures={} founds={} eliminations={} eliminated_slots={:?} \ - start_cities={} min_total_cities={} final_cities={:?} turns_run={}", + start_cities={} min_total_cities={} per_player_min_cities={:?} \ + final_cities={:?} turns_run={}", probe.max_visible_tiles, probe.ever_committed, probe.committed_with_target, @@ -295,6 +321,7 @@ fn report(tag: &str, probe: &Probe) { probe.eliminated_slots, probe.start_total_cities, probe.min_total_cities, + probe.per_player_min_cities, probe.final_cities, probe.turns_run, ); @@ -364,3 +391,71 @@ fn fair_scripted_duel_elimination_measurement() { probe.min_total_cities < probe.start_total_cities, ); } + +/// p1-29i — post-capture refound-suppression LEVER sweep + measurement. +/// +/// The p1-29h Phase-2 diagnostic established the failure mode precisely: +/// `per_player_min_cities = [1, 1]` (both empires ARE pressed to a single city) +/// yet `eliminations = 0` — the loser refounds before the attacker can take the +/// last city. This test sweeps the `refound_suppression.cooldown_turns` lever +/// and records, for each value, whether ≥1 elimination is reached on the fair +/// two-`scripted:default` surface. +/// +/// HONEST GATE: the assertion is only that the lever *moves the dial in the +/// right direction* and that the surface stays real (lock engages, vision > 0). +/// A specific cooldown reaching ≥1 elimination is the GOAL recorded in the log, +/// not a hard `assert` — if no surveyed value converges without a degenerate +/// (4X-breaking) setting, that negative is the honest deliverable and the bullet +/// stays open. `#[ignore]` — run via `--ignored --nocapture`. +#[test] +#[ignore = "p1-29i refound-suppression lever sweep (recorded measurement); invoke via --ignored --nocapture"] +fn refound_suppression_lever_sweep() { + // Surveyed cooldown values. 0 = baseline (lever off). Values chosen to span + // "barely delays" (5) → "a full settle-window" (40) without ever DISABLING + // expansion (which would break 4X feel — the brief's named failure). + let sweep = [0u32, 5, 10, 20, 40]; + let mut results: Vec<(u32, usize, usize, usize, [usize; 2])> = Vec::new(); + + for &cd in &sweep { + let probe = drive_with_cooldown(160, cd); + report(&format!("LEVER-SWEEP cooldown={cd}"), &probe); + // Surface must stay real for every sampled value. + assert!(probe.max_visible_tiles > 0, "cooldown={cd}: vision must be real"); + assert!( + probe.ever_committed && probe.committed_with_target, + "cooldown={cd}: army-lock must still engage", + ); + results.push(( + cd, + probe.eliminations, + probe.captures, + probe.founds, + probe.per_player_min_cities, + )); + } + + eprintln!("p1-29i SWEEP SUMMARY (cooldown, elims, captures, founds, per_player_min):"); + for (cd, elims, caps, founds, mins) in &results { + eprintln!( + " cooldown={cd:>3}: eliminations={elims} captures={caps} founds={founds} per_player_min={mins:?}", + ); + } + + let baseline_elims = results[0].1; + let best = results.iter().map(|r| r.1).max().unwrap_or(0); + eprintln!( + "p1-29i RESULT: baseline(cooldown=0) eliminations={baseline_elims}; \ + best-across-sweep eliminations={best}", + ); + + // Directional gate only: founds must fall as the cooldown rises (the lever + // demonstrably suppresses refounding) — proving the mechanism works even if + // suppression alone is not sufficient to convert a capture into a kill. + let baseline_founds = results[0].3; + let max_cd_founds = results.last().unwrap().3; + assert!( + max_cd_founds <= baseline_founds, + "the lever must not INCREASE refounding (baseline founds={baseline_founds}, \ + max-cooldown founds={max_cd_founds})", + ); +} diff --git a/src/simulator/crates/mc-turn/src/game_state.rs b/src/simulator/crates/mc-turn/src/game_state.rs index 24ec191e..da99f545 100644 --- a/src/simulator/crates/mc-turn/src/game_state.rs +++ b/src/simulator/crates/mc-turn/src/game_state.rs @@ -914,6 +914,13 @@ pub struct PlayerState { /// `process_siege` when a city changes ownership. #[serde(default)] pub cities_lost_total: u32, + /// p1-29i: game-turn on which this player most recently LOST a city to + /// capture. Drives the post-capture refound cooldown + /// (`CombatBalance::refound_suppression`): `try_found_city` refuses to found + /// a replacement while `turn - last_city_lost_turn < cooldown_turns`. + /// `None` until the first city loss (and on old saves) → no suppression. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_city_lost_turn: Option, /// Derived realm-level statistics recomputed at end of every turn by /// `TurnProcessor::step → recompute_derived_stats`. All future derived /// scalars land here — single recompute site rule, see `mc_core::derived_stats`. diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 7df01be4..ef793f53 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -1345,6 +1345,20 @@ impl TurnProcessor { pi: usize, events: &mut Vec, ) { + // p1-29i: post-capture refound suppression. If this empire lost a city + // to capture within the last `cooldown_turns` turns, it may not found a + // *replacement* yet — giving an attacker a window to press the now + // near-undefended capital before the loser rebuilds (the p1-29h Phase-2 + // capture-stickiness gap). `cooldown_turns == 0` disables the lever. + let cooldown = state.combat_balance.refound_suppression.cooldown_turns; + if cooldown > 0 { + if let Some(lost_turn) = state.players[pi].last_city_lost_turn { + if state.turn.saturating_sub(lost_turn) < cooldown { + return; + } + } + } + let player = &mut state.players[pi]; if player.expansion_points < self.lair_combat_config.city_founding_cost { return; @@ -3690,6 +3704,7 @@ impl TurnProcessor { // Process in reverse to avoid index invalidation within the same player. captures.sort_by(|a, b| b.2.cmp(&a.2)); captures.dedup_by(|a, b| a.1 == b.1 && a.2 == b.2); + let capture_turn = state.turn; for (attacker_pi, defender_pi, city_idx) in captures { let defender = &mut state.players[defender_pi]; if city_idx < defender.cities.len() { @@ -3699,6 +3714,9 @@ impl TurnProcessor { // Mirrors GDScript `combat_utils.gd:118` so bench and live // engine agree on `cities_lost_total`. defender.cities_lost_total = defender.cities_lost_total.saturating_add(1); + // p1-29i: stamp the loss turn so `try_found_city` can enforce + // the post-capture refound cooldown. + defender.last_city_lost_turn = Some(capture_turn); let city = defender.cities.swap_remove(city_idx); let pos = if city_idx < defender.city_positions.len() { defender.city_positions.swap_remove(city_idx)