feat(@projects/@magic-civilization): p3-18 P1 — tech-gated land embarkation in pathfinding

Land units were hard-confined to their landmass: is_passable("ocean", Land) was
always false, with no tech path across water (Civ "embarkation" gap). The
scaffolding existed but was dead (embarked_defence_penalty, the transport keyword).

P1 wires the first layer — the pathfinding gate:
- mc-pathfinding gains EmbarkLevel {None, Coast, Ocean}; is_passable + find_path
  take it. A Land unit may now enter water per level — coastal (IsCoast) water
  needs Coast, open/deep ocean needs Ocean. None = legacy land-locked (default,
  so existing behaviour is unchanged).
- The move handler (process_one_move) derives the level from the player's naval
  tree via embark_level_for: ocean_navigation→Ocean, shipbuilding→Coast. So a
  teched army can cross water; an un-teched one still cannot.

Maps onto the existing naval tech tree and the IsCoast/IsWater biome tags — no
new techs, no new biomes. Civ two-tier model (Optics/Astronomy → shipbuilding/
ocean_navigation).

Tests: mc-pathfinding 9/9 (incl. embark_gates_land_on_water_by_level +
ocean_embark_lets_a_land_unit_cross_an_ocean_strip); mc-turn suite green.

Objective p3-18 created (full design + phased acceptance P1–P6); P1 marked done.
Follow-ups: P2 embarked combat, P3 AI water-pathing, P4 transport, P5 GDScript
mirror, P6 end-to-end conquest demo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-25 03:03:17 -04:00
parent 19c53a6de1
commit 7f8f8682ee
3 changed files with 186 additions and 22 deletions

View file

@ -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).

View file

@ -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<HexCoord> {
// 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));
}
}

View file

@ -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: