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:
parent
10e99af962
commit
6cd5abb210
13 changed files with 93 additions and 7 deletions
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue