From 8b4c71688f4610ed89ff5b6cfea04c6c93dc0ca2 Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 25 Jun 2026 06:13:06 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9B=B5=20p3-18=20P4c=20=E2=80=94=20carried=20units=20lost=20?= =?UTF-8?q?when=20their=20transport=20is=20destroyed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the PvP combat phase, prune_orphaned_cargo drops any unit whose carrier_id references a hull no longer in its player's roster — the transport sank, its cargo goes down with it. Order-preserving removal that keeps the index-parallel unit_upkeep aligned; idempotent and a no-op for carrier-free rosters (so existing combat is untouched — pvp tests still green). Test: destroyed_transport_loses_cargo (orphaned cargo pruned, uncarried unit survives, unit_upkeep stays aligned). Known follow-up: carried units ride the hull's hex (stacked); fully shielding them from being individually targeted needs combat target-selection changes that touch 1UPT assumptions — tracked, not in this commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/simulator/crates/mc-turn/src/processor.rs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) 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");