feat(mc-ai): 🧭 frontier-seeking exploration for idle military units (p3-17)

Idle military units (no combat target, no locked target) fell to a garrison
patrol that kept them next to friendly cities, so the AI never physically
explored to discover the rival — starving both target-acquisition and the
war-dec discovery gate (p3-16). decide_movement now drives such units toward
the far side of the map (in a duel the rival sits opposite the player's own
holdings) with a per-unit lateral offset so a stack fans out instead of
clumping. Reuses emit_move_toward; a passable-hex set keeps moves on land;
keyed on unit.id so it stays deterministic. Runs before the garrison fallback
(an idle unit with no known enemy is more useful scouting than fortifying).

Tests: 2 new movement cases (steps toward far side; never onto impassable
water). mc-ai 276 green.

Honest measurement note: in seed-42 hotseat self-play the headline summary
barely moves — expansion (30 cities founded) already drives first contact
~turn 17, which is distance-limited (~33 hexes at 2 mp/turn), and the
unit_moved event is a noisy proxy (a position probe shows both sides' military
marching toward each other every turn while the counter sits at ~45). The
value here is correctness — idle military now seeks the frontier deterministically
rather than idling — not a dramatic self-play metric swing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-23 21:06:07 -04:00
parent 9d2d2bee8b
commit bb28c4e7b1

View file

@ -17,6 +17,7 @@
//! garrison_target via its `is_military` / `is_civilian` split, which
//! we reproduce here through the `unit_role` classifier.
use std::collections::HashSet;
use std::time::Instant;
use mc_core::action::{legal_actions, ActionKind, UnitCapability};
@ -25,7 +26,15 @@ use mc_core::algorithms::hex::{axial_distance, axial_neighbors};
use crate::evaluator::ScoringWeights;
use crate::mcts::XorShift64;
use super::{Action, TacticalPlayerState, TacticalState, TacticalUnit};
use super::{Action, TacticalMap, TacticalPlayerState, TacticalState, TacticalUnit};
/// Biomes a land unit cannot stand on. Mirrors `mc_mapgen::spawn_box`
/// FORBIDDEN the pathfinder's land-impassable set (and the harness's
/// `_scan_land_tiles` in `player_api_main.gd`). Used to keep exploration
/// targets on reachable ground.
const IMPASSABLE_BIOMES: &[&str] = &[
"ocean", "deep_ocean", "coast", "inland_sea", "lake", "mountains", "volcano", "ice",
];
// ── Constants ────────────────────────────────────────────────────────────
//
@ -119,6 +128,9 @@ pub(crate) fn decide_movement(
// are past the equal-start phase).
let is_trailing = compute_is_trailing(state, me);
// Passable-ground lookup for frontier exploration (p3-17), built once.
let passable = passable_land_hexes(&state.map);
let mut actions = Vec::with_capacity(me.units.len());
for unit in &me.units {
@ -175,9 +187,14 @@ pub(crate) fn decide_movement(
if primary.is_some() {
primary
} else {
// Try patrol heuristic (scout sweep / chokepoint garrison),
// fall back to Fortify or idle via non_motion_macro.
score_patrol_for_military(unit, me, &enemy_city_positions)
// No combat target: explore toward the frontier to find
// the rival (p3-17), then fall back to the patrol
// heuristic (scout sweep / chokepoint garrison), then
// Fortify / idle. Exploration precedes garrison because
// an idle unit with no known enemy is more useful
// scouting than fortifying in place.
score_explore_move(unit, me, &state.map, &passable)
.or_else(|| score_patrol_for_military(unit, me, &enemy_city_positions))
.or_else(|| non_motion_macro(unit))
}
}
@ -838,6 +855,55 @@ pub(crate) fn decide_siege_action(
/// Ties are broken by the canonical neighbor order (E, NE, NW, W, SW,
/// SE) — first neighbor with the top score wins, so no RNG tiebreak is
/// needed and the function stays deterministic on `state` alone.
/// Set of passable land hexes on the map, for keeping exploration on
/// reachable ground. Built once per `decide_movement` call.
fn passable_land_hexes(map: &TacticalMap) -> HashSet<(i32, i32)> {
map.tiles
.iter()
.filter(|t| !IMPASSABLE_BIOMES.contains(&t.biome.as_str()))
.map(|t| t.hex)
.collect()
}
/// Frontier-seeking move for an idle military unit with no combat target
/// (p3-17). The AI knows the full map *terrain* but only currently-visible
/// enemy units/cities, so it must physically move to discover the rival.
/// We drive the unit toward the far side of the map — in a duel the rival
/// sits opposite the player's own holdings — with a per-unit lateral offset
/// so a stack of idle units fans out across the frontier instead of
/// clumping onto one hex. Deterministic (keyed on `unit.id`), so a replayed
/// turn produces the same sweep.
fn score_explore_move(
unit: &TacticalUnit,
me: &TacticalPlayerState,
map: &TacticalMap,
passable: &HashSet<(i32, i32)>,
) -> Option<Action> {
if map.width == 0 || map.height == 0 {
return None;
}
let (cx, cy) = army_centroid(me);
let w = map.width as i32;
let h = map.height as i32;
// Reflect the army centroid across the map to aim at the opposite reach;
// `unit.id`-derived lateral spread (3..=3) disperses a stack.
let spread = (unit.id % 7) as i32 - 3;
let target = (
(w - 1 - cx).clamp(0, w - 1),
(h - 1 - cy + spread).clamp(0, h - 1),
);
if target == unit.hex {
return None;
}
let score = |hex: (i32, i32)| -> f32 {
if !passable.contains(&hex) {
return f32::NEG_INFINITY;
}
-(axial_distance(hex.0, hex.1, target.0, target.1) as f32)
};
emit_move_toward(unit, &[], &score)
}
fn emit_move_toward(
unit: &TacticalUnit,
enemy_units: &[&TacticalUnit],
@ -873,7 +939,63 @@ fn emit_move_toward(
#[cfg(test)]
mod tests {
use super::*;
use crate::tactical::{TacticalCity, TacticalMap, TacticalPlayerState, TacticalState, TacticalUnit};
use crate::tactical::{
TacticalCity, TacticalMap, TacticalPlayerState, TacticalState, TacticalTile, TacticalUnit,
};
fn plains_map(w: i32, h: i32) -> TacticalMap {
let mut tiles = Vec::new();
for row in 0..h {
for col in 0..w {
tiles.push(TacticalTile {
hex: (col, row),
biome: "plains".into(),
yields: (1, 1, 0),
resource: None,
is_coast: false,
owner: None,
});
}
}
TacticalMap { width: w as u32, height: h as u32, tiles }
}
#[test]
fn explore_drives_idle_unit_toward_far_side() {
let map = plains_map(10, 10);
// Unit + capital tucked in the (0,0) corner → far side is (9,9).
let me = player(0, vec![warrior(1, (1, 1), 10)], vec![city(0, (0, 0), true)], vec![0, 0]);
let passable = passable_land_hexes(&map);
let action = score_explore_move(&me.units[0], &me, &map, &passable);
let Some(Action::MoveUnit { unit_id, to_hex }) = action else {
panic!("expected an exploration move, got {action:?}");
};
assert_eq!(unit_id, 1);
assert!(
axial_distance(to_hex.0, to_hex.1, 0, 0) > axial_distance(1, 1, 0, 0),
"exploration must step away from home (0,0); moved to {to_hex:?}"
);
}
#[test]
fn explore_never_steps_onto_impassable_water() {
// All plains except an ocean ring along the far edge. The unit must
// pick a passable neighbour, never an ocean hex, even though ocean
// lies in the target direction.
let mut map = plains_map(6, 6);
for t in map.tiles.iter_mut() {
if t.hex.0 == 5 || t.hex.1 == 5 {
t.biome = "ocean".into();
}
}
let me = player(0, vec![warrior(1, (1, 1), 10)], vec![city(0, (0, 0), true)], vec![0, 0]);
let passable = passable_land_hexes(&map);
if let Some(Action::MoveUnit { to_hex, .. }) =
score_explore_move(&me.units[0], &me, &map, &passable)
{
assert!(passable.contains(&to_hex), "stepped onto impassable hex {to_hex:?}");
}
}
fn empty_map() -> TacticalMap {
TacticalMap {