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:
parent
9ddd40abe6
commit
a49e24c969
6 changed files with 244 additions and 3 deletions
88
.project/objectives/p1-29i-refound-suppression.md
Normal file
88
.project/objectives/p1-29i-refound-suppression.md
Normal 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.
|
||||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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})",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue