diff --git a/src/simulator/crates/mc-ai/src/tactical/apply.rs b/src/simulator/crates/mc-ai/src/tactical/apply.rs index 4b9646ea..a3d6cbd1 100644 --- a/src/simulator/crates/mc-ai/src/tactical/apply.rs +++ b/src/simulator/crates/mc-ai/src/tactical/apply.rs @@ -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, } } diff --git a/src/simulator/crates/mc-ai/src/tactical/citizen.rs b/src/simulator/crates/mc-ai/src/tactical/citizen.rs index 510deda5..b6c0181e 100644 --- a/src/simulator/crates/mc-ai/src/tactical/citizen.rs +++ b/src/simulator/crates/mc-ai/src/tactical/citizen.rs @@ -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, } } diff --git a/src/simulator/crates/mc-ai/src/tactical/diplomacy.rs b/src/simulator/crates/mc-ai/src/tactical/diplomacy.rs index 9dd1e4fb..efcd4f68 100644 --- a/src/simulator/crates/mc-ai/src/tactical/diplomacy.rs +++ b/src/simulator/crates/mc-ai/src/tactical/diplomacy.rs @@ -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, } } diff --git a/src/simulator/crates/mc-ai/src/tactical/mod.rs b/src/simulator/crates/mc-ai/src/tactical/mod.rs index 5ca8d785..ac92fea9 100644 --- a/src/simulator/crates/mc-ai/src/tactical/mod.rs +++ b/src/simulator/crates/mc-ai/src/tactical/mod.rs @@ -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, } } diff --git a/src/simulator/crates/mc-ai/src/tactical/movement.rs b/src/simulator/crates/mc-ai/src/tactical/movement.rs index 3a4fa58a..5b85fd60 100644 --- a/src/simulator/crates/mc-ai/src/tactical/movement.rs +++ b/src/simulator/crates/mc-ai/src/tactical/movement.rs @@ -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()); diff --git a/src/simulator/crates/mc-ai/src/tactical/production.rs b/src/simulator/crates/mc-ai/src/tactical/production.rs index ab872727..ab96173d 100644 --- a/src/simulator/crates/mc-ai/src/tactical/production.rs +++ b/src/simulator/crates/mc-ai/src/tactical/production.rs @@ -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, } } diff --git a/src/simulator/crates/mc-ai/src/tactical/scoring.rs b/src/simulator/crates/mc-ai/src/tactical/scoring.rs index 9dcc43ba..ccdd5875 100644 --- a/src/simulator/crates/mc-ai/src/tactical/scoring.rs +++ b/src/simulator/crates/mc-ai/src/tactical/scoring.rs @@ -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, } } diff --git a/src/simulator/crates/mc-ai/src/tactical/settle.rs b/src/simulator/crates/mc-ai/src/tactical/settle.rs index 514cff0e..aa4898fa 100644 --- a/src/simulator/crates/mc-ai/src/tactical/settle.rs +++ b/src/simulator/crates/mc-ai/src/tactical/settle.rs @@ -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, } } diff --git a/src/simulator/crates/mc-ai/src/tactical/state.rs b/src/simulator/crates/mc-ai/src/tactical/state.rs index 88e52300..ccb0e0c6 100644 --- a/src/simulator/crates/mc-ai/src/tactical/state.rs +++ b/src/simulator/crates/mc-ai/src/tactical/state.rs @@ -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"); 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 1ca0ed72..17a65c04 100644 --- a/src/simulator/crates/mc-ai/src/tactical/tree_state.rs +++ b/src/simulator/crates/mc-ai/src/tactical/tree_state.rs @@ -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, } } diff --git a/src/simulator/crates/mc-ai/tests/personality_building_bias.rs b/src/simulator/crates/mc-ai/tests/personality_building_bias.rs index 25325581..481c3953 100644 --- a/src/simulator/crates/mc-ai/tests/personality_building_bias.rs +++ b/src/simulator/crates/mc-ai/tests/personality_building_bias.rs @@ -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, } } 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 b78eadb1..24e75d71 100644 --- a/src/simulator/crates/mc-ai/tests/tactical_port_regression.rs +++ b/src/simulator/crates/mc-ai/tests/tactical_port_regression.rs @@ -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"); diff --git a/src/simulator/crates/mc-player-api/src/projection.rs b/src/simulator/crates/mc-player-api/src/projection.rs index 62ddb132..969f91f9 100644 --- a/src/simulator/crates/mc-player-api/src/projection.rs +++ b/src/simulator/crates/mc-player-api/src/projection.rs @@ -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, } }