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:
parent
d49993e3dd
commit
d4cf465236
9 changed files with 116 additions and 10 deletions
|
|
@ -115,6 +115,7 @@ fn build_huge_map() -> TacticalMap {
|
|||
resource,
|
||||
is_coast: row == 0 || row == HUGE_MAP_HEIGHT as i32 - 1,
|
||||
owner,
|
||||
explored: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -307,6 +307,7 @@ mod tests {
|
|||
resource: None,
|
||||
is_coast: false,
|
||||
owner,
|
||||
explored: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -350,6 +350,7 @@ mod tests {
|
|||
resource: None,
|
||||
is_coast: false,
|
||||
owner: if col < 15 { Some(0) } else { Some(1) },
|
||||
explored: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -367,6 +367,7 @@ mod tests {
|
|||
resource: None,
|
||||
is_coast: false,
|
||||
owner: None,
|
||||
explored: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ mod tests {
|
|||
resource: None,
|
||||
is_coast: false,
|
||||
owner: if col < 2 { Some(0) } else { Some(1) },
|
||||
explored: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue