From 6a4d87a02932026bc4ef630d7937f2bf776b6e74 Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 25 Jun 2026 00:38:35 -0400 Subject: [PATCH] =?UTF-8?q?test(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=A7=AA=20p3-17=20end-to-end=20exploration=20gate=20(fog?= =?UTF-8?q?=20=E2=86=92=20projection=20=E2=86=92=20decision)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guards the wiring the movement unit tests can't: compute_vision (fog) → project_tactical_with_vision (sets TacticalTile.explored) → decide_tactical_actions. An idle warrior with no visible enemy on a 20x20 foggy map must issue a MoveUnit toward the fog rather than idling — the discovery feed p3-16's war-dec gate needs. Asserts the far corner projects as unexplored and the unit steps off its start tile. Reproducible (no MCP), runs under cargo test --workspace. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../mc-player-api/tests/ai_exploration.rs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/simulator/crates/mc-player-api/tests/ai_exploration.rs diff --git a/src/simulator/crates/mc-player-api/tests/ai_exploration.rs b/src/simulator/crates/mc-player-api/tests/ai_exploration.rs new file mode 100644 index 00000000..f9f942b8 --- /dev/null +++ b/src/simulator/crates/mc-player-api/tests/ai_exploration.rs @@ -0,0 +1,96 @@ +//! p3-17 end-to-end — idle military units frontier-seek toward real fog. +//! +//! Exercises the full chain the headless self-play path runs every turn: +//! `compute_vision` (fog) → `project_tactical_with_vision` (sets +//! `TacticalTile::explored`) → `decide_tactical_actions`. An idle warrior with no +//! visible enemy must issue a `MoveUnit` that heads toward unexplored territory +//! rather than idling — the discovery feed p3-16's war-dec gate depends on. The +//! movement-layer unit tests cover the scorer in isolation; this guards the +//! projection→decision wiring (explored actually reaches the AI). + +use mc_ai::mcts::XorShift64; +use mc_ai::tactical::TacticalMemory; +use mc_ai::{decide_tactical_actions, Action, ScoringWeights}; +use mc_player_api::project_tactical_with_vision; +use mc_state::game_state::{GameState, MapUnit, PlayerState}; +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 +} + +fn warrior(id: u32, col: i32, row: i32) -> MapUnit { + let mut u = MapUnit::default(); + u.id = id; + u.unit_id = "warrior".into(); + u.col = col; + u.row = row; + u.hp = 100; + u.max_hp = 100; + // movement_remaining drives the projected `moves_left`; without it the unit + // can't step and the frontier scorer has nothing to emit. + u.base_moves = 2; + u.movement_remaining = 2; + u +} + +#[test] +fn idle_military_frontier_seeks_toward_unexplored_with_real_fog() { + let mut state = GameState::default(); + state.turn = 1; + state.grid = Some(grassland_grid(20, 20)); + + // Player 0: a lone warrior in the (2,2) corner. Its sight covers a small + // radius; the rest of the 20x20 map is unexplored fog. + let mut p0 = PlayerState::default(); + p0.player_index = 0; + p0.units.push(warrior(1, 2, 2)); + state.players.push(p0); + + // Player 1: a warrior in the far (17,17) corner — hidden by fog, so player 0 + // has no visible enemy and its warrior is idle-military (must explore). + let mut p1 = PlayerState::default(); + p1.player_index = 1; + p1.units.push(warrior(2, 17, 17)); + state.players.push(p1); + + let vs = compute_vision(&state, &VisionCatalog::default(), None); + let pv = vs.for_player(0).expect("player 0 vision"); + let mut tactical = project_tactical_with_vision(&state, 0, Some(pv)); + tactical.current_player = 0; + + // Fog is real and reaches the projection: the far corner is unexplored. + let far = tactical + .map + .tiles + .iter() + .find(|t| t.hex == (17, 17)) + .map(|t| t.explored); + assert_eq!(far, Some(false), "the far corner must project as unexplored"); + assert!( + tactical.map.tiles.iter().any(|t| !t.explored), + "the tactical map must carry fog for the frontier scorer to act on" + ); + + let mut rng = XorShift64::new(42); + let mut memory = TacticalMemory::default(); + let weights = ScoringWeights::default(); + let actions = decide_tactical_actions(&tactical, &weights, &mut rng, None, &mut memory); + + // The idle warrior must issue an exploration move, not idle/fortify. + let to_hex = actions + .iter() + .find_map(|a| match a { + Action::MoveUnit { unit_id: 1, to_hex } => Some(*to_hex), + _ => None, + }) + .expect("idle military unit must issue an exploration MoveUnit toward the fog"); + + // The step must leave the start tile (head into the map toward unexplored + // territory), not stay put. + assert_ne!(to_hex, (2, 2), "exploration move must advance off the start tile"); +}