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:
parent
ba81bcf476
commit
b40fc80bbc
5 changed files with 221 additions and 0 deletions
|
|
@ -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 0–1) and water (cols 2–4), 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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue