test(@projects/@magic-civilization): 🧪 p3-17 end-to-end exploration gate (fog → projection → decision)

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) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-25 00:38:35 -04:00
parent d4cf465236
commit 6a4d87a029

View file

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