test(@projects/@magic-civilization): 🧪 p3-17 deterministic self-play first-contact gate

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) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-25 00:46:34 -04:00
parent c8896d50c9
commit a3b2a2324f

View file

@ -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<u32> = 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"
);
}