feat(@projects/@magic-civilization): p3-18 P4c — carried units lost when their transport is destroyed

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) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-25 06:13:06 -04:00
parent b40fc80bbc
commit 8b4c71688f

View file

@ -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<u32> = player.units.iter().map(|u| u.id).collect();
let drop: Vec<usize> = 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<u32> = 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");