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:
parent
9d2d2bee8b
commit
bb28c4e7b1
1 changed files with 127 additions and 5 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue