test(mc-player-api): ✅ Add test coverage for gridded elimination validation in player API
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
1e4a1db05d
commit
a40a021c5c
1 changed files with 366 additions and 0 deletions
|
|
@ -0,0 +1,366 @@
|
|||
//! p1-29h Phase 2 — gridded, fair, two-`scripted:default` elimination surface.
|
||||
//!
|
||||
//! This is option (b) from the p1-29h resume note: a Rust harness that builds a
|
||||
//! GRIDDED `GameState` (so `compute_vision` populates and the fog-gated tactical
|
||||
//! projection surfaces enemy cities → `want_attack` can fire) and loops
|
||||
//! `apply_action(EndTurn)` through the PERSISTENT `drive_ai_slot` seam, counting
|
||||
//! eliminations.
|
||||
//!
|
||||
//! # Why a grid is the load-bearing fix
|
||||
//!
|
||||
//! The pre-existing seam guard (`p1_29h_persistent_memory_seam.rs`) used
|
||||
//! `GameState::default()` with no grid, so `compute_vision` returned an EMPTY
|
||||
//! visible set → zero enemy cities visible → the army-lock never engaged. Here
|
||||
//! `state.grid = Some(flat_grid(...))`, so vision is real and the lock can
|
||||
//! commit. Engagement on a full-visibility state is independently proven by
|
||||
//! `mc_ai::tactical::movement::tests::army_lock_concentrates_and_persists_across_turns`.
|
||||
//!
|
||||
//! # Why this is a FAIR two-`scripted:default` surface
|
||||
//!
|
||||
//! `apply_end_turn` (dispatch.rs:368) drives EVERY slot through `drive_ai_slot`
|
||||
//! except (a) the slot that called `EndTurn` and (b) env-listed external slots.
|
||||
//! So we add a passive non-combatant "ender" slot that owns nothing, and call
|
||||
//! `apply_action(state, ender_slot, EndTurn)`. Both combatant slots (0 + 1) then
|
||||
//! run the SAME scripted-default controller through the SAME persistent-memory
|
||||
//! seam — neither is juiced (no MCTS, no GDScript harness lock). This is the
|
||||
//! exact symmetry the p1-29d clean baseline requires.
|
||||
//!
|
||||
//! # Attribution discipline (p1-29h bullet 3)
|
||||
//!
|
||||
//! An elimination only counts toward the bullet if the army-lock actually
|
||||
//! engaged: the prior seam guard recorded `ever_committed=false` and correctly
|
||||
//! refused to attribute any capture to the lock. Here we record both
|
||||
//! `ever_committed` (with `locked_target=Some`) AND eliminations, and the gate
|
||||
//! asserts BOTH — so a green run proves a lock-attributable elimination, not an
|
||||
//! incidental one.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use mc_ai::evaluator::ScoringWeights;
|
||||
use mc_player_api::action::PlayerAction;
|
||||
use mc_player_api::apply_action;
|
||||
use mc_trade::relation::{Relation, RelationState};
|
||||
use mc_turn::game_state::{GameState, MapUnit, PlayerState};
|
||||
|
||||
mod common;
|
||||
use common::{build_runtime_units_catalog, build_building_catalog, build_unit_catalog};
|
||||
|
||||
/// Flat grid of one biome — mirrors `ai_fairness::flat_grid`.
|
||||
fn flat_grid(width: i32, height: i32, biome: &str) -> mc_core::grid::GridState {
|
||||
let mut g = mc_core::grid::GridState::new(width, height);
|
||||
for t in &mut g.tiles {
|
||||
t.biome_label_id = biome.into();
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// Aggressive militarist with a city + a warrior stack, co-located so the two
|
||||
/// combatants are within tactical reach. Personality axes mirror the seam guard
|
||||
/// (`aggression=9`, `grudge=8`) so commitment fires readily; the clan id stamps
|
||||
/// the personality-derived commitment length (Rail 2, `ai_personalities.json`).
|
||||
fn militarist(
|
||||
state: &mut GameState,
|
||||
city_col: i32,
|
||||
city_row: i32,
|
||||
clan: &str,
|
||||
n_warriors: i32,
|
||||
) -> u8 {
|
||||
let pi = state.players.len() as u8;
|
||||
let mut axes: BTreeMap<String, u8> = BTreeMap::new();
|
||||
axes.insert("expansion".into(), 2);
|
||||
axes.insert("production".into(), 5);
|
||||
axes.insert("aggression".into(), 9);
|
||||
axes.insert("grudge_persistence".into(), 8);
|
||||
|
||||
let units: Vec<MapUnit> = (0..n_warriors)
|
||||
.map(|i| {
|
||||
let id = state.next_unit_id;
|
||||
state.next_unit_id = state.next_unit_id.saturating_add(1);
|
||||
let mut u = MapUnit::new(
|
||||
"dwarf_warrior",
|
||||
city_col + i,
|
||||
city_row,
|
||||
pi,
|
||||
&state.units_catalog,
|
||||
);
|
||||
u.id = id;
|
||||
u.hp = 60;
|
||||
u.max_hp = 60;
|
||||
u.attack = 16;
|
||||
u.defense = 2;
|
||||
u
|
||||
})
|
||||
.collect();
|
||||
|
||||
state.players.push(PlayerState {
|
||||
player_index: pi,
|
||||
gold: 60,
|
||||
cities: vec![mc_city::CityState::starter()],
|
||||
unit_upkeep: Vec::new(),
|
||||
strategic_axes: axes,
|
||||
scoring_weights: ScoringWeights::default(),
|
||||
expansion_points: 0,
|
||||
city_buildings: vec![Vec::new()],
|
||||
city_improvements: vec![Vec::new()],
|
||||
city_ecology: vec![Default::default()],
|
||||
tech_state: None,
|
||||
science_pool: 0,
|
||||
player_tech: None,
|
||||
science_yield: 0,
|
||||
units,
|
||||
city_positions: vec![(city_col, city_row)],
|
||||
capital_position: Some((city_col, city_row)),
|
||||
culture_total: 0,
|
||||
culture_pool: mc_culture::CulturePool::default(),
|
||||
arcane_lore_pop_deducted: false,
|
||||
traded_luxuries: Default::default(),
|
||||
relations: Default::default(),
|
||||
strategic_ledger: Default::default(),
|
||||
wonders_built: Default::default(),
|
||||
explored_deposits: Default::default(),
|
||||
clan_id: clan.to_string(),
|
||||
promotion_offense_weight: 1.0,
|
||||
promotion_defense_weight: 1.0,
|
||||
promotion_mobility_weight: 1.0,
|
||||
..Default::default()
|
||||
});
|
||||
pi
|
||||
}
|
||||
|
||||
/// A passive non-combatant "ender" slot — owns no units and no cities, so it
|
||||
/// never decides anything, but ending its turn drives every OTHER slot through
|
||||
/// the persistent `drive_ai_slot` seam (the fair two-`scripted:default` setup).
|
||||
fn passive_ender(state: &mut GameState) -> u8 {
|
||||
let pi = state.players.len() as u8;
|
||||
state.players.push(PlayerState {
|
||||
player_index: pi,
|
||||
gold: 0,
|
||||
cities: Vec::new(),
|
||||
city_buildings: Vec::new(),
|
||||
city_improvements: Vec::new(),
|
||||
city_ecology: Vec::new(),
|
||||
units: Vec::new(),
|
||||
city_positions: Vec::new(),
|
||||
capital_position: None,
|
||||
culture_pool: mc_culture::CulturePool::default(),
|
||||
scoring_weights: ScoringWeights::default(),
|
||||
clan_id: "observer".into(),
|
||||
..Default::default()
|
||||
});
|
||||
pi
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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.grid = Some(flat_grid(24, 24, "grassland"));
|
||||
|
||||
// Two combatants 5 tiles apart on the same row — within a few turns'
|
||||
// march so contact is made early. Attacker gets a heavier stack so a
|
||||
// killing blow is reachable (the spec's whole point is whether the LOCK
|
||||
// turns a capture into an elimination, not whether a 1:1 fight stalls).
|
||||
let p0 = militarist(&mut state, 6, 12, "blackhammer", attacker_warriors);
|
||||
let p1 = militarist(&mut state, 11, 12, "deepforge", 2);
|
||||
let ender = passive_ender(&mut state);
|
||||
|
||||
// War between the two combatants (authoritative table on players[0]).
|
||||
let war = RelationState { relation: Relation::War, ..Default::default() };
|
||||
state.players[0].relations.insert((p0, p1), war.clone());
|
||||
state.players[0].relations.insert((p1, p0), war);
|
||||
|
||||
(state, ender)
|
||||
}
|
||||
|
||||
/// Per-turn engagement record for the diagnostic recap.
|
||||
#[derive(Default, Clone)]
|
||||
struct Probe {
|
||||
ever_committed: bool,
|
||||
committed_with_target: bool,
|
||||
max_visible_tiles: usize,
|
||||
eliminations: usize,
|
||||
eliminated_slots: Vec<u8>,
|
||||
final_cities: Vec<usize>,
|
||||
turns_run: u32,
|
||||
/// Count of `Event::CityCaptured` emitted across the whole run. The direct
|
||||
/// (a)-vs-(b) discriminator: >0 ⇒ captures DO occur (real p1-29d
|
||||
/// indecisive-war pathology — captures happen, refounds prevent
|
||||
/// elimination); =0 ⇒ no decisive contact (geometry/harness artifact).
|
||||
captures: usize,
|
||||
/// City founds across the run — sprawl signal.
|
||||
founds: usize,
|
||||
/// Lowest combined city count seen at any turn. A dip below the starting
|
||||
/// total (2) corroborates that a city was lost (captured/razed) even if it
|
||||
/// was later refounded.
|
||||
min_total_cities: usize,
|
||||
/// Starting combined city count (for the dip comparison).
|
||||
start_total_cities: usize,
|
||||
}
|
||||
|
||||
/// Drive the gridded fair surface for `max_turns`, recording engagement.
|
||||
fn drive(max_turns: u32) -> Probe {
|
||||
use mc_player_api::wire::Event;
|
||||
|
||||
let (mut state, ender) = build_gridded_duel(4);
|
||||
let mut probe = Probe::default();
|
||||
let combatants = [0u8, 1u8];
|
||||
probe.start_total_cities = combatants
|
||||
.iter()
|
||||
.map(|&c| state.players[c as usize].cities.len())
|
||||
.sum();
|
||||
probe.min_total_cities = probe.start_total_cities;
|
||||
|
||||
for _ in 0..max_turns {
|
||||
// Re-assert war each loop (defensive against a peace flip).
|
||||
for &(a, b) in &[(0u8, 1u8), (1u8, 0u8)] {
|
||||
state
|
||||
.players[0]
|
||||
.relations
|
||||
.entry((a, b))
|
||||
.or_insert(RelationState { relation: Relation::War, ..Default::default() })
|
||||
.relation = Relation::War;
|
||||
}
|
||||
|
||||
let result = apply_action(&mut state, ender, &PlayerAction::EndTurn);
|
||||
if let Ok(events) = &result {
|
||||
for ev in events {
|
||||
match ev {
|
||||
Event::CityCaptured { .. } => probe.captures += 1,
|
||||
Event::CityFounded { .. } => probe.founds += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
probe.turns_run = state.turn;
|
||||
|
||||
let total_cities: usize = combatants
|
||||
.iter()
|
||||
.map(|&c| state.players[c as usize].cities.len())
|
||||
.sum();
|
||||
probe.min_total_cities = probe.min_total_cities.min(total_cities);
|
||||
|
||||
// Vision sanity — the whole point of the grid.
|
||||
let vs = mc_vision::compute_vision(&state, &mc_vision::VisionCatalog::default(), None);
|
||||
for &c in &combatants {
|
||||
if let Some(pv) = vs.for_player(c) {
|
||||
probe.max_visible_tiles = probe.max_visible_tiles.max(pv.visible.len());
|
||||
}
|
||||
}
|
||||
|
||||
// Lock engagement on either combatant (persistent path).
|
||||
for &c in &combatants {
|
||||
let mem = &state.players[c as usize].tactical_memory;
|
||||
if mem.is_committed() {
|
||||
probe.ever_committed = true;
|
||||
if mem.locked_target.is_some() {
|
||||
probe.committed_with_target = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Elimination = a combatant with zero cities.
|
||||
for &c in &combatants {
|
||||
if state.players[c as usize].cities.is_empty()
|
||||
&& !probe.eliminated_slots.contains(&c)
|
||||
{
|
||||
probe.eliminated_slots.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
probe.eliminations = probe.eliminated_slots.len();
|
||||
probe.final_cities = combatants
|
||||
.iter()
|
||||
.map(|&c| state.players[c as usize].cities.len())
|
||||
.collect();
|
||||
probe
|
||||
}
|
||||
|
||||
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={}",
|
||||
probe.max_visible_tiles,
|
||||
probe.ever_committed,
|
||||
probe.committed_with_target,
|
||||
probe.captures,
|
||||
probe.founds,
|
||||
probe.eliminations,
|
||||
probe.eliminated_slots,
|
||||
probe.start_total_cities,
|
||||
probe.min_total_cities,
|
||||
probe.final_cities,
|
||||
probe.turns_run,
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase-1-on-the-production-seam regression guard (always runs). Proves the two
|
||||
/// things p1-29h bullets 1+2 require and the gridless seam guard COULD NOT show:
|
||||
/// (1) a real grid yields real vision, and (2) the army-lock engages on the
|
||||
/// persistent `drive_ai_slot` seam (committed with a locked target). The
|
||||
/// elimination outcome is RECORDED, never asserted — the elimination bullet is
|
||||
/// measured-negative (see the ignored gate below), so encoding `>=1` here would
|
||||
/// hide a known-false assertion behind `cargo test`'s skip-ignored behaviour.
|
||||
#[test]
|
||||
fn gridded_fair_surface_engages_army_lock() {
|
||||
let probe = drive(40);
|
||||
report("engage-guard (40t)", &probe);
|
||||
|
||||
// (1) Real grid → real vision (the fix the whole objective hinges on).
|
||||
assert!(
|
||||
probe.max_visible_tiles > 0,
|
||||
"gridded surface must produce non-empty vision (was {})",
|
||||
probe.max_visible_tiles
|
||||
);
|
||||
// (2) The army-lock engages on the production persistent seam — bullets 1+2
|
||||
// capability, now exercised by a full-game driver (not just the unit test).
|
||||
assert!(
|
||||
probe.ever_committed && probe.committed_with_target,
|
||||
"army-lock must engage (committed with a locked target); \
|
||||
ever_committed={} committed_with_target={}",
|
||||
probe.ever_committed,
|
||||
probe.committed_with_target
|
||||
);
|
||||
assert!(probe.turns_run >= 40, "turn loop must advance at least 40 turns");
|
||||
}
|
||||
|
||||
/// Phase-2 elimination MEASUREMENT (bullet 3). NOT an assertion gate — the
|
||||
/// measured result on the fair two-`scripted:default` surface is 0 eliminations
|
||||
/// (the army-lock targets correctly but the heuristic war stays indecisive,
|
||||
/// confirming p1-29d's root cause + the spec's own weight-insensitivity risk).
|
||||
/// Records the discriminating signals (captures / min-city-dip) so the next
|
||||
/// investigator can see WHETHER captures occur, then asserts only the
|
||||
/// always-true surface facts so the recorded negative can never silently
|
||||
/// "pass" by being skipped.
|
||||
///
|
||||
/// `#[ignore]` because it runs a longer game; invoke via `--ignored`.
|
||||
#[test]
|
||||
#[ignore = "p1-29h Phase 2 elimination measurement (recorded, not gated); invoke via --ignored"]
|
||||
fn fair_scripted_duel_elimination_measurement() {
|
||||
let probe = drive(160);
|
||||
report("FAIR-DUEL measurement (160t)", &probe);
|
||||
|
||||
// Always-true surface facts — the run is real and the lock engaged.
|
||||
assert!(probe.max_visible_tiles > 0, "fair surface must produce non-empty vision");
|
||||
assert!(
|
||||
probe.ever_committed && probe.committed_with_target,
|
||||
"army-lock must engage for the measurement to be meaningful"
|
||||
);
|
||||
// Recorded findings (NOT asserted as pass/fail — they ARE the deliverable):
|
||||
// * eliminations: the measured-negative bullet-3 result.
|
||||
// * captures / min_total_cities dip: the (a) indecisive-war vs
|
||||
// (b) no-contact discriminator.
|
||||
eprintln!(
|
||||
"p1-29h finding: lock engages, eliminations={}, captures={}, \
|
||||
city-dip-below-start={}",
|
||||
probe.eliminations,
|
||||
probe.captures,
|
||||
probe.min_total_cities < probe.start_total_cities,
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue