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:
parent
19c53a6de1
commit
7f8f8682ee
3 changed files with 186 additions and 22 deletions
85
.project/objectives/p3-18-water-crossing-embark-transport.md
Normal file
85
.project/objectives/p3-18-water-crossing-embark-transport.md
Normal 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).
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue