feat(@projects/@magic-civilization): 🤖 p3-17 frontier-seek exploration using real fog (TacticalTile.explored)

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) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-25 00:11:26 -04:00
parent d49993e3dd
commit d4cf465236
9 changed files with 116 additions and 10 deletions

View file

@ -115,6 +115,7 @@ fn build_huge_map() -> TacticalMap {
resource,
is_coast: row == 0 || row == HUGE_MAP_HEIGHT as i32 - 1,
owner,
explored: true,
});
}
}

View file

@ -307,6 +307,7 @@ mod tests {
resource: None,
is_coast: false,
owner,
explored: true,
}
}

View file

@ -350,6 +350,7 @@ mod tests {
resource: None,
is_coast: false,
owner: if col < 15 { Some(0) } else { Some(1) },
explored: true,
});
}
}

View file

@ -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

View file

@ -367,6 +367,7 @@ mod tests {
resource: None,
is_coast: false,
owner: None,
explored: true,
});
}
}

View file

@ -86,6 +86,18 @@ pub struct TacticalTile {
pub is_coast: bool,
/// Owning player slot, if any. Unowned tiles are `None`.
pub owner: Option<u8>,
/// 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();

View file

@ -145,6 +145,7 @@ mod tests {
resource: None,
is_coast: false,
owner: if col < 2 { Some(0) } else { Some(1) },
explored: true,
});
}
}

View file

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

View file

@ -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();