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 new file mode 100644 index 00000000..ea3e4a57 --- /dev/null +++ b/src/simulator/crates/mc-player-api/tests/p1_29h_gridded_elimination.rs @@ -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 = 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 = (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, + final_cities: Vec, + 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, + ); +}