diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index c1bd56a7..27a44ee9 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -3601,6 +3601,10 @@ impl TurnProcessor { // p2-55: drain capture/ransom/destroyed events into the TurnResult. state.pending_capture_events.drain_into(result); + + // p3-18 transport — units whose carrier hull was destroyed this phase go + // down with it (lost at sea). + prune_orphaned_cargo(state); } // ── Phase 5c: City siege ────────────────────────────────────────────── @@ -4706,6 +4710,30 @@ fn embark_level_for(player: &crate::game_state::PlayerState) -> mc_core::EmbarkL player.embark_level } +/// p3-18 transport — drop any unit whose `carrier_id` references a hull that no +/// longer exists in its player's roster (the transport was destroyed). Carried +/// units go down with the ship. Order-preserving (removes descending indices) +/// and keeps the index-parallel `unit_upkeep` aligned. Idempotent — safe to call +/// after any combat phase. +fn prune_orphaned_cargo(state: &mut crate::game_state::GameState) { + for player in state.players.iter_mut() { + let live: std::collections::HashSet = player.units.iter().map(|u| u.id).collect(); + let drop: Vec = player + .units + .iter() + .enumerate() + .filter(|(_, u)| matches!(u.carrier_id, Some(cid) if !live.contains(&cid))) + .map(|(i, _)| i) + .collect(); + for &i in drop.iter().rev() { + player.units.remove(i); + if i < player.unit_upkeep.len() { + player.unit_upkeep.remove(i); + } + } + } +} + fn process_one_move(state: &mut GameState, req: &crate::game_state::MoveRequest) -> MoveOutcome { use mc_pathfinding::{find_path, UnitDomain}; @@ -5179,6 +5207,29 @@ mod move_request_tests { assert_eq!(state.players[0].units[0].carrier_id, None, "warrior stayed off the full hull"); } + #[test] + fn destroyed_transport_loses_cargo() { + // carrier id 2 is absent from the roster (destroyed); its cargo (id 1) + // must be pruned, while an uncarried unit (id 3) survives. + let mut state = transport_state(); + state.players.push(PlayerState { + player_index: 0, + units: vec![ + MapUnit { + id: 1, col: 2, row: 0, unit_id: "warrior".into(), + carrier_id: Some(2), ..MapUnit::default() + }, + MapUnit { id: 3, col: 0, row: 0, unit_id: "warrior".into(), ..MapUnit::default() }, + ], + unit_upkeep: vec![1, 1], + ..PlayerState::default() + }); + prune_orphaned_cargo(&mut state); + let ids: Vec = state.players[0].units.iter().map(|u| u.id).collect(); + assert_eq!(ids, vec![3], "orphaned cargo lost with the sunk hull; uncarried unit survives"); + assert_eq!(state.players[0].unit_upkeep.len(), 1, "unit_upkeep stays index-aligned"); + } + #[test] fn zero_budget_rejects() { let mut state = build_state_with_unit(7, (0, 0), 0, |_, _| "plains");