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