feat(@projects/@magic-civilization): p3-18 P3 — AI paths across water when teched

The tactical AI's own passability gate (passable_land_hexes) hard-excluded all
water, so even a teched army never considered crossing it — exploration and the
march-on-enemy-capital both stopped at the shoreline. Now the AI is embark-aware:

- TacticalState carries the bound player's embark_level (data-driven; projected
  from PlayerState::embark_level by project_tactical_with_vision).
- passable_land_hexes → passable_hexes(map, embark): water tiles open per the
  embark level (coast water needs Coast, open/deep ocean needs Ocean), mirroring
  mc_pathfinding::is_passable. Non-water impassables (mountains/volcano/ice) stay
  blocked regardless of embark.

So a frontier-seeking or capital-marching army with the naval tech will path
across water to reach a rival on another landmass — the discovery/conquest gap
that motivated p3-18.

Tests: embark_opens_water_for_passability (None/Coast/Ocean tiers) +
embark_never_opens_non_water_impassables; mc-ai lib 287 green (the embark=None
default preserves all existing movement behaviour).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-25 05:00:17 -04:00
parent 10e99af962
commit 6cd5abb210
13 changed files with 93 additions and 7 deletions

View file

@ -258,6 +258,7 @@ mod tests {
unit_catalog: Vec::new(),
building_catalog: Vec::new(),
difficulty_threshold_mult: 1.0,
embark_level: mc_core::EmbarkLevel::None,
}
}

View file

@ -377,6 +377,7 @@ mod tests {
unit_catalog: Vec::new(),
building_catalog: Vec::new(),
difficulty_threshold_mult: 1.0,
embark_level: mc_core::EmbarkLevel::None,
}
}

View file

@ -130,6 +130,7 @@ mod tests {
unit_catalog: Vec::new(),
building_catalog: Vec::new(),
difficulty_threshold_mult: 1.0,
embark_level: mc_core::EmbarkLevel::None,
}
}

View file

@ -457,6 +457,7 @@ mod tests {
unit_catalog: Vec::new(),
building_catalog: Vec::new(),
difficulty_threshold_mult: 1.0,
embark_level: mc_core::EmbarkLevel::None,
}
}

View file

@ -129,7 +129,7 @@ pub(crate) fn decide_movement(
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 passable = passable_hexes(&state.map, state.embark_level);
let mut actions = Vec::with_capacity(me.units.len());
@ -857,10 +857,29 @@ pub(crate) fn decide_siege_action(
/// 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)> {
fn passable_hexes(map: &TacticalMap, embark: mc_core::EmbarkLevel) -> HashSet<(i32, i32)> {
use mc_core::grid::biome_registry::{has_tag, BiomeTag};
use mc_core::EmbarkLevel;
map.tiles
.iter()
.filter(|t| !IMPASSABLE_BIOMES.contains(&t.biome.as_str()))
.filter(|t| {
// Ordinary land is always passable.
if !IMPASSABLE_BIOMES.contains(&t.biome.as_str()) {
return true;
}
// p3-18 — water tiles open to embarked land units per the player's
// embark capability (coast water needs Coast, open/deep ocean needs
// Ocean). Non-water impassables (mountains / volcano / ice) stay
// blocked. Mirrors `mc_pathfinding::is_passable`.
if !has_tag(&t.biome, BiomeTag::IsWater) {
return false;
}
match embark {
EmbarkLevel::None => false,
EmbarkLevel::Coast => has_tag(&t.biome, BiomeTag::IsCoast),
EmbarkLevel::Ocean => true,
}
})
.map(|t| t.hex)
.collect()
}
@ -1001,7 +1020,7 @@ mod tests {
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 passable = passable_hexes(&map, mc_core::EmbarkLevel::None);
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:?}");
@ -1028,7 +1047,7 @@ mod tests {
Some((3, 0)),
"nearest unexplored tile is the frontier target"
);
let passable = passable_land_hexes(&map);
let passable = passable_hexes(&map, mc_core::EmbarkLevel::None);
let Some(Action::MoveUnit { to_hex, .. }) =
score_explore_move(&me.units[0], &me, &map, &passable)
else {
@ -1051,7 +1070,7 @@ mod tests {
None,
"a fully-explored map has no frontier"
);
let passable = passable_land_hexes(&map);
let passable = passable_hexes(&map, mc_core::EmbarkLevel::None);
assert!(
score_explore_move(&me.units[0], &me, &map, &passable).is_some(),
"fully-explored fallback still yields an exploration move"
@ -1070,7 +1089,7 @@ mod tests {
}
}
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 passable = passable_hexes(&map, mc_core::EmbarkLevel::None);
if let Some(Action::MoveUnit { to_hex, .. }) =
score_explore_move(&me.units[0], &me, &map, &passable)
{
@ -1078,6 +1097,39 @@ mod tests {
}
}
#[test]
fn embark_opens_water_for_passability() {
use mc_core::EmbarkLevel;
// strip: plains | open ocean | coastal water.
let mut map = plains_map(3, 1);
map.tiles[1].biome = "ocean".into();
map.tiles[2].biome = "coast".into();
let none = passable_hexes(&map, EmbarkLevel::None);
assert!(none.contains(&(0, 0)));
assert!(!none.contains(&(1, 0)), "ocean blocked without embark");
assert!(!none.contains(&(2, 0)), "coast blocked without embark");
let coast = passable_hexes(&map, EmbarkLevel::Coast);
assert!(coast.contains(&(2, 0)), "coast passable with Coast embark");
assert!(!coast.contains(&(1, 0)), "open ocean still needs Ocean embark");
let ocean = passable_hexes(&map, EmbarkLevel::Ocean);
assert!(
ocean.contains(&(1, 0)) && ocean.contains(&(2, 0)),
"all water passable with Ocean embark"
);
}
#[test]
fn embark_never_opens_non_water_impassables() {
use mc_core::EmbarkLevel;
let mut map = plains_map(2, 1);
map.tiles[1].biome = "mountains".into();
let p = passable_hexes(&map, EmbarkLevel::Ocean);
assert!(!p.contains(&(1, 0)), "embark must not open mountains/volcano/ice");
}
fn empty_map() -> TacticalMap {
TacticalMap {
width: 0,
@ -1178,6 +1230,7 @@ mod tests {
unit_catalog: Vec::new(),
building_catalog: Vec::new(),
difficulty_threshold_mult: 1.0,
embark_level: mc_core::EmbarkLevel::None,
}
}
@ -1201,6 +1254,7 @@ mod tests {
unit_catalog: Vec::new(),
building_catalog: Vec::new(),
difficulty_threshold_mult: 1.0,
embark_level: mc_core::EmbarkLevel::None,
};
let actions = decide_movement(&s, &weights(), &mut rng(), None, &mut crate::tactical::memory::TacticalMemory::default());
assert!(actions.is_empty());

View file

@ -940,6 +940,7 @@ mod tests {
unit_catalog: Vec::new(),
building_catalog: Vec::new(),
difficulty_threshold_mult: 1.0,
embark_level: mc_core::EmbarkLevel::None,
}
}

View file

@ -97,6 +97,7 @@ mod tests {
unit_catalog: Vec::new(),
building_catalog: Vec::new(),
difficulty_threshold_mult: 1.0,
embark_level: mc_core::EmbarkLevel::None,
}
}

View file

@ -422,6 +422,7 @@ mod tests {
unit_catalog: Vec::new(),
building_catalog: Vec::new(),
difficulty_threshold_mult: 1.0,
embark_level: mc_core::EmbarkLevel::None,
}
}

View file

@ -53,6 +53,12 @@ pub struct TacticalState {
/// `difficulty.json::ai_modifiers.difficulty_threshold_mult` by the bridge.
#[serde(default = "default_threshold_mult")]
pub difficulty_threshold_mult: f32,
/// p3-18 — the bound player's embarkation capability (data-driven from naval
/// tech; see [`mc_core::EmbarkLevel`]). Lets movement passability open water
/// tiles so a teched army can path across water. Defaults to `None`
/// (land-locked) when a projection or the bridge omits it.
#[serde(default)]
pub embark_level: mc_core::EmbarkLevel,
}
fn default_threshold_mult() -> f32 {
@ -359,6 +365,7 @@ impl TacticalEphemerals {
unit_catalog: self.unit_catalog,
building_catalog: self.building_catalog,
difficulty_threshold_mult: self.difficulty_threshold_mult,
embark_level: mc_core::EmbarkLevel::None,
}
}
}
@ -524,6 +531,7 @@ mod tests {
unit_catalog: Vec::new(),
building_catalog: Vec::new(),
difficulty_threshold_mult: 1.0,
embark_level: mc_core::EmbarkLevel::None,
}
}
@ -553,6 +561,7 @@ mod tests {
unit_catalog: Vec::new(),
building_catalog: Vec::new(),
difficulty_threshold_mult: 1.0,
embark_level: mc_core::EmbarkLevel::None,
};
let json = serde_json::to_string(&empty).expect("serialize");
let back: TacticalState = serde_json::from_str(&json).expect("deserialize");

View file

@ -175,6 +175,7 @@ mod tests {
unit_catalog: Vec::new(),
building_catalog: Vec::new(),
difficulty_threshold_mult: 1.0,
embark_level: mc_core::EmbarkLevel::None,
}
}

View file

@ -152,6 +152,7 @@ fn state_with(player: TacticalPlayerState) -> TacticalState {
unit_catalog: Vec::new(),
building_catalog: catalog,
difficulty_threshold_mult: 1.0,
embark_level: mc_core::EmbarkLevel::None,
}
}

View file

@ -181,6 +181,7 @@ fn two_player_state() -> TacticalState {
unit_catalog: Vec::new(),
building_catalog: Vec::new(),
difficulty_threshold_mult: 1.0,
embark_level: mc_core::EmbarkLevel::None,
}
}
@ -222,6 +223,7 @@ fn settler_only_state() -> TacticalState {
unit_catalog: Vec::new(),
building_catalog: Vec::new(),
difficulty_threshold_mult: 1.0,
embark_level: mc_core::EmbarkLevel::None,
}
}
@ -279,6 +281,7 @@ fn production_state() -> TacticalState {
unit_catalog: Vec::new(),
building_catalog: Vec::new(),
difficulty_threshold_mult: 1.0,
embark_level: mc_core::EmbarkLevel::None,
}
}
@ -360,6 +363,7 @@ fn tactical_state_empty_roundtrip() {
unit_catalog: Vec::new(),
building_catalog: Vec::new(),
difficulty_threshold_mult: 1.0,
embark_level: mc_core::EmbarkLevel::None,
};
let json = serde_json::to_string(&state).expect("serialize");
let back: TacticalState = serde_json::from_str(&json).expect("deserialize");
@ -392,6 +396,7 @@ fn tactical_state_with_100_tile_map_roundtrip() {
unit_catalog: Vec::new(),
building_catalog: Vec::new(),
difficulty_threshold_mult: 1.0,
embark_level: mc_core::EmbarkLevel::None,
};
let json = serde_json::to_string(&state).expect("serialize");
let back: TacticalState = serde_json::from_str(&json).expect("deserialize");

View file

@ -1036,6 +1036,14 @@ pub fn project_tactical_with_vision(
1.0
};
// p3-18 — surface the bound player's embark capability so the tactical
// movement passability can open water tiles for a teched army.
let embark_level = state
.players
.get(player as usize)
.map(|p| p.embark_level)
.unwrap_or_default();
TacticalState {
current_player: player,
turn: state.turn,
@ -1044,6 +1052,7 @@ pub fn project_tactical_with_vision(
unit_catalog,
building_catalog,
difficulty_threshold_mult,
embark_level,
}
}