From 3aaa524bf14e1b229d8485aa1d5c6e3e7aec556c Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 25 Jun 2026 00:50:01 -0400 Subject: [PATCH] =?UTF-8?q?test(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=A7=AA=20p3-16=20self-play=20war-dec=20gate=20(explore?= =?UTF-8?q?=E2=86=92discover=E2=86=92declare)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../tests/ai_self_play_first_contact.rs | 94 ++++++++++++++----- 1 file changed, 68 insertions(+), 26 deletions(-) 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 index 9967fc98..fe913fca 100644 --- 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 @@ -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 = 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 — exploration→discovery→decide_diplomacy chain broken" + ); +}