From d4cf46523631e0ebabcdb33d900344c7dcc36e27 Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 25 Jun 2026 00:11:26 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=A4=96=20p3-17=20frontier-seek=20exploration=20using=20re?= =?UTF-8?q?al=20fog=20(TacticalTile.explored)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Idle military/scout units' explore move used a centroid-mirror heuristic (head for the far reach) that ignored actual fog, so the AI rarely expanded its explored footprint or made first contact — starving target acquisition and the p3-16 war-dec discovery gate. - Add `explored: bool` to TacticalTile (serde default true so un-flagged bridge tiles read as seen, never spuriously sought). projection.rs::project_tactical_map populates it from PlayerVision::is_explored (omniscient → all true). - Upgrade movement.rs::score_explore_move to target the nearest genuinely unexplored tile (nearest_unexplored_frontier), with a deterministic per-unit tie-break so a stack fans across frontier tiles; the centroid-mirror is kept as the fully-explored fallback. No rng/Instant — determinism contract preserved. - 2 new tests (targets-nearest-unexplored, mirror-fallback-when-fully-explored). The headless self-play path (dispatch → project_tactical_with_vision, rebuilt per turn) now drives real exploration. mc-ai+mc-player-api 555/0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../mc-ai/benches/tactical_state_build.rs | 1 + .../crates/mc-ai/src/tactical/citizen.rs | 1 + .../crates/mc-ai/src/tactical/mod.rs | 1 + .../crates/mc-ai/src/tactical/movement.rs | 101 ++++++++++++++++-- .../crates/mc-ai/src/tactical/settle.rs | 1 + .../crates/mc-ai/src/tactical/state.rs | 13 +++ .../crates/mc-ai/src/tactical/tree_state.rs | 1 + .../mc-ai/tests/tactical_port_regression.rs | 3 + .../crates/mc-player-api/src/projection.rs | 4 + 9 files changed, 116 insertions(+), 10 deletions(-) diff --git a/src/simulator/crates/mc-ai/benches/tactical_state_build.rs b/src/simulator/crates/mc-ai/benches/tactical_state_build.rs index 279df881..f22e8142 100644 --- a/src/simulator/crates/mc-ai/benches/tactical_state_build.rs +++ b/src/simulator/crates/mc-ai/benches/tactical_state_build.rs @@ -115,6 +115,7 @@ fn build_huge_map() -> TacticalMap { resource, is_coast: row == 0 || row == HUGE_MAP_HEIGHT as i32 - 1, owner, + explored: true, }); } } diff --git a/src/simulator/crates/mc-ai/src/tactical/citizen.rs b/src/simulator/crates/mc-ai/src/tactical/citizen.rs index ed663ab9..510deda5 100644 --- a/src/simulator/crates/mc-ai/src/tactical/citizen.rs +++ b/src/simulator/crates/mc-ai/src/tactical/citizen.rs @@ -307,6 +307,7 @@ mod tests { resource: None, is_coast: false, owner, + explored: true, } } diff --git a/src/simulator/crates/mc-ai/src/tactical/mod.rs b/src/simulator/crates/mc-ai/src/tactical/mod.rs index 03d7d43e..5ca8d785 100644 --- a/src/simulator/crates/mc-ai/src/tactical/mod.rs +++ b/src/simulator/crates/mc-ai/src/tactical/mod.rs @@ -350,6 +350,7 @@ mod tests { resource: None, is_coast: false, owner: if col < 15 { Some(0) } else { Some(1) }, + explored: true, }); } } diff --git a/src/simulator/crates/mc-ai/src/tactical/movement.rs b/src/simulator/crates/mc-ai/src/tactical/movement.rs index 4ab77f16..cd5a0704 100644 --- a/src/simulator/crates/mc-ai/src/tactical/movement.rs +++ b/src/simulator/crates/mc-ai/src/tactical/movement.rs @@ -882,16 +882,14 @@ fn score_explore_move( 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), - ); + // p3-17: aim at the nearest genuinely UNEXPLORED tile (real fog from + // `TacticalTile::explored`), so idle military / scout units expand the + // explored footprint and make first contact / discover enemy cities. When + // the map carries no fog (fully explored — e.g. omniscient self-play) fall + // back to the army-centroid mirror: a coarse "head for the far reach where + // the rival likely is" heuristic (the pre-p3-17 behaviour). + let target = + nearest_unexplored_frontier(unit, map).unwrap_or_else(|| centroid_mirror_target(unit, me, map)); if target == unit.hex { return None; } @@ -904,6 +902,43 @@ fn score_explore_move( emit_move_toward(unit, &[], &score) } +/// Nearest unexplored tile to `unit`, with a deterministic per-unit tie-break so +/// stacked units fan out to different frontier tiles instead of converging on +/// one. Returns `None` when every tile is explored (no fog). (p3-17) +fn nearest_unexplored_frontier(unit: &TacticalUnit, map: &TacticalMap) -> Option<(i32, i32)> { + let (ux, uy) = unit.hex; + map.tiles + .iter() + .filter(|t| !t.explored) + .min_by_key(|t| { + let d = axial_distance(ux, uy, t.hex.0, t.hex.1); + // Deterministic dispersion: mix tile coords with the unit id so + // co-located units don't all select the identical nearest frontier. + let tie = (t.hex.0.wrapping_mul(31) ^ t.hex.1).wrapping_add(unit.id as i32); + (d, tie) + }) + .map(|t| t.hex) +} + +/// Army-centroid mirror target — the pre-p3-17 heuristic, retained as the +/// fully-explored fallback. Reflects the army centroid across the map to aim at +/// the opposite reach; a `unit.id`-derived lateral spread (−3..=3) disperses a +/// stack. +fn centroid_mirror_target( + unit: &TacticalUnit, + me: &TacticalPlayerState, + map: &TacticalMap, +) -> (i32, i32) { + let (cx, cy) = army_centroid(me); + let w = map.width as i32; + let h = map.height as i32; + let spread = (unit.id % 7) as i32 - 3; + ( + (w - 1 - cx).clamp(0, w - 1), + (h - 1 - cy + spread).clamp(0, h - 1), + ) +} + fn emit_move_toward( unit: &TacticalUnit, enemy_units: &[&TacticalUnit], @@ -954,6 +989,7 @@ mod tests { resource: None, is_coast: false, owner: None, + explored: true, }); } } @@ -977,6 +1013,51 @@ mod tests { ); } + #[test] + fn frontier_seek_targets_nearest_unexplored_tile() { + // p3-17: fully-explored map except a single unexplored pocket at (3,0). + // A unit at (1,0) must target that real frontier — not the centroid + // mirror (which would aim at the far (9,9) corner). + let mut map = plains_map(10, 10); + for t in map.tiles.iter_mut() { + t.explored = t.hex != (3, 0); + } + let me = player(0, vec![warrior(1, (1, 0), 10)], vec![city(0, (0, 0), true)], vec![0, 0]); + assert_eq!( + nearest_unexplored_frontier(&me.units[0], &map), + Some((3, 0)), + "nearest unexplored tile is the frontier target" + ); + let passable = passable_land_hexes(&map); + let Some(Action::MoveUnit { to_hex, .. }) = + score_explore_move(&me.units[0], &me, &map, &passable) + else { + panic!("expected an exploration move toward the unexplored frontier"); + }; + assert!( + axial_distance(to_hex.0, to_hex.1, 3, 0) < axial_distance(1, 0, 3, 0), + "must step toward the unexplored frontier (3,0); moved to {to_hex:?}" + ); + } + + #[test] + fn frontier_seek_falls_back_to_mirror_when_fully_explored() { + // No fog at all → no frontier; the scorer must fall back to the + // centroid-mirror heuristic and still produce a move. + let map = plains_map(8, 8); + let me = player(0, vec![warrior(1, (1, 1), 10)], vec![city(0, (0, 0), true)], vec![0, 0]); + assert_eq!( + nearest_unexplored_frontier(&me.units[0], &map), + None, + "a fully-explored map has no frontier" + ); + let passable = passable_land_hexes(&map); + assert!( + score_explore_move(&me.units[0], &me, &map, &passable).is_some(), + "fully-explored fallback still yields an exploration move" + ); + } + #[test] fn explore_never_steps_onto_impassable_water() { // All plains except an ocean ring along the far edge. The unit must diff --git a/src/simulator/crates/mc-ai/src/tactical/settle.rs b/src/simulator/crates/mc-ai/src/tactical/settle.rs index 279ad888..514cff0e 100644 --- a/src/simulator/crates/mc-ai/src/tactical/settle.rs +++ b/src/simulator/crates/mc-ai/src/tactical/settle.rs @@ -367,6 +367,7 @@ mod tests { resource: None, is_coast: false, owner: None, + explored: true, }); } } diff --git a/src/simulator/crates/mc-ai/src/tactical/state.rs b/src/simulator/crates/mc-ai/src/tactical/state.rs index d6145f19..88e52300 100644 --- a/src/simulator/crates/mc-ai/src/tactical/state.rs +++ b/src/simulator/crates/mc-ai/src/tactical/state.rs @@ -86,6 +86,18 @@ pub struct TacticalTile { pub is_coast: bool, /// Owning player slot, if any. Unowned tiles are `None`. pub owner: Option, + /// Whether the bound player has ever explored this tile (fog-of-war seen). + /// Drives the frontier-seeking exploration scorer (p3-17): idle military / + /// scout units move toward `explored == false` tiles. Defaults to `true` + /// when a projection (or the GDScript bridge) omits it, so an un-flagged + /// tile is treated as already-seen and never spuriously sought. + #[serde(default = "default_explored")] + pub explored: bool, +} + +/// Serde default for [`TacticalTile::explored`] — un-flagged tiles read as seen. +fn default_explored() -> bool { + true } /// Per-player state: economy, units, cities, diplomacy, research. @@ -384,6 +396,7 @@ mod tests { } else { None }, + explored: true, }) }) .collect(); diff --git a/src/simulator/crates/mc-ai/src/tactical/tree_state.rs b/src/simulator/crates/mc-ai/src/tactical/tree_state.rs index 969b200e..1ca0ed72 100644 --- a/src/simulator/crates/mc-ai/src/tactical/tree_state.rs +++ b/src/simulator/crates/mc-ai/src/tactical/tree_state.rs @@ -145,6 +145,7 @@ mod tests { resource: None, is_coast: false, owner: if col < 2 { Some(0) } else { Some(1) }, + explored: true, }); } } diff --git a/src/simulator/crates/mc-ai/tests/tactical_port_regression.rs b/src/simulator/crates/mc-ai/tests/tactical_port_regression.rs index 1e3cf795..b78eadb1 100644 --- a/src/simulator/crates/mc-ai/tests/tactical_port_regression.rs +++ b/src/simulator/crates/mc-ai/tests/tactical_port_regression.rs @@ -34,6 +34,7 @@ fn small_map() -> TacticalMap { resource: None, is_coast: row == 0, owner: None, + explored: true, }); } } @@ -379,6 +380,7 @@ fn tactical_state_with_100_tile_map_roundtrip() { resource: if c == 5 && r == 5 { Some("iron".into()) } else { None }, is_coast: r == 0, owner: if c < 5 { Some(0) } else { Some(1) }, + explored: true, }); } } @@ -415,6 +417,7 @@ fn tactical_tile_resource_none_roundtrip() { resource: None, is_coast: false, owner: None, + explored: true, }; let json = serde_json::to_string(&tile).expect("serialize"); let back: TacticalTile = serde_json::from_str(&json).expect("deserialize"); diff --git a/src/simulator/crates/mc-player-api/src/projection.rs b/src/simulator/crates/mc-player-api/src/projection.rs index adbbfb40..62ddb132 100644 --- a/src/simulator/crates/mc-player-api/src/projection.rs +++ b/src/simulator/crates/mc-player-api/src/projection.rs @@ -1136,6 +1136,10 @@ fn project_tactical_map(state: &GameState, vision: Option<&PlayerVision>) -> Tac // Mark all unowned for v1; the tactical AI's settle/expand // path doesn't strictly require it. owner: None, + // p3-17: per-tile fog state for the frontier-seeking scorer. With no + // vision projection (omniscient / CP_OMNISCIENT) every tile reads as + // explored, so the AI only seeks frontier when fog is actually on. + explored: vision.map_or(true, |pv| pv.is_explored((t.col, t.row))), }) .collect();