From 12ec17eaf8fbed48942b5c27effbb5f8f30641bf Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 21 Aug 2026 11:07:53 -0400 Subject: [PATCH] feat(unit-search): the weapon bays a large aerospace design fires in A DropShip fires in bays rather than one weapon at a time, and its `.blk` marks each bay with a `(B)` that nothing read - so a Mingo's ten medium lasers read as four and its six laser bays as none. Which sort of bay is the weapon's own property, so `DumpEquipment` now carries it. Two more `.blk` spellings with it: a leading facing marker, and the two ways a magazine's load is written. Change-Id: I855e39151c78924d699b12e6c5f468aab52ea106 --- bridge/DumpEquipment.java | 28 +++++++ crates/helm-bridge/src/lib.rs | 3 + crates/helm-core/src/catalogue.rs | 8 ++ crates/helm-core/src/lib.rs | 2 +- crates/helm-core/src/metrics.rs | 41 ++++++++++ crates/helm-core/src/unit.rs | 8 +- crates/helm-facet/src/query.rs | 4 + crates/helm-facet/tests/loadout.rs | 2 +- crates/helm-unitfile/src/blk.rs | 115 ++++++++++++++++++++++++++++- crates/helm-unitfile/src/mtf.rs | 2 + 10 files changed, 208 insertions(+), 5 deletions(-) diff --git a/bridge/DumpEquipment.java b/bridge/DumpEquipment.java index cfac02c..a9c78f6 100644 --- a/bridge/DumpEquipment.java +++ b/bridge/DumpEquipment.java @@ -102,6 +102,18 @@ public final class DumpEquipment { } sb.append(']'); + // The bay a weapon is grouped into aboard a large + // aerospace unit. MegaMek makes one bay Mounted per + // group while it reads the design, and the grouping is + // in the weapon rather than in the file. + Object bay = bayType(et); + if (bay != null) { + sb.append(','); + str(sb, "bayType"); + sb.append(':'); + str(sb, String.valueOf(bay)); + } + // Later (more derived) classes win on a name clash, which is // what a caller reading "damage" expects to see. Map vals = new LinkedHashMap<>(); @@ -210,6 +222,22 @@ public final class DumpEquipment { return sb.toString(); } + /** + * The bay this weapon belongs to, by its lookup name, or null for + * anything that is not a weapon or is a bay itself. + */ + private static Object bayType(EquipmentType et) { + if (!(et instanceof WeaponType weapon)) { + return null; + } + try { + EquipmentType bay = weapon.getBayType(); + return bay == null || bay == et ? null : bay.getInternalName(); + } catch (Throwable t) { + return null; + } + } + private static Object callName(EquipmentType et, boolean display) { try { return display ? et.getName() : et.getInternalName(); diff --git a/crates/helm-bridge/src/lib.rs b/crates/helm-bridge/src/lib.rs index e8b844b..6582374 100644 --- a/crates/helm-bridge/src/lib.rs +++ b/crates/helm-bridge/src/lib.rs @@ -181,6 +181,8 @@ pub const CATALOGUE_FIELDS: &[&str] = &[ "_name", "ammoType", "baseDamageCapacity", + // Which bay a weapon is grouped into aboard a large aerospace unit. + "bayType", "bv", "cost", "criticalSlots", @@ -449,6 +451,7 @@ fn equipment_record(v: &Value) -> Option { battle_value: f(v, "bv").filter(|b| *b > i32::MIN as f64), variable_battle_value: f(v, "bv").is_some_and(|b| b <= i32::MIN as f64), cost: f(v, "cost"), + bay_type: s(v, "bayType"), spreadable: v .get("spreadable") .and_then(Value::as_bool) diff --git a/crates/helm-core/src/catalogue.rs b/crates/helm-core/src/catalogue.rs index 3bf9881..4eabb27 100644 --- a/crates/helm-core/src/catalogue.rs +++ b/crates/helm-core/src/catalogue.rs @@ -86,6 +86,14 @@ pub struct EquipmentEntry { pub flags: Vec, /// Whether it goes off when hit. pub explosive: bool, + /// The weapon bay this weapon is grouped into aboard a large aerospace + /// unit, by the bay's own lookup name - `Laser Bay`. + /// + /// A DropShip fires its weapons in bays rather than one at a time, and a + /// `.blk` marks where each bay starts with `(B)` and says nothing about + /// what sort of bay it is. That is a property of the weapon, and MegaMek + /// keeps it in code. + pub bay_type: Option, /// Whether one of it is spread over several critical slots and locations. /// /// A Mek's frame, its plate, its tracks and its talons are each one item diff --git a/crates/helm-core/src/lib.rs b/crates/helm-core/src/lib.rs index ebc2857..516962b 100644 --- a/crates/helm-core/src/lib.rs +++ b/crates/helm-core/src/lib.rs @@ -44,7 +44,7 @@ pub use metrics::{ can_ride_a_mek, damage_at_range, equipment_list, infantry_armor_is_encumbering, infantry_can_make_anti_mek_attacks, infantry_can_swarm, infantry_is_mechanized, is_conventional_infantry, jumping_mp, max_single_damage_at_range, mounted_items, one_shot_ammo, - platoon_weapons, walking_mp, + platoon_weapons, walking_mp, weapon_bays, }; pub use rng::Rng; pub use structure::{Shape, max_armor, structure_per_location, structure_total_in}; diff --git a/crates/helm-core/src/metrics.rs b/crates/helm-core/src/metrics.rs index 9b9aa5c..5b4e5c7 100644 --- a/crates/helm-core/src/metrics.rs +++ b/crates/helm-core/src/metrics.rs @@ -394,6 +394,12 @@ pub fn equipment_list<'a>(unit: &Unit, catalogue: &'a Catalogue) -> Vec<(&'a Equ } return items; } + // A large aerospace unit fires in bays, and MegaMek mounts one bay + // for each `(B)` the file marks. Which sort of bay is a property of + // the weapon rather than of the file. + for (entry, count) in weapon_bays(unit, catalogue) { + set(Some(entry), count); + } // A suit that can climb a Mek and pull it apart is given the attacks // for doing so, none of which is written in any file. Four of them, // and they come as a set. @@ -527,6 +533,38 @@ pub fn one_shot_ammo<'a>( .find(|e| e.internal_name.to_ascii_uppercase().starts_with("CL") == clan) } +/// The weapon bays a large aerospace unit's design implies. +/// +/// A DropShip does not fire its lasers one at a time: they are grouped into +/// bays and the bay is what shoots. The `.blk` marks the first weapon of each +/// group with `(B)` and says nothing about what sort of bay it is, so the +/// grouping is read off the weapon - `Medium Laser` belongs to a `Laser Bay` +/// - and one bay is counted per mark. +/// +/// Empty for anything whose file marks no bays, which is every design that is +/// not a large aerospace unit. +pub fn weapon_bays<'a>(unit: &Unit, catalogue: &'a Catalogue) -> Vec<(&'a EquipmentEntry, i64)> { + let clan = unit.is_clan(); + let mut out: Vec<(&EquipmentEntry, i64)> = Vec::new(); + for mount in unit.equipment.iter().filter(|m| m.bay) { + let Some(bay) = catalogue + .resolve_for(&mount.name, clan) + .and_then(|w| w.bay_type.as_deref()) + .and_then(|name| catalogue.resolve(name)) + else { + continue; + }; + match out + .iter_mut() + .find(|(e, _)| e.internal_name == bay.internal_name) + { + Some((_, n)) => *n += 1, + None => out.push((bay, 1)), + } + } + out +} + /// Whether MegaMek loads this design as a conventional infantry platoon. pub fn is_conventional_infantry(unit: &Unit) -> bool { unit.unit_type @@ -961,6 +999,7 @@ mod tests { equipment: mounts .iter() .map(|n| Mount { + bay: false, name: (*n).to_string(), location: None, rear: false, @@ -1196,6 +1235,7 @@ mod reading_the_catalogue { equipment: weapons .iter() .map(|name| Mount { + bay: false, name: (*name).into(), location: Some("Right Arm".into()), rear: false, @@ -1474,6 +1514,7 @@ mod anti_mek { equipment: hands .iter() .map(|name| Mount { + bay: false, name: (*name).into(), location: Some("LA".into()), rear: false, diff --git a/crates/helm-core/src/unit.rs b/crates/helm-core/src/unit.rs index aa04b3e..c876f35 100644 --- a/crates/helm-core/src/unit.rs +++ b/crates/helm-core/src/unit.rs @@ -25,13 +25,19 @@ impl Format { /// `.mtf` names a location per mount and repeats the line for each one; `.blk` /// groups mounts under a location block. Both are normalised to one row per /// mount so counting is a `GROUP BY`. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Mount { pub name: String, pub location: Option, /// True when the entry is rear-facing — MegaMek marks it with an `(R)` /// suffix, which is not part of the equipment's name. pub rear: bool, + /// Whether this line opens a weapon bay. + /// + /// A large aerospace unit's `.blk` marks the first weapon of each bay + /// with `(B)`, and the lines after it belong to the same bay. MegaMek + /// mounts a bay of its own for each mark. + pub bay: bool, /// What a battle armour suit is carrying this *on*, where that is not the /// suit itself: an anti-personnel mount, a squad support mount or a /// detachable weapon pack. A `.blk` writes it as a tag after the name - diff --git a/crates/helm-facet/src/query.rs b/crates/helm-facet/src/query.rs index aab44a6..de262c5 100644 --- a/crates/helm-facet/src/query.rs +++ b/crates/helm-facet/src/query.rs @@ -475,6 +475,7 @@ mod tests { name: "Atlas AS7-D".into(), equipment: vec![ Mount { + bay: false, name: "Medium Laser".into(), location: Some("Left Arm".into()), rear: false, @@ -483,6 +484,7 @@ mod tests { trooper: None, }, Mount { + bay: false, name: "Medium Laser".into(), location: Some("Right Arm".into()), rear: false, @@ -491,6 +493,7 @@ mod tests { trooper: None, }, Mount { + bay: false, name: "AC/20".into(), location: Some("Right Torso".into()), rear: false, @@ -499,6 +502,7 @@ mod tests { trooper: None, }, Mount { + bay: false, name: "Medium Laser".into(), location: Some("Center Torso".into()), rear: true, diff --git a/crates/helm-facet/tests/loadout.rs b/crates/helm-facet/tests/loadout.rs index b2af1e6..a6a8372 100644 --- a/crates/helm-facet/tests/loadout.rs +++ b/crates/helm-facet/tests/loadout.rs @@ -23,7 +23,7 @@ use std::path::PathBuf; /// Out of a smaller population than the library: 479 designs share a display /// name with another and are skipped, because nothing in the dump says which /// oracle row is which design. -const DIFFERING: usize = 512; +const DIFFERING: usize = 83; #[test] #[ignore = "needs a MegaMek install and a bridge dump; set HELM_MEGAMEK and HELM_BRIDGE"] diff --git a/crates/helm-unitfile/src/blk.rs b/crates/helm-unitfile/src/blk.rs index c5f7988..bec09eb 100644 --- a/crates/helm-unitfile/src/blk.rs +++ b/crates/helm-unitfile/src/blk.rs @@ -80,6 +80,7 @@ pub fn parse_blk(path: &str, text: &str) -> Result { name, location: None, rear, + bay: false, carried_on: None, shots: None, trooper: None, @@ -105,6 +106,7 @@ pub fn parse_blk(path: &str, text: &str) -> Result { let (name, rear, suit_mount) = strip_mount_suffixes_at(entry); unit.equipment.push(Mount { name, + bay: opens_a_bay(entry), // Where on a battle armour suit it sits, where the file // says - `Body`, `LA` - and otherwise the block's own // location, which is what a vehicle has. @@ -131,6 +133,27 @@ pub fn parse_blk(path: &str, text: &str) -> Result { assign(&mut unit, &lower, tag, &value); } + // A ProtoMek writes an ammunition bin's load in brackets after the name - + // `Clan Ammo SRM-1 (20)` - and its loader is the only one that reads it + // that way. Doing it here rather than in the shared stripper is what + // keeps `Capellan Confederation Armor Kit (3050)` a kit and not a bin of + // 3,050 rounds. + if unit + .unit_type + .as_deref() + .is_some_and(|t| t.eq_ignore_ascii_case("protomek")) + { + for mount in &mut unit.equipment { + let Some(count) = trailing_count(&mount.name) else { + continue; + }; + mount.shots = count.parse().ok(); + let keep = mount.name.len() - count.len() - 2; + mount.name.truncate(keep); + mount.name = mount.name.trim().to_string(); + } + } + // Armour arrives positionally with no names attached; the count implies // the layout. Anything unrecognised is still recorded, by index, rather // than dropped. @@ -186,9 +209,23 @@ pub(crate) fn strip_mount_suffixes(entry: &str) -> (String, bool) { /// `BA-SRM1 Ammo:SHOTS4#` is four rounds rather than the standard load, and /// for battle armour that is the value: a suit's ammunition is worth its /// weight, so twice the rounds is twice the figure. +/// The digits inside a trailing `(...)`, where that is all it holds. +fn trailing_count(name: &str) -> Option<&str> { + let rest = name.trim_end().strip_suffix(')')?; + let (_, digits) = rest.rsplit_once('(')?; + (!digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit())).then_some(digits) +} + pub(crate) fn declared_shots(entry: &str) -> Option { let upper = entry.to_ascii_uppercase(); - let at = upper.find(":SHOTS")?; + let Some(at) = upper.find(":SHOTS") else { + // A large aerospace unit writes the load as a bare number after a + // colon - `IS Ammo AC/5:20` - rather than with the `:SHOTS` word. + let (name, count) = entry.trim().rsplit_once(':')?; + return (!name.is_empty()) + .then(|| count.trim().parse().ok()) + .flatten(); + }; entry[at + ":SHOTS".len()..] .trim_end_matches('#') .split(|c: char| !c.is_ascii_digit()) @@ -231,9 +268,41 @@ pub(crate) fn strip_mount_suffixes_at(entry: &str) -> (String, bool, Option bool { + entry + .split_whitespace() + .take_while(|word| word.starts_with('(')) + .any(|word| word.eq_ignore_ascii_case("(B)")) +} + fn strip_mount_suffixes_inner(entry: &str) -> (String, bool) { let mut name = entry.trim().to_string(); + // Markers a large aerospace unit writes in front of the name rather than + // after it: the facing, and `(B)` for the first weapon of a bay. Taken + // off here so the name is the equipment's; the bay mark is read + // separately because it is the only one that adds anything. + let mut leading_rear = false; + while let Some((marker, after)) = name + .strip_prefix('(') + .and_then(|rest| rest.split_once(')')) + .filter(|(marker, _)| { + matches!( + marker.to_ascii_uppercase().as_str(), + "B" | "R" | "F" | "FL" | "FR" | "RL" | "RR" + ) + }) + { + leading_rear |= marker.eq_ignore_ascii_case("R"); + name = after.trim_start().to_string(); + } + // Battle armour tags, from BLKBattleArmorFile rather than BLKFile: where // on the suit a mount sits, and how it is carried. Removed wherever they // appear rather than only at the end, because upstream uses replace() and @@ -263,6 +332,16 @@ fn strip_mount_suffixes_inner(entry: &str) -> (String, bool) { { name.truncate(idx); } + // The same thing written as a bare number, which is what a large + // aerospace unit writes: `IS Ammo AC/5:20`. Only a run of digits, so a + // name that legitimately contains a colon is left alone. + if let Some((before, after)) = name.rsplit_once(':') + && !before.is_empty() + && !after.is_empty() + && after.chars().all(|c| c.is_ascii_digit()) + { + name = before.to_string(); + } for suffix in [":OMNI", "(PT)", "(ST)"] { if name.to_ascii_uppercase().ends_with(suffix) { @@ -287,7 +366,7 @@ fn strip_mount_suffixes_inner(entry: &str) -> (String, bool) { } } - (name, rear) + (name, rear || leading_rear) } /// Armour location names by array length, for the layouts that are @@ -428,4 +507,36 @@ mod tests { assert_eq!(name("Cargo:SIZE:3.0"), "Cargo"); assert_eq!(name("ISArrowIV(ST)"), "ISArrowIV"); } + + #[test] + fn a_large_aerospace_line_names_its_bay_its_facing_and_its_load() { + // `(R) (B) Autocannon/5` is the first weapon of a rear-facing bay. + assert!(super::opens_a_bay("(R) (B) Autocannon/5")); + assert!(super::opens_a_bay("(B) Medium Laser")); + assert!(!super::opens_a_bay("Medium Laser")); + assert!(!super::opens_a_bay("(R) Medium Laser")); + + let (name, rear) = strip_mount_suffixes("(R) (B) Autocannon/5"); + assert_eq!(name, "Autocannon/5"); + assert!(rear); + + // The load is a bare number after the name. + assert_eq!(strip_mount_suffixes("IS Ammo AC/5:20").0, "IS Ammo AC/5"); + assert_eq!(super::declared_shots("IS Ammo AC/5:20"), Some(20)); + } + + #[test] + fn a_name_that_ends_in_a_year_is_not_a_load() { + // Only a ProtoMek's loader reads a trailing bracket as a round + // count, so the shared stripper leaves every bracket alone. + assert_eq!( + strip_mount_suffixes("Capellan Confederation Armor Kit (3050)").0, + "Capellan Confederation Armor Kit (3050)" + ); + assert_eq!(super::trailing_count("Clan Ammo SRM-1 (20)"), Some("20")); + assert_eq!( + super::trailing_count("Pulse Laser Rifle (Inner Sphere)"), + None + ); + } } diff --git a/crates/helm-unitfile/src/mtf.rs b/crates/helm-unitfile/src/mtf.rs index 57e1274..d62a37e 100644 --- a/crates/helm-unitfile/src/mtf.rs +++ b/crates/helm-unitfile/src/mtf.rs @@ -155,6 +155,8 @@ fn parse_weapon_entry(entry: &str) -> Mount { }; Mount { + // Only a large aerospace `.blk` groups weapons into bays. + bay: false, name, location, rear, -- 2.51.2