diff --git a/.project/objectives/p3-18-water-crossing-embark-transport.md b/.project/objectives/p3-18-water-crossing-embark-transport.md new file mode 100644 index 00000000..f3dc116c --- /dev/null +++ b/.project/objectives/p3-18-water-crossing-embark-transport.md @@ -0,0 +1,85 @@ +--- +id: p3-18 +title: Water crossing — land-unit embarkation + naval transport +priority: p3 +status: partial +scope: game1 +owner: warcouncil +updated_at: 2026-06-25 +--- + +## Summary + +Land armies are permanently confined to their starting landmass: `mc-pathfinding` +gates water purely by `UnitDomain` (`is_passable("ocean", Land)` is hard-`false`), +with no tech override and no way to embark or be ferried. On maps where the two +capitals sit on separate landmasses (common — the start-balancer maximally +separates capitals on the 40×24 `duel` map, often ocean-separated), conquest by a +land army is impossible. This is the Civ "embarkation" tech gap. + +The scaffolding is **half-built** (existing tech debt): `mc_combat::siege:: +embarked_defence_penalty` (halves an embarked unit's defence — the Civ-V/VI +vulnerability rule) and a `transport` keyword in `combat.json` ("carry up to 2 +land units across water", on `dwarf_fortress_ship`) both exist, but neither is +wired into movement/pathfinding, so they are dead. + +## Decision (owner, 2026-06-25): implement BOTH models + +- **Embarkation** (Civ V/VI baseline): a land unit can enter water itself once its + player has the enabling tech, becoming *embarked* — vulnerable (the existing + `embarked_defence_penalty`), disembarks onto land. +- **Transport** (Civ IV option): wire the `transport` keyword — load up to N land + units onto a transport-capable ship, ferry, unload onto adjacent land. Carried + units are protected (ride the hull, not separately exposed). + +## Tech gating (reuses the existing naval tree — no new techs) + +The water biomes are tagged (`BiomeTag::IsCoast` for `coast`/`coastal_cliffs`/ +`lake` shallow water; `IsWater & !IsCoast` for `ocean`/`deep_ocean`). Map to the +two-tier Civ model on the existing tree (`public/resources/techs/naval.json`): + +- **Coast/shallow embark** ← `shipbuilding` (the first naval tech). +- **Deep-ocean embark** ← `ocean_navigation`. + +## Acceptance (phased — each phase green + committed) + +- [x] **P1 — pathfinding embark gate.** ✅ `mc-pathfinding` gained `EmbarkLevel` + ({None, Coast, Ocean}); `is_passable` / `find_path` now take it and let a Land + unit cross water per level (coastal `IsCoast` water needs Coast, open/deep ocean + needs Ocean; `None` = legacy land-locked). The move handler + (`processor.rs::process_one_move`) derives the level from the player's naval tech + via `embark_level_for` (`ocean_navigation`→Ocean, `shipbuilding`→Coast). Tests: + `mc-pathfinding` `embark_gates_land_on_water_by_level` + + `ocean_embark_lets_a_land_unit_cross_an_ocean_strip` (9/9 green); mc-turn suite + green (None default preserves behaviour). Commit pending. +- [ ] **P2 — embarked combat.** Wire `embarked_defence_penalty` at the resolve + site: a land defender on a water tile fights at halved defence. Parity test + (predict vs resolve) + a unit test. +- [ ] **P3 — AI water-pathing.** The tactical movement passable-set + (`mc-ai/tactical/movement.rs`) includes water when the unit's player can embark, + so the frontier-seek / march cross water. Self-play test: cross-water first + contact / conquest now reachable (extends `ai_self_play_first_contact.rs`). +- [ ] **P4 — transport load/carry/unload.** Implement the `transport` keyword: + load adjacent land units (≤ capacity) onto a transport ship, they move with it, + unload onto adjacent land. Carried units protected. Unit tests. +- [ ] **P5 — GDScript pathfinder mirror.** `pathfinder.gd::_is_passable` mirrors + the embark gate (or, preferably, the GDScript movement delegates to the Rust + pathfinder so there is one implementation — Rail 1). +- [ ] **P6 — end-to-end.** A headless 1v1 (both sides driven) reaches a decisive + `game_over` by crossing water to capture the enemy capital — the demo that + motivated this objective. + +## Code sites + +- `mc-pathfinding/src/lib.rs` — `is_passable`, `find_path`, `UnitDomain`. +- `mc-turn/src/processor.rs:4791` — the lone `find_path` caller (has player ctx). +- `mc-ai/src/tactical/movement.rs` — AI passable-set (its own neighbour gate). +- `mc-combat/src/siege.rs:168` — `embarked_defence_penalty` (exists; wire it). +- `combat.json` `transport` keyword; `dwarf_fortress_ship` (has it). +- `src/game/engine/src/map/pathfinder.gd:245-260` — GDScript mirror. + +## Notes + +This finishes pre-existing half-built scaffolding rather than adding net-new dead +code. Motivated by the headless 1v1 self-play demo, where a land army could not +cross the ocean separating the two capitals (no embark/transport). diff --git a/src/simulator/crates/mc-pathfinding/src/lib.rs b/src/simulator/crates/mc-pathfinding/src/lib.rs index bae34c4d..f9a642f1 100644 --- a/src/simulator/crates/mc-pathfinding/src/lib.rs +++ b/src/simulator/crates/mc-pathfinding/src/lib.rs @@ -102,12 +102,29 @@ pub fn hex_distance(a: HexCoord, b: HexCoord) -> i32 { mc_core::algorithms::hex::offset_distance(a.0, a.1, b.0, b.1) } -/// Passability check — mirrors `_is_passable` at `pathfinder.gd:245-260`. +/// Embarkation capability — how much water a land unit's player can cross +/// (Civ-style; p3-18). Derived from the naval tech tree by the caller; non-land +/// domains ignore it. Embarked land units fight at halved defence +/// (`mc_combat::siege::embarked_defence_penalty`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EmbarkLevel { + /// No embarkation — land units are confined to land (no naval tech). + None, + /// Coastal embark — cross `IsCoast` (near-shore) water only. Civ "Optics"; + /// here gated by `shipbuilding`. + Coast, + /// Deep-water embark — cross any water, incl. open / deep ocean. Civ + /// "Astronomy"; here gated by `ocean_navigation`. + Ocean, +} + +/// Passability check — mirrors `_is_passable` at `pathfinder.gd:245-260`, plus +/// the p3-18 embarkation gate for land units on water. /// /// Substitutes biome-tag membership for the GDScript JSON `flags` lookup /// (see crate-level docs for the mapping table). #[inline] -pub fn is_passable(tile_biome_id: &str, domain: UnitDomain) -> bool { +pub fn is_passable(tile_biome_id: &str, domain: UnitDomain, embark: EmbarkLevel) -> bool { let is_water = has_tag(tile_biome_id, BiomeTag::IsWater); match domain { // pathfinder.gd:249-251 — flying ignores everything except impassable; @@ -115,10 +132,19 @@ pub fn is_passable(tile_biome_id: &str, domain: UnitDomain) -> bool { UnitDomain::Flying => true, // pathfinder.gd:252-254 — naval requires water. UnitDomain::Naval => is_water, - // pathfinder.gd:255-260 — land blocked by water / naval_only / impassable. - // All three GDScript flags collapse to "is water" under the Rust biome - // registry; no impassable-tagged biome exists in Game 1 content. - UnitDomain::Land => !is_water, + // Land on land is always fine. Land on water requires embarkation + // capability (p3-18): coastal water (`IsCoast`) needs `Coast`, open/deep + // ocean needs `Ocean`. Without the tech, water blocks land as before. + UnitDomain::Land => { + if !is_water { + return true; + } + match embark { + EmbarkLevel::None => false, + EmbarkLevel::Coast => has_tag(tile_biome_id, BiomeTag::IsCoast), + EmbarkLevel::Ocean => true, + } + } } } @@ -153,6 +179,7 @@ pub fn find_path( goal: HexCoord, movement_budget: i32, domain: UnitDomain, + embark: EmbarkLevel, ) -> Vec { // pathfinder.gd:32-33 — early exit on same-tile. if start == goal { @@ -162,7 +189,7 @@ pub fn find_path( let Some(goal_biome) = tile_biome_at(grid, goal) else { return Vec::new(); }; - if !is_passable(&goal_biome, domain) { + if !is_passable(&goal_biome, domain, embark) { return Vec::new(); } @@ -210,7 +237,7 @@ pub fn find_path( for nb in neighbors { let Some(nb_biome) = tile_biome_at(grid, nb) else { continue }; // pathfinder.gd:79 - if !is_passable(&nb_biome, domain) { + if !is_passable(&nb_biome, domain, embark) { continue; } // pathfinder.gd:82-83 @@ -278,24 +305,27 @@ mod tests { grid } + // Land units default to no embarkation in these legacy tests. + const NO_EMBARK: EmbarkLevel = EmbarkLevel::None; + #[test] fn same_tile_returns_empty() { let grid = make_grid(3, 3, |_, _| "plains"); - let p = find_path(&grid, (1, 1), (1, 1), 10, UnitDomain::Land); + let p = find_path(&grid, (1, 1), (1, 1), 10, UnitDomain::Land, NO_EMBARK); assert!(p.is_empty()); } #[test] fn unreachable_water_for_land_unit_returns_empty() { let grid = make_grid(3, 3, |c, r| if (c, r) == (2, 2) { "ocean" } else { "plains" }); - let p = find_path(&grid, (0, 0), (2, 2), 100, UnitDomain::Land); - assert!(p.is_empty(), "land unit must not reach water goal"); + let p = find_path(&grid, (0, 0), (2, 2), 100, UnitDomain::Land, NO_EMBARK); + assert!(p.is_empty(), "land unit without embark must not reach water goal"); } #[test] fn naval_unit_pathing_only_water() { let grid = make_grid(3, 3, |c, _r| if c < 2 { "ocean" } else { "plains" }); - let p = find_path(&grid, (0, 0), (0, 2), 10, UnitDomain::Naval); + let p = find_path(&grid, (0, 0), (0, 2), 10, UnitDomain::Naval, NO_EMBARK); assert!(!p.is_empty()); assert_eq!(*p.last().unwrap(), (0, 2)); } @@ -303,7 +333,7 @@ mod tests { #[test] fn land_unit_short_path_excludes_start_includes_goal() { let grid = make_grid(5, 5, |_, _| "plains"); - let p = find_path(&grid, (0, 0), (2, 0), 10, UnitDomain::Land); + let p = find_path(&grid, (0, 0), (2, 0), 10, UnitDomain::Land, NO_EMBARK); assert!(!p.is_empty(), "expected reachable path"); assert_ne!(p[0], (0, 0), "start must be excluded"); assert_eq!(*p.last().unwrap(), (2, 0), "goal must be last entry"); @@ -312,24 +342,58 @@ mod tests { #[test] fn budget_exhausted_returns_empty() { let grid = make_grid(10, 10, |_, _| "plains"); - let p = find_path(&grid, (0, 0), (8, 0), 3, UnitDomain::Land); + let p = find_path(&grid, (0, 0), (8, 0), 3, UnitDomain::Land, NO_EMBARK); assert!(p.is_empty(), "path exceeds budget 3, must reject"); } #[test] fn flying_crosses_water() { let grid = make_grid(3, 3, |c, _r| if c == 1 { "ocean" } else { "plains" }); - let p = find_path(&grid, (0, 1), (2, 1), 10, UnitDomain::Flying); + let p = find_path(&grid, (0, 1), (2, 1), 10, UnitDomain::Flying, NO_EMBARK); assert!(!p.is_empty(), "flying must cross water column"); } #[test] fn is_passable_matches_gd_table() { - assert!(is_passable("plains", UnitDomain::Land)); - assert!(!is_passable("ocean", UnitDomain::Land)); - assert!(is_passable("ocean", UnitDomain::Naval)); - assert!(!is_passable("plains", UnitDomain::Naval)); - assert!(is_passable("ocean", UnitDomain::Flying)); - assert!(is_passable("mountains", UnitDomain::Land)); + assert!(is_passable("plains", UnitDomain::Land, NO_EMBARK)); + assert!(!is_passable("ocean", UnitDomain::Land, NO_EMBARK)); + assert!(is_passable("ocean", UnitDomain::Naval, NO_EMBARK)); + assert!(!is_passable("plains", UnitDomain::Naval, NO_EMBARK)); + assert!(is_passable("ocean", UnitDomain::Flying, NO_EMBARK)); + assert!(is_passable("mountains", UnitDomain::Land, NO_EMBARK)); + } + + // ── p3-18 embarkation ──────────────────────────────────────────────── + + #[test] + fn embark_gates_land_on_water_by_level() { + // Coast level: crosses coastal (IsCoast) water only. + assert!(is_passable("coast", UnitDomain::Land, EmbarkLevel::Coast)); + assert!(!is_passable("ocean", UnitDomain::Land, EmbarkLevel::Coast), + "coastal embark must NOT cross open ocean"); + // Ocean level: crosses any water. + assert!(is_passable("coast", UnitDomain::Land, EmbarkLevel::Ocean)); + assert!(is_passable("ocean", UnitDomain::Land, EmbarkLevel::Ocean)); + assert!(is_passable("deep_ocean", UnitDomain::Land, EmbarkLevel::Ocean)); + // None: water blocks land (legacy behaviour). + assert!(!is_passable("coast", UnitDomain::Land, EmbarkLevel::None)); + // Land tiles unaffected by embark level. + assert!(is_passable("plains", UnitDomain::Land, EmbarkLevel::Ocean)); + // Naval/Flying ignore embark entirely. + assert!(is_passable("ocean", UnitDomain::Naval, EmbarkLevel::None)); + assert!(!is_passable("plains", UnitDomain::Naval, EmbarkLevel::Ocean)); + } + + #[test] + fn ocean_embark_lets_a_land_unit_cross_an_ocean_strip() { + // Ocean column splits two landmasses. No embark → blocked; Ocean → crosses. + let grid = make_grid(3, 3, |c, _r| if c == 1 { "ocean" } else { "plains" }); + assert!( + find_path(&grid, (0, 1), (2, 1), 10, UnitDomain::Land, EmbarkLevel::None).is_empty(), + "land unit without embark cannot cross the ocean strip" + ); + let p = find_path(&grid, (0, 1), (2, 1), 10, UnitDomain::Land, EmbarkLevel::Ocean); + assert!(!p.is_empty(), "ocean-embarked land unit crosses the strip"); + assert_eq!(*p.last().unwrap(), (2, 1)); } } diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index d0681a52..35dffdd7 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -4687,6 +4687,19 @@ pub enum MoveOutcome { }, } +/// p3-18 — a player's embarkation capability, derived from the naval tech tree. +/// `ocean_navigation` → cross any water (deep ocean); `shipbuilding` → coastal +/// water only; otherwise land units stay landlocked. Used to gate `find_path` +/// for land units so a teched army can cross water. +fn embark_level_for(player: &crate::game_state::PlayerState) -> mc_pathfinding::EmbarkLevel { + use mc_pathfinding::EmbarkLevel; + match &player.player_tech { + Some(pt) if pt.has_tech("ocean_navigation") => EmbarkLevel::Ocean, + Some(pt) if pt.has_tech("shipbuilding") => EmbarkLevel::Coast, + _ => EmbarkLevel::None, + } +} + fn process_one_move(state: &mut GameState, req: &crate::game_state::MoveRequest) -> MoveOutcome { use mc_pathfinding::{find_path, UnitDomain}; @@ -4786,9 +4799,11 @@ fn process_one_move(state: &mut GameState, req: &crate::game_state::MoveRequest) // Pathfind only when grid is available; bench tests without a grid // teleport (decrement cost = 1). + // p3-18 — gate land-on-water by the player's naval tech (embarkation). + let embark = embark_level_for(&state.players[req.player_idx]); let (path, cost) = match &state.grid { Some(grid) => { - let p = find_path(grid, from, target, budget, domain); + let p = find_path(grid, from, target, budget, domain, embark); if p.is_empty() { // Try a partial move: pathfind to the furthest tile along // the straight line that's reachable. Simplest fallback: