diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 2a05bc32..c1bd56a7 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -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"); diff --git a/src/simulator/crates/mc-turn/tests/capture_caravan.rs b/src/simulator/crates/mc-turn/tests/capture_caravan.rs index 25cf45a4..cb9bdd07 100644 --- a/src/simulator/crates/mc-turn/tests/capture_caravan.rs +++ b/src/simulator/crates/mc-turn/tests/capture_caravan.rs @@ -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 diff --git a/src/simulator/crates/mc-turn/tests/capture_engineer.rs b/src/simulator/crates/mc-turn/tests/capture_engineer.rs index 229c6810..a189b4a9 100644 --- a/src/simulator/crates/mc-turn/tests/capture_engineer.rs +++ b/src/simulator/crates/mc-turn/tests/capture_engineer.rs @@ -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 diff --git a/src/simulator/crates/mc-turn/tests/capture_pvp_end_to_end.rs b/src/simulator/crates/mc-turn/tests/capture_pvp_end_to_end.rs index 368ee51d..d3d65fdc 100644 --- a/src/simulator/crates/mc-turn/tests/capture_pvp_end_to_end.rs +++ b/src/simulator/crates/mc-turn/tests/capture_pvp_end_to_end.rs @@ -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 diff --git a/src/simulator/crates/mc-turn/tests/quality_spawn_live_processor.rs b/src/simulator/crates/mc-turn/tests/quality_spawn_live_processor.rs index e1ea747c..d4bffbfe 100644 --- a/src/simulator/crates/mc-turn/tests/quality_spawn_live_processor.rs +++ b/src/simulator/crates/mc-turn/tests/quality_spawn_live_processor.rs @@ -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,