From a3b2a2324f6c81c3f10a689ea3d274b20b5afb95 Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 25 Jun 2026 00:46:34 -0400 Subject: [PATCH] =?UTF-8?q?test(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=A7=AA=20p3-17=20deterministic=20self-play=20first-contac?= =?UTF-8?q?t=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes p3-17's "measurable improvement in self-play: earlier first-contact" as a reproducible cargo test (the sim is deterministic, so this needs no apricot smoke batch). Two AI militarists start fogged-apart on a 12x12 map; the frontier-seek exploration must bring one into view of another within 40 turns. Asserts no contact at game start (fog intact) and contact by the budget. This is the discovery feed p3-16's decide_diplomacy (declares only on a *discovered* rival) depends on — it never fired reliably before exploration landed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/ai_self_play_first_contact.rs | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/simulator/crates/mc-player-api/tests/ai_self_play_first_contact.rs diff --git a/src/simulator/crates/mc-player-api/tests/ai_self_play_first_contact.rs b/src/simulator/crates/mc-player-api/tests/ai_self_play_first_contact.rs new file mode 100644 index 00000000..9967fc98 --- /dev/null +++ b/src/simulator/crates/mc-player-api/tests/ai_self_play_first_contact.rs @@ -0,0 +1,82 @@ +//! p3-17 / p3-16 — deterministic self-play first-contact via exploration. +//! +//! The original objectives asked for "measurable improvement in a hotseat +//! self-play run: earlier first-contact". The sim is deterministic (fixed seed → +//! identical outcome), so this is a reproducible cargo test, not an apricot-only +//! smoke batch: two AI militarists start fogged-apart on a small map; once the +//! frontier-seek exploration (p3-17) expands their footprints, one must come into +//! view of the other within a bounded turn budget. First contact is the discovery +//! feed p3-16's war-dec gate (`decide_diplomacy` only declares on a *discovered* +//! rival) depends on — without exploration this never happened reliably. + +mod common; + +use common::{ + add_player_militarist_inline, build_building_catalog, build_runtime_units_catalog, + build_unit_catalog, stamp_personality, +}; +use mc_player_api::action::PlayerAction; +use mc_player_api::{apply_action, project_tactical_with_vision}; +use mc_state::game_state::GameState; +use mc_vision::{compute_vision, VisionCatalog}; + +fn grassland_grid(w: i32, h: i32) -> mc_core::grid::GridState { + let mut g = mc_core::grid::GridState::new(w, h); + for t in &mut g.tiles { + t.biome_label_id = "grassland".into(); + } + g +} + +/// Has player `viewer` discovered any unit or city of any *other* player +/// (fog-aware projection redacts the unseen)? +fn has_first_contact(state: &GameState, viewer: u8) -> bool { + let vs = compute_vision(state, &VisionCatalog::default(), None); + let Some(pv) = vs.for_player(viewer) else { + return false; + }; + let tac = project_tactical_with_vision(state, viewer, Some(pv)); + tac.players + .iter() + .any(|p| p.index != viewer && (!p.units.is_empty() || !p.cities.is_empty())) +} + +#[test] +fn ai_self_play_makes_first_contact_via_exploration() { + let mut state = GameState::default(); + state.turn = 1; + state.units_catalog = build_runtime_units_catalog(); + // Slot 0 is a passive "human" that only EndTurns; slots 1 + 2 are AI driven + // by the dispatch EndTurn. Start them fogged-apart on a compact 12x12 map. + add_player_militarist_inline(&mut state, 2, 2); + add_player_militarist_inline(&mut state, 9, 2); + add_player_militarist_inline(&mut state, 5, 9); + stamp_personality(&mut state, 1, "blackhammer"); + stamp_personality(&mut state, 2, "deepforge"); + state.ai_unit_catalog = build_unit_catalog(); + state.ai_building_catalog = build_building_catalog(); + state.ai_difficulty_threshold_mult = 1.0; + state.grid = Some(grassland_grid(12, 12)); + + // Sanity: nobody has seen anyone else at game start (fog intact). + assert!( + !has_first_contact(&state, 1), + "slot 1 must start fogged — no enemy visible before exploration" + ); + + // Drive deterministic self-play. Slot-0 EndTurn runs the AI for slots 1 + 2. + let mut contact_turn: Option = None; + for turn in 1..=40u32 { + apply_action(&mut state, 0, &PlayerAction::EndTurn).expect("end turn dispatch"); + if has_first_contact(&state, 1) || has_first_contact(&state, 2) { + contact_turn = Some(turn); + break; + } + } + + assert!( + contact_turn.is_some(), + "AI frontier-seek exploration must produce first contact within 40 turns; \ + got none — exploration regressed or the discovery feed is broken" + ); +}