test(@projects/@magic-civilization): 🧪 p3-16 self-play war-dec gate (explore→discover→declare)

Adds the deterministic end-to-end proof p3-16's last bullet needed: in AI-vs-AI
self-play, once frontier-seek exploration brings a militarist into view of a
*weaker* rival, it dispatches a war-dec and the relation flips to War within 60
turns. Asymmetric armies (slot 2 weakened) so the aggressor clears its
superiority threshold on discovery. Pairs with the existing decide_diplomacy unit
cases (cautious-holds-at-parity / warmonger-strikes) to demonstrate
personality-driven war-decs manifest in actual play — the integration that was
blocked purely on discovery (p3-17). Shared self-play setup helper extracted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-25 00:50:01 -04:00
parent a3b2a2324f
commit 3aaa524bf1

View file

@ -1,13 +1,15 @@
//! p3-17 / p3-16 — deterministic self-play first-contact via exploration.
//! p3-17 / p3-16 — deterministic self-play: exploration → first contact → war-dec.
//!
//! 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.
//! self-play run" (p3-17: earlier first-contact; p3-16: personality-driven
//! war-decs). The sim is deterministic (fixed seed → identical outcome), so these
//! are reproducible cargo tests rather than apricot-only smoke batches:
//! - `..._first_contact`: the frontier-seek (p3-17) expands fogged-apart AI
//! footprints until one sees another within a bounded turn budget — the
//! discovery feed `decide_diplomacy` needs.
//! - `..._declares_war`: once a militarist discovers a *weaker* rival it
//! dispatches a war-dec (p3-16), flipping the relation to War. Without
//! exploration this never fired in AI-vs-AI play.
mod common;
@ -18,6 +20,7 @@ use common::{
use mc_player_api::action::PlayerAction;
use mc_player_api::{apply_action, project_tactical_with_vision};
use mc_state::game_state::GameState;
use mc_trade::relation::Relation;
use mc_vision::{compute_vision, VisionCatalog};
fn grassland_grid(w: i32, h: i32) -> mc_core::grid::GridState {
@ -28,8 +31,26 @@ fn grassland_grid(w: i32, h: i32) -> mc_core::grid::GridState {
g
}
/// Has player `viewer` discovered any unit or city of any *other* player
/// (fog-aware projection redacts the unseen)?
/// Three militarists on a compact 12x12 map. Slot 0 is a passive "human" that
/// only EndTurns; slots 1 + 2 are AI driven by the dispatch EndTurn. They start
/// fogged-apart, so any contact is earned by exploration.
fn build_self_play_state() -> GameState {
let mut state = GameState::default();
state.turn = 1;
state.units_catalog = build_runtime_units_catalog();
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));
state
}
/// Has `viewer` discovered any unit or city of another player (fog-aware)?
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 {
@ -41,30 +62,22 @@ fn has_first_contact(state: &GameState, viewer: u8) -> bool {
.any(|p| p.index != viewer && (!p.units.is_empty() || !p.cities.is_empty()))
}
fn any_pair_at_war(state: &GameState) -> bool {
state
.players
.iter()
.any(|p| p.relations.values().any(|r| r.relation == Relation::War))
}
#[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));
let mut state = build_self_play_state();
// 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");
@ -80,3 +93,32 @@ fn ai_self_play_makes_first_contact_via_exploration() {
got none exploration regressed or the discovery feed is broken"
);
}
#[test]
fn ai_self_play_declares_war_on_discovered_weaker_rival() {
let mut state = build_self_play_state();
// Asymmetry: weaken slot 2's army (drain two of its three warriors; the
// founder at index 3 stays) so slot 1 — an aggressive militarist — clears
// its superiority threshold and war-decs slot 2 once it discovers it.
state.players[2].units.drain(1..3);
assert!(
!any_pair_at_war(&state),
"everyone must start at peace (courier-diplomacy default)"
);
let mut war = false;
for _turn in 1..=60u32 {
apply_action(&mut state, 0, &PlayerAction::EndTurn).expect("end turn dispatch");
if any_pair_at_war(&state) {
war = true;
break;
}
}
assert!(
war,
"a militarist must dispatch a war-dec against a discovered weaker rival \
within 60 turns explorationdiscoverydecide_diplomacy chain broken"
);
}