feat(p1-29i): post-capture refound-suppression lever (defaulted off) + measurement

p1-29h Phase 2 isolated the elimination wall. A per-player diagnostic added to
the gridded harness shows per_player_min_cities=[1,1] — both empires ARE pressed
to one city but neither is eliminated; the loser instantly refounds. That
[1,1]-but-no-kill signal makes refound-suppression the correctly-targeted lever.

Lever (data-driven, Rail 2, DEFAULTED OFF — zero live-balance change):
- mc_core::CombatBalance::refound_suppression { cooldown_turns: u32 } (default 0).
- mc_turn::PlayerState::last_city_lost_turn stamped at the capture site.
- processor::try_found_city refuses a replacement while
  turn - last_city_lost_turn < cooldown_turns (0 short-circuits).

Measurement (single-seed sweep, 160t): cooldown 0->0 elim, 5->1, 10->0, 20->0,
40->0. The lone cd=5 elimination is NOT credited as convergence — it is
single-seed noise: non-monotone (more suppression -> fewer eliminations), the
WINNER flips slot-to-slot across the sweep (chaotic-snowball perturbation, not a
lever response), and it fails p1-29h's literal gate. The lever demonstrably
suppresses refounding (founds trend down) but suppression ALONE does not robustly
convert captures into eliminations. Honest measured-negative per the brief; NO
non-zero combat_balance.json value authored, p1-29d NOT re-scored as converged.

Tests (apricot): mc-core 262 lib; gridded harness non-ignored 1/1; cargo check
--workspace 0; cargo test --workspace --no-run 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
autocommit 2026-06-04 16:29:04 -07:00
parent 9ddd40abe6
commit a49e24c969
6 changed files with 244 additions and 3 deletions

View file

@ -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<u32>` 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.

View file

@ -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(),
}
}
}

View file

@ -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};

View file

@ -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})",
);
}

View file

@ -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<u32>,
/// 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`.

View file

@ -1345,6 +1345,20 @@ impl TurnProcessor {
pi: usize,
events: &mut Vec<mc_replay::TurnEvent>,
) {
// 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)