diff --git a/crates/helm-bv/src/loadout.rs b/crates/helm-bv/src/loadout.rs index f2d6d86..f39d4e7 100644 --- a/crates/helm-bv/src/loadout.rs +++ b/crates/helm-bv/src/loadout.rs @@ -188,17 +188,16 @@ impl<'a> Loadout<'a> { // them. Without this a design whose jets are all shot out looks like // one that never had any, and falls back to the `jump mp:` line. let mut had_jump_jets = false; + // A hit anywhere in a multi-slot item destroys the item, so the slots + // are grouped into whole items first and a group with a hit in it is + // dropped entire - across locations, since an item can span two. + let shot_away = shot_out_across(unit, catalogue, condition); for (location, slots) in &unit.criticals { - // What a critical hit took out here, and which magazines are - // empty. Both are asked per slot, so they are looked up once for - // the location rather than once per line. + // Which magazines are empty here, asked per slot and so looked up + // once for the location rather than once per line. let none = BTreeSet::new(); - let destroyed = condition.destroyed.get(location).unwrap_or(&none); let empty = condition.empty_ammo.get(location).unwrap_or(&none); - // A hit anywhere in a multi-slot item destroys the item, so the - // slots are grouped into whole items first and a group with a hit - // in it is dropped entire. - let gone = shot_out(slots, catalogue, destroyed); + let gone = shot_away.get(location).unwrap_or(&none); for (index, line) in slots.iter().enumerate() { if gone.contains(&index) || empty.contains(&index) { // A heat sink shot out of an arm is cooling the design no @@ -680,15 +679,30 @@ fn sink_dissipation(entry: &EquipmentEntry) -> Option { /// into whole items the way they were installed, and a group containing a hit /// is gone entire. Grouping matters: five machine guns in a row are five /// items, and losing one is not losing five. +/// Part of an item that does not fit in one location: what it is, how many +/// slots the whole item takes, which of this location's slots it holds, and +/// whether any of them was hit. +struct Part { + name: String, + size: usize, + slots: std::ops::Range, + hit: bool, +} + +/// Where one item's parts are, and how big the whole item is. +#[derive(Default)] +struct Scattered { + size: usize, + parts: Vec<(String, std::ops::Range, bool)>, +} + fn shot_out( slots: &[String], catalogue: &Catalogue, destroyed: &BTreeSet, + partials: &mut Vec, ) -> BTreeSet { let mut gone = BTreeSet::new(); - if destroyed.is_empty() { - return gone; - } let mut index = 0; while index < slots.len() { let name = clean(&slots[index]).name; @@ -705,14 +719,86 @@ fn shot_out( while run < size && index + run < slots.len() && clean(&slots[index + run]).name == name { run += 1; } - if (index..index + run).any(|i| destroyed.contains(&i)) { + let hit = (index..index + run).any(|i| destroyed.contains(&i)); + if hit { gone.extend(index..index + run); } + // Fewer slots here than the item takes, so the rest of it is in + // another location: a Victor VTR-9Ka's autocannon fills nine slots of + // an arm and one of the torso beside it. Reported so the caller can + // pair the halves up - within one location there is nothing to pair. + if run < size && !name.is_empty() { + partials.push(Part { + name, + size, + slots: index..index + run, + hit, + }); + } index += run; } gone } +/// Which slots a critical hit has taken out, across the whole design. +/// +/// A weapon too big for one location has the rest of itself in the next one, +/// and it is one weapon: a hit on either half destroys it. `shot_out` works a +/// location at a time and cannot see that, so the halves are paired up here. +/// +/// The pairing is by name, which is exact for the one case that exists - a +/// design carries at most one split item of a given kind - and would over-pair +/// a design carrying two split autocannons of the same model. MegaMek tracks +/// each mount rather than each name; matching that needs the loader rather +/// than the file, which is the same gap `UnitFacets::loadout` records. +fn shot_out_across( + unit: &Unit, + catalogue: &Catalogue, + condition: &crate::Condition, +) -> BTreeMap> { + let mut gone: BTreeMap> = BTreeMap::new(); + let mut partials: BTreeMap = BTreeMap::new(); + let none = BTreeSet::new(); + for (location, slots) in &unit.criticals { + let destroyed = condition.destroyed.get(location).unwrap_or(&none); + let mut here = Vec::new(); + let out = shot_out(slots, catalogue, destroyed, &mut here); + if !out.is_empty() { + gone.insert(location.clone(), out); + } + for part in here { + let scattered = partials.entry(part.name).or_default(); + scattered.size = part.size; + scattered + .parts + .push((location.clone(), part.slots, part.hit)); + } + } + + // Two runs that add up to one item are one item in two places: if either + // half was hit, the whole thing is gone. + // + // Exactly two, and they have to account for the whole item. A Chameleon + // Light Polarization Shield or a run of Endo-Composite is spread over + // half the design a slot or two at a time - by construction rather than + // because it did not fit - and each of those slots stands alone. Pairing + // on the name alone took a Scarecrow UCU-F4's shield out with its arm, + // which MegaMek does not do. + for scattered in partials.values() { + let parts = &scattered.parts; + let whole: usize = parts.iter().map(|(_, range, _)| range.len()).sum(); + if parts.len() != 2 || whole != scattered.size || !parts.iter().any(|(_, _, hit)| *hit) { + continue; + } + for (location, range, _) in parts { + gone.entry(location.clone()) + .or_default() + .extend(range.clone()); + } + } + gone +} + /// Whether a location is an arm, which can turn to face what it is shooting. fn is_arm(location: &str) -> bool { let l = location.to_ascii_lowercase(); @@ -768,6 +854,83 @@ struct Marked { mod tests { use super::*; + /// A weapon too big for one location has the rest of itself in the next, + /// and it is one weapon: a Victor VTR-9Ka's autocannon fills nine slots of + /// an arm and one of the torso beside it, and blowing the arm off destroys + /// it. Reading a location at a time leaves the torso slot behind, which + /// helm counted as a whole autocannon the Mek no longer had. + #[test] + fn a_weapon_split_over_two_locations_dies_with_either_half() { + let cat = Catalogue::new(vec![EquipmentEntry { + internal_name: "Autocannon/20".into(), + classes: vec!["WeaponType".into()], + criticals: Some(10), + battle_value: Some(178.0), + ..Default::default() + }]); + let unit = Unit { + criticals: [ + ( + "Right Arm".to_string(), + vec!["Autocannon/20".to_string(); 9], + ), + ("Right Torso".to_string(), vec!["Autocannon/20".to_string()]), + ] + .into_iter() + .collect(), + ..Default::default() + }; + let mut condition = crate::Condition::undamaged(); + condition + .destroyed + .insert("Right Arm".to_string(), (0..9).collect()); + + let gone = shot_out_across(&unit, &cat, &condition); + assert_eq!( + gone.get("Right Torso").map(|s| s.len()), + Some(1), + "the torso half survived the arm: {gone:?}" + ); + } + + /// And the other way: a Chameleon Light Polarization Shield is spread over + /// half a design a slot at a time *by construction* rather than because it + /// did not fit, and each of those slots stands alone. Pairing on the name + /// alone took a Scarecrow UCU-F4's shield out with its arm, which MegaMek + /// does not do. + #[test] + fn a_system_spread_over_many_locations_is_not_one_split_item() { + let cat = Catalogue::new(vec![EquipmentEntry { + internal_name: "Chameleon Light Polarization Shield".into(), + classes: vec!["MiscType".into()], + criticals: Some(6), + ..Default::default() + }]); + let name = "Chameleon Light Polarization Shield".to_string(); + let unit = Unit { + criticals: [ + ("Right Arm".to_string(), vec![name.clone()]), + ("Left Arm".to_string(), vec![name.clone()]), + ("Right Torso".to_string(), vec![name.clone()]), + ("Left Torso".to_string(), vec![name.clone()]), + ] + .into_iter() + .collect(), + ..Default::default() + }; + let mut condition = crate::Condition::undamaged(); + condition + .destroyed + .insert("Right Arm".to_string(), [0].into()); + + let gone = shot_out_across(&unit, &cat, &condition); + assert_eq!(gone.get("Right Arm").map(|s| s.len()), Some(1)); + assert!( + !gone.contains_key("Left Torso"), + "the shield was taken out everywhere: {gone:?}" + ); + } + fn catalogue() -> Catalogue { Catalogue::new(vec![ EquipmentEntry {