diff --git a/crates/helm-bridge/src/lib.rs b/crates/helm-bridge/src/lib.rs index 12c486e..e8b844b 100644 --- a/crates/helm-bridge/src/lib.rs +++ b/crates/helm-bridge/src/lib.rs @@ -211,6 +211,8 @@ pub const CATALOGUE_FIELDS: &[&str] = &[ "rackSize", "shortAV", "shortRange", + // One item over many slots, which is how a set of talons stops being two. + "spreadable", "tonnage", ]; @@ -447,6 +449,10 @@ 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"), + spreadable: v + .get("spreadable") + .and_then(Value::as_bool) + .unwrap_or(false), damage_capacity: i(v, "baseDamageCapacity").filter(|c| *c > 0), // Only an infantry armour kit has one, and MegaMek writes 1.0 on the // rest of MiscType rather than leaving it out. diff --git a/crates/helm-core/src/catalogue.rs b/crates/helm-core/src/catalogue.rs index e0b964c..3bf9881 100644 --- a/crates/helm-core/src/catalogue.rs +++ b/crates/helm-core/src/catalogue.rs @@ -86,6 +86,12 @@ pub struct EquipmentEntry { pub flags: Vec, /// Whether it goes off when hit. pub explosive: bool, + /// 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 + /// filling many slots, so counting the slots counts one set of talons as + /// two. A hatchet is not spreadable and a Mek can carry two. + pub spreadable: bool, /// The implementing class and every class above it. /// /// The rules ask `instanceof GaussWeapon` and `instanceof PPCWeapon`, and diff --git a/crates/helm-core/src/design.rs b/crates/helm-core/src/design.rs index c2b32d5..3218995 100644 --- a/crates/helm-core/src/design.rs +++ b/crates/helm-core/src/design.rs @@ -47,6 +47,10 @@ pub enum EngineKind { pub struct HeatSinks { pub count: i64, pub kind: HeatSinkKind, + /// Whether the line names Clan sinks. A mixed-tech design says so on the + /// line itself - `heat sinks:12 Clan Double` on a Wolfhound whose chassis + /// is Inner Sphere - and the chassis is the wrong thing to ask. + pub clan: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -225,7 +229,7 @@ impl Unit { // no space in it is a count rather than nothing. let (count, rest) = raw.split_once(char::is_whitespace).unwrap_or((raw, "")); let count = count.parse::().ok()?; - let (name, _) = strip_tech_base(rest); + let (name, clan) = strip_tech_base(rest); let kind = if same(&name, "Double") { HeatSinkKind::Double } else if same(&name, "Laser") { @@ -239,7 +243,7 @@ impl Unit { // far likelier to be a single than to be nothing. HeatSinkKind::Single }; - Some(HeatSinks { count, kind }) + Some(HeatSinks { count, kind, clan }) } /// The armour type, read from `armor:Ferro-Fibrous(Clan)`. diff --git a/crates/helm-core/src/lib.rs b/crates/helm-core/src/lib.rs index 76d4ff9..ebc2857 100644 --- a/crates/helm-core/src/lib.rs +++ b/crates/helm-core/src/lib.rs @@ -43,7 +43,7 @@ pub use metrics::{ CombatMetrics, FORMATION_RANGES, FORMATION_SINGLE_RANGES, can_make_anti_mek_attacks, 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, + is_conventional_infantry, jumping_mp, max_single_damage_at_range, mounted_items, one_shot_ammo, platoon_weapons, walking_mp, }; pub use rng::Rng; @@ -78,7 +78,13 @@ pub fn round_half_up(value: f64) -> i64 { /// Rifleman RFL-3B carries a `Binary Laser (Blazer) Cannon`, and cutting at /// the bracket leaves `Binary Laser`, which matches nothing and disarms it. pub fn strip_slot_markers(slot: &str) -> String { - let mut name = slot.to_string(); + // A variable-size item states its size on the slot line rather than in + // the catalogue: `Cargo:SIZE:0.5`. The size is not part of the name, and + // leaving it on is why 74 IndustrialMeks carried no cargo bay at all. + let mut name = match slot.split_once(":SIZE:") { + Some((before, _)) => before.to_string(), + None => slot.to_string(), + }; for marker in SLOT_MARKERS { while let Some(at) = name.find(marker) { name.replace_range(at..at + marker.len(), " "); diff --git a/crates/helm-core/src/metrics.rs b/crates/helm-core/src/metrics.rs index 7e5874a..9b9aa5c 100644 --- a/crates/helm-core/src/metrics.rs +++ b/crates/helm-core/src/metrics.rs @@ -198,17 +198,8 @@ fn per_weapon_damage_at_range(unit: &Unit, catalogue: &Catalogue, range: i64) -> // file, finds nothing and scores a Firestarter FS9-C's six // rocket launchers at zero. let aboard = ammo_aboard.iter().copied().find(|e| matches(e)); - let loaded = weapon - .has_flag("F_ONE_SHOT") - .then(|| { - catalogue - .entries() - .iter() - .find(|e| e.is_ammo() && matches(e)) - }) - .flatten(); aboard - .or(loaded) + .or_else(|| one_shot_ammo(weapon, catalogue)) .map(|a| a.damage_per_shot.unwrap_or(0) * a.rack_size.unwrap_or(1).max(1)) .unwrap_or(0) } @@ -321,6 +312,12 @@ pub fn mounted_items<'a>(unit: &Unit, catalogue: &'a Catalogue) -> Vec<(&'a Equi .into_iter() .filter_map(|(name, (filled, locations))| { let entry = catalogue.get(name)?; + // One item spread over many slots and often over two locations: + // a frame, a set of tracks, a pair of talons. MegaMek mounts it + // once however far it reaches. + if entry.spreadable { + return Some((entry, 1)); + } let count = match entry.criticals.filter(|c| *c > 0) { // Rounded up, the way `helm_bv::loadout` rounds it: a Gauss // rifle is seven slots and takes four on a superheavy. @@ -358,6 +355,12 @@ pub fn mounted_items<'a>(unit: &Unit, catalogue: &'a Catalogue) -> Vec<(&'a Equi /// question, and only filtering asks it. pub fn equipment_list<'a>(unit: &Unit, catalogue: &'a Catalogue) -> Vec<(&'a EquipmentEntry, i64)> { let mut items: Vec<(&EquipmentEntry, i64)> = mounted_items(unit, catalogue); + // Worked out before anything is added, so a launcher's own round cannot + // be mistaken for another launcher. + let loaded: Vec<(&EquipmentEntry, i64)> = items + .iter() + .filter_map(|(e, n)| one_shot_ammo(e, catalogue).map(|a| (a, *n))) + .collect(); let mut set = |entry: Option<&'a EquipmentEntry>, count: i64| { let Some(entry) = entry.filter(|_| count > 0) else { return; @@ -436,19 +439,42 @@ pub fn equipment_list<'a>(unit: &Unit, catalogue: &'a Catalogue) -> Vec<(&'a Equ ); } + // Every one-shot launcher arrives loaded, and the round it arrives with + // is in no file. One bin per launcher, not one per kind of launcher. + for (entry, count) in loaded { + set(Some(entry), count); + } + // A Clan Mek's CASE is part of how it is built rather than something // bolted in, so it fills no slot and appears in no file - one for each // location that holds something explosive. A Timber Wolf Prime has two. - if unit.is_clan() { + // A design that mounts its own CASE anywhere is given none: MegaMek + // checks that first and leaves the design alone. + let cases_itself = |flag: &str| { + unit.criticals.values().flatten().any(|line| { + line.split('|') + .filter_map(|slot| catalogue.resolve(&crate::strip_slot_markers(slot))) + .any(|e| e.has_flag(flag)) + }) + }; + if unit.is_clan() && !cases_itself("F_CASE") { let cased = unit .criticals - .values() - .filter(|slots| { - slots - .iter() - .flat_map(|line| line.split('|')) - .filter_map(|slot| catalogue.resolve(&crate::strip_slot_markers(slot))) - .any(|e| e.explosive || e.is_ammo()) + .iter() + .filter(|(_, slots)| { + let resolved = || { + slots + .iter() + .flat_map(|line| line.split('|')) + .filter_map(|slot| catalogue.resolve(&crate::strip_slot_markers(slot))) + }; + // Explosive by MegaMek's own answer, which is not "is it + // ammunition": Gauss rounds are inert and the rifle firing + // them is not, so a torso holding nothing but Gauss ammo gets + // no CASE and an arm holding the rifle does. + resolved().any(|e| e.explosive) + // A location already protected is left alone. + && !resolved().any(|e| e.has_flag("F_CASEII")) }) .count() as i64; set(catalogue.resolve("CLCASE"), cased); @@ -460,7 +486,7 @@ pub fn equipment_list<'a>(unit: &Unit, catalogue: &'a Catalogue) -> Vec<(&'a Equ && sinks.count > 0 { set( - catalogue.resolve(sinks.kind.equipment_name(unit.is_clan())), + catalogue.resolve(sinks.kind.equipment_name(sinks.clan || unit.is_clan())), sinks.count, ); } @@ -468,6 +494,39 @@ pub fn equipment_list<'a>(unit: &Unit, catalogue: &'a Catalogue) -> Vec<(&'a Equ items } +/// The round a one-shot launcher arrives loaded with. +/// +/// A one-shot launcher's ammunition is part of the weapon rather than a bin in +/// a critical slot, so it is written in no file. MegaMek makes it while it +/// reads the design and finds it in the equipment list a moment later, which +/// is why a Catapult CPLT-H2 carrying eight rocket launchers also carries +/// eight bins of RL-20. +/// +/// `None` for anything that is not one-shot, and for a launcher whose +/// ammunition the catalogue does not know. +pub fn one_shot_ammo<'a>( + weapon: &EquipmentEntry, + catalogue: &'a Catalogue, +) -> Option<&'a EquipmentEntry> { + if !weapon.has_flag("F_ONE_SHOT") { + return None; + } + let ammo_type = weapon.ammo_type.as_deref()?; + // A Clan launcher is loaded with Clan rounds. Both exist under the same + // ammunition type and rack size, and taking the first gives an + // `CLStreakSRM4 (OS)` an Inner Sphere bin. + let clan = weapon.internal_name.to_ascii_uppercase().starts_with("CL"); + catalogue + .entries() + .iter() + .filter(|e| { + e.is_ammo() + && e.ammo_type.as_deref() == Some(ammo_type) + && e.rack_size == weapon.rack_size + }) + .find(|e| e.internal_name.to_ascii_uppercase().starts_with("CL") == clan) +} + /// Whether MegaMek loads this design as a conventional infantry platoon. pub fn is_conventional_infantry(unit: &Unit) -> bool { unit.unit_type @@ -1496,4 +1555,86 @@ mod anti_mek { assert!(can_ride_a_mek(&heavy, &c)); assert!(!can_make_anti_mek_attacks(&heavy, &c)); } + + #[test] + fn a_slot_states_a_variable_items_size_and_the_size_is_not_its_name() { + assert_eq!(crate::strip_slot_markers("Cargo:SIZE:0.5"), "Cargo"); + assert_eq!( + crate::strip_slot_markers("Liquid Storage:SIZE:2.0 (OMNIPOD)"), + "Liquid Storage" + ); + // Nothing else is touched. + assert_eq!( + crate::strip_slot_markers("ISGaussRifle (R)"), + "ISGaussRifle" + ); + } + + #[test] + fn a_one_shot_launcher_is_found_the_round_it_arrives_with() { + let cat = Catalogue::new(vec![ + EquipmentEntry { + internal_name: "RL20".into(), + name: "Rocket Launcher 20".into(), + ammo_type: Some("ROCKET_LAUNCHER".into()), + rack_size: Some(20), + classes: vec!["WeaponType".into()], + flags: vec!["WeaponType.F_ONE_SHOT".into()], + ..EquipmentEntry::default() + }, + EquipmentEntry { + internal_name: "IS Ammo RL-20".into(), + name: "Rocket Launcher 20 Ammo".into(), + ammo_type: Some("ROCKET_LAUNCHER".into()), + rack_size: Some(20), + classes: vec!["AmmoType".into()], + ..EquipmentEntry::default() + }, + ]); + let launcher = cat.get("RL20").unwrap(); + assert_eq!( + one_shot_ammo(launcher, &cat).map(|e| e.internal_name.as_str()), + Some("IS Ammo RL-20") + ); + // And nothing for a weapon that is fed from a bin like any other. + let bin = cat.get("IS Ammo RL-20").unwrap(); + assert!(one_shot_ammo(bin, &cat).is_none()); + } + + #[test] + fn a_platoon_is_loaded_with_one_of_the_two_weapons_it_declares() { + let cat = Catalogue::new(vec![ + EquipmentEntry { + internal_name: "Rifle".into(), + name: "Rifle".into(), + classes: vec!["WeaponType".into()], + ..EquipmentEntry::default() + }, + EquipmentEntry { + internal_name: "Support Laser".into(), + name: "Support Laser".into(), + classes: vec!["WeaponType".into()], + ..EquipmentEntry::default() + }, + ]); + let mut unit = Unit { + unit_type: Some("Infantry".into()), + ..Default::default() + }; + unit.push_field("Primary", "Rifle"); + unit.push_field("Secondary", "Support Laser"); + // One trooper a squad carries the support weapon, so the platoon is + // loaded with the rifle it mostly carries. + unit.push_field("secondn", "1"); + let names = |u: &Unit| -> Vec { + platoon_weapons(u, &cat) + .iter() + .map(|(e, _)| e.internal_name.clone()) + .collect() + }; + assert_eq!(names(&unit), vec!["Rifle".to_string()]); + // More than one, and it is the support weapon that is mounted. + unit.fields.insert("secondn".into(), "2".into()); + assert_eq!(names(&unit), vec!["Support Laser".to_string()]); + } } diff --git a/crates/helm-facet/tests/loadout.rs b/crates/helm-facet/tests/loadout.rs index d6ca0e8..b2f348f 100644 --- a/crates/helm-facet/tests/loadout.rs +++ b/crates/helm-facet/tests/loadout.rs @@ -19,7 +19,7 @@ use std::path::PathBuf; /// /// A ratchet rather than a target: it may fall and may not rise without /// somebody saying why. It was 9,105 before the loader was modelled at all. -const DIFFERING: usize = 1_166; +const DIFFERING: usize = 722; #[test] #[ignore = "needs a MegaMek install and a bridge dump; set HELM_MEGAMEK and HELM_BRIDGE"]