feat(@projects/@magic-civilization): p3-18 P4b — transport board / carry / disembark

Implements the carry mechanic in process_one_move, modelled as automatic moves
(consistent with auto-embark — no new explicit actions):

- BOARD: a land unit stepping onto an adjacent friendly transport hull (on water)
  boards it (carrier_id = hull.id) instead of being blocked — no embark tech
  needed, capacity-gated at TRANSPORT_CAPACITY. Handled before find_path (a land
  unit can't path onto water otherwise).
- CARRY: when a transport moves, its loaded units are dragged to the new hex
  (they ride the hull's hex, stacked).
- DISEMBARK: a carried unit steps off onto an adjacent empty land hex (carrier_id
  cleared); rejected otherwise (carried units can't move independently on water).

Boarding/disembarking units are aboard, not embarked (is_embarked stays false).

Tests: transport_board_carry_then_unload (full cycle) + transport_rejects_boarding_when_full;
mc-turn 246 lib + all integration binaries green (the new modes only activate for
units with carrier_id or a transport-at-target, so existing movement is untouched).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-25 06:07:27 -04:00
parent ba81bcf476
commit b40fc80bbc
5 changed files with 221 additions and 0 deletions

View file

@ -4803,6 +4803,93 @@ fn process_one_move(state: &mut GameState, req: &crate::game_state::MoveRequest)
.map(|s| UnitDomain::from_str(&s.domain))
.unwrap_or(UnitDomain::Land);
// ── p3-18 transport — board / disembark are single-hex land↔water steps the
// normal land/water pathfinder cannot express (a land unit can't path onto
// water without embark; a carried unit sits on the hull's water hex). Handle
// them up front, before find_path. ───────────────────────────────────────
let mover_carrier = state.players[req.player_idx].units[req.unit_idx].carrier_id;
let mover_has_move = state.players[req.player_idx].units[req.unit_idx].movement_remaining > 0;
let adjacent = mc_pathfinding::hex_distance(from, target) == 1;
// Disembark: a carried unit steps off onto an adjacent, empty, passable land
// hex (no embark tech needed — it rode the hull, it isn't swimming).
if mover_carrier.is_some() {
let dest_is_land = state
.grid
.as_ref()
.map_or(true, |g| !mc_pathfinding::is_water_at(g, target));
let occupied = state
.players
.iter()
.flat_map(|p| p.units.iter())
.any(|u| u.col == target.0 && u.row == target.1 && u.id != unit_id_u32);
if adjacent && dest_is_land && !occupied && mover_has_move {
let u = &mut state.players[req.player_idx].units[req.unit_idx];
u.carrier_id = None;
u.col = target.0;
u.row = target.1;
u.is_embarked = false;
u.movement_remaining = (u.movement_remaining - 1).max(0);
return MoveOutcome::Moved {
unit_id: unit_id_u32,
from,
to: target,
path: vec![target],
cost: 1,
};
}
return MoveOutcome::Rejected {
unit_id: Some(unit_id_u32),
reason: "carried unit can only disembark to an adjacent empty land hex".into(),
};
}
// Board: a land unit steps onto an adjacent friendly transport hull with
// spare capacity; the hull carries it across water (no embark tech needed).
if matches!(domain, UnitDomain::Land) {
let transport_at_target = state.players[req.player_idx]
.units
.iter()
.find(|u| {
u.col == target.0
&& u.row == target.1
&& u.id != unit_id_u32
&& state
.units_catalog
.get(&u.unit_id)
.is_some_and(|s| s.is_transport())
})
.map(|u| u.id);
if let Some(tid) = transport_at_target {
let load = state.players[req.player_idx]
.units
.iter()
.filter(|u| u.carrier_id == Some(tid))
.count();
if load >= mc_units::TRANSPORT_CAPACITY {
return MoveOutcome::Rejected {
unit_id: Some(unit_id_u32),
reason: "transport is at capacity".into(),
};
}
if adjacent && mover_has_move {
let u = &mut state.players[req.player_idx].units[req.unit_idx];
u.carrier_id = Some(tid);
u.col = target.0;
u.row = target.1;
u.is_embarked = false;
u.movement_remaining = (u.movement_remaining - 1).max(0);
return MoveOutcome::Moved {
unit_id: unit_id_u32,
from,
to: target,
path: vec![target],
cost: 1,
};
}
}
}
// 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).
@ -4863,6 +4950,22 @@ fn process_one_move(state: &mut GameState, req: &crate::game_state::MoveRequest)
u.is_embarked = dest_is_water;
}
// p3-18 — a transport hull drags its loaded units to the new hex so they
// stay aboard. Carried units occupy the hull's hex (stacked) until they
// disembark onto land.
if state
.units_catalog
.get(&unit_type)
.is_some_and(|s| s.is_transport())
{
for u in state.players[req.player_idx].units.iter_mut() {
if u.carrier_id == Some(unit_id_u32) {
u.col = target.0;
u.row = target.1;
}
}
}
// p2-59 — drag the linked protected unit into the escort's vacated tile.
// The escort just left `from`, so it is empty; the protectee was ≤ radius
// away, so the single-tile step is always legal. If the vacated tile is
@ -4966,6 +5069,116 @@ mod move_request_tests {
assert!(state.pending_move_requests.is_empty(), "queue must be drained");
}
/// p3-18 transport — grid with land (cols 01) and water (cols 24), a land
/// `warrior` + a naval `barge` (transport keyword). Exercises the full
/// board → carry → carry-back → disembark cycle.
fn transport_state() -> GameState {
let mut grid = GridState::new(5, 5);
for r in 0..5 {
for c in 0..5 {
let idx = grid.idx(c, r);
grid.tiles[idx].biome_label_id =
if c >= 2 { "ocean" } else { "plains" }.to_string();
}
}
let mut state = GameState::default();
state.grid = Some(grid);
state
.units_catalog
.load_json_str(
r#"[
{"id":"warrior","movement":4,"domain":"land"},
{"id":"barge","movement":4,"domain":"naval","keywords":["transport"]}
]"#,
)
.expect("catalog loads");
state
}
fn move_req(unit_idx: usize, to: (i32, i32)) -> MoveRequest {
MoveRequest { player_idx: 0, unit_idx, target_col: to.0, target_row: to.1 }
}
#[test]
fn transport_board_carry_then_unload() {
let mut state = transport_state();
state.players.push(PlayerState {
player_index: 0,
units: vec![
MapUnit { id: 1, col: 1, row: 0, unit_id: "warrior".into(), ..MapUnit::default() }
.with_moves(4),
MapUnit { id: 2, col: 2, row: 0, unit_id: "barge".into(), ..MapUnit::default() }
.with_moves(4),
],
..PlayerState::default()
});
// 1) BOARD: warrior (1,0) → barge hex (2,0).
state.pending_move_requests.push(move_req(0, (2, 0)));
process_move_requests(&mut state);
assert_eq!(state.players[0].units[0].carrier_id, Some(2), "warrior boarded the barge");
assert_eq!((state.players[0].units[0].col, state.players[0].units[0].row), (2, 0));
assert!(!state.players[0].units[0].is_embarked, "aboard a hull, not swimming");
// 2) CARRY: barge (2,0) → (3,0); the carried warrior follows.
state.players[0].units[1].movement_remaining = 4;
state.pending_move_requests.push(move_req(1, (3, 0)));
process_move_requests(&mut state);
assert_eq!((state.players[0].units[1].col, state.players[0].units[1].row), (3, 0), "barge moved");
assert_eq!(
(state.players[0].units[0].col, state.players[0].units[0].row),
(3, 0),
"carried warrior moved with the hull"
);
assert_eq!(state.players[0].units[0].carrier_id, Some(2), "still aboard");
// 3) CARRY back to (2,0) so the hull is adjacent to land again.
state.players[0].units[1].movement_remaining = 4;
state.pending_move_requests.push(move_req(1, (2, 0)));
process_move_requests(&mut state);
assert_eq!((state.players[0].units[0].col, state.players[0].units[0].row), (2, 0));
// 4) DISEMBARK: warrior (2,0 water) → adjacent empty land (1,0).
state.players[0].units[0].movement_remaining = 4;
state.pending_move_requests.push(move_req(0, (1, 0)));
process_move_requests(&mut state);
assert_eq!(state.players[0].units[0].carrier_id, None, "warrior disembarked");
assert_eq!((state.players[0].units[0].col, state.players[0].units[0].row), (1, 0), "back on land");
assert!(!state.players[0].units[0].is_embarked);
}
#[test]
fn transport_rejects_boarding_when_full() {
let mut state = transport_state();
// barge id 2 already carries 2 (= TRANSPORT_CAPACITY); warrior id 1 tries to board.
state.players.push(PlayerState {
player_index: 0,
units: vec![
MapUnit { id: 1, col: 1, row: 0, unit_id: "warrior".into(), ..MapUnit::default() }
.with_moves(4),
MapUnit { id: 2, col: 2, row: 0, unit_id: "barge".into(), ..MapUnit::default() }
.with_moves(4),
MapUnit {
id: 3, col: 2, row: 0, unit_id: "warrior".into(),
carrier_id: Some(2), ..MapUnit::default()
},
MapUnit {
id: 4, col: 2, row: 0, unit_id: "warrior".into(),
carrier_id: Some(2), ..MapUnit::default()
},
],
..PlayerState::default()
});
state.pending_move_requests.push(move_req(0, (2, 0)));
let out = process_move_requests(&mut state);
assert!(
matches!(out[0], MoveOutcome::Rejected { .. }),
"boarding a full transport must be rejected, got {:?}",
out[0]
);
assert_eq!(state.players[0].units[0].carrier_id, None, "warrior stayed off the full hull");
}
#[test]
fn zero_budget_rejects() {
let mut state = build_state_with_unit(7, (0, 0), 0, |_, _| "plains");

View file

@ -37,6 +37,7 @@ fn build_trade_catalog() -> UnitsCatalog {
ransom_multiplier: 2.0,
build_cost: 0,
logistics: None,
keywords: Vec::new(),
combat: CombatStats::default(),
});
// merchant.json: tier-1 trade GP — premium ransom multiplier, modest cost.
@ -49,6 +50,7 @@ fn build_trade_catalog() -> UnitsCatalog {
ransom_multiplier: 3.0,
build_cost: 80,
logistics: None,
keywords: Vec::new(),
combat: CombatStats::default(),
});
// caravan_master.json: tier-3 — higher cost AND higher multiplier.
@ -61,6 +63,7 @@ fn build_trade_catalog() -> UnitsCatalog {
ransom_multiplier: 3.5,
build_cost: 160,
logistics: None,
keywords: Vec::new(),
combat: CombatStats::default(),
});
cat

View file

@ -41,6 +41,7 @@ fn build_engineer_catalog() -> UnitsCatalog {
ransom_multiplier: 2.0,
build_cost: 0,
logistics: None,
keywords: Vec::new(),
combat: CombatStats::default(),
});
// dwarf_engineer.json shape — capturable, premium ransom multiplier,
@ -54,6 +55,7 @@ fn build_engineer_catalog() -> UnitsCatalog {
ransom_multiplier: 3.0,
build_cost: 70,
logistics: None,
keywords: Vec::new(),
combat: CombatStats::default(),
});
cat

View file

@ -55,6 +55,7 @@ fn build_capturable_catalog() -> UnitsCatalog {
ransom_multiplier: 2.0,
build_cost: 0,
logistics: None,
keywords: Vec::new(),
combat: CombatStats::default(),
});
cat.insert(CatalogUnitStats {
@ -66,6 +67,7 @@ fn build_capturable_catalog() -> UnitsCatalog {
ransom_multiplier: 2.0,
build_cost: 70,
logistics: None,
keywords: Vec::new(),
combat: CombatStats::default(),
});
cat

View file

@ -87,6 +87,7 @@ fn catalog_unit(id: &str, hp: i32, attack: i32, defense: i32) -> CatalogUnitStat
ransom_multiplier: 2.0,
build_cost: 0,
logistics: None,
keywords: Vec::new(),
combat: CombatStats {
hp,
max_hp: None,