diff --git a/TODO.md b/TODO.md index de46806..d10fd08 100644 --- a/TODO.md +++ b/TODO.md @@ -403,11 +403,12 @@ The long pole, and the reason `helm-core` does no I/O. Tracked as gear and aerospace bays rather than anything Mek-shaped. Battle armour too: the jets are declared in ``, - which nothing read, and the four anti-Mek attacks are invented for any - biped suit no heavier than medium. Suits carrying MegaMek's list went - from 276 of 1,188 to 1,130. The 58 left are suits MegaMek says cannot - climb a Mek where this says they can, and nobody has found the rule that - says so - mostly exoskeletons. + which nothing read, and the four anti-Mek attacks are invented for a suit + that can climb a Mek. Which can is `BattleArmor.canMakeAntiMekAttacks`: + lighter than a heavy suit, not a quad, and with two hands free - two + armoured gloves or two basic manipulators, or one battle claw, or a + magnetic clamp. Suits carrying MegaMek's list went from 276 of 1,188 to + 1,184. Infantry is untouched: a platoon's weapons are declared in a way of their own, and all 1,792 differ. diff --git a/crates/helm-core/src/lib.rs b/crates/helm-core/src/lib.rs index f609f03..df1811f 100644 --- a/crates/helm-core/src/lib.rs +++ b/crates/helm-core/src/lib.rs @@ -40,8 +40,9 @@ pub use design::{ StructureKind, }; pub use metrics::{ - CombatMetrics, FORMATION_RANGES, FORMATION_SINGLE_RANGES, damage_at_range, equipment_list, - jumping_mp, max_single_damage_at_range, mounted_items, walking_mp, + CombatMetrics, FORMATION_RANGES, FORMATION_SINGLE_RANGES, can_make_anti_mek_attacks, + can_ride_a_mek, damage_at_range, equipment_list, jumping_mp, max_single_damage_at_range, + mounted_items, walking_mp, }; 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 13b5d7a..c495a69 100644 --- a/crates/helm-core/src/metrics.rs +++ b/crates/helm-core/src/metrics.rs @@ -369,19 +369,7 @@ pub fn equipment_list<'a>(unit: &Unit, catalogue: &'a Catalogue) -> Vec<(&'a Equ // 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. - // - // Who can: a biped no heavier than medium. A quad has no free hands, - // and a heavy or assault suit is too big to get a purchase. That rule - // holds for 720 of the library's suits and leaves 57 that MegaMek - // says cannot and this says can - mostly exoskeletons - which is a - // residue nobody has explained yet rather than a rule anybody has. - if unit - .unit_type - .as_deref() - .is_some_and(|t| t.eq_ignore_ascii_case("battlearmor")) - && !unit.is_quad() - && unit.suit_weight_class().is_some_and(|wc| wc <= 2) - { + if can_make_anti_mek_attacks(unit, catalogue) { for name in ["LegAttack", "SwarmMek", "SwarmWeaponMek", "StopSwarm"] { set(catalogue.resolve(name), 1); } @@ -501,6 +489,80 @@ pub fn walking_mp(unit: &Unit, catalogue: &Catalogue) -> Option { (cost > 0).then(|| (declared - cost).max(0)) } +/// Whether a battle armour suit can climb a Mek and pull it apart. +/// +/// Four conditions, and none of them is written in any file - MegaMek works +/// them out from the suit and then gives it `LegAttack`, `SwarmMek`, +/// `SwarmWeaponMek` and `StopSwarm`, which is a quarter of what such a suit is +/// worth. `BattleArmor.canMakeAntiMekAttacks`: +/// +/// * it must have a hand free, which is [`can_ride_a_mek`]; +/// * and it must be lighter than a heavy suit, since a big one cannot get a +/// purchase. +/// +/// Upstream has two further clauses that are not reproduced, because nothing +/// in the library needs them and modelling them from a `.blk` costs answers. +/// One excludes a suit whose movement mode is `INF_UMU`: a `.blk` says +/// `UMU`, which is not the same thing - a Sea Fox and an Undine +/// both say it and both can climb, while a Water Elemental says it and cannot, +/// and what separates them is their hands rather than their swimming. The +/// other excludes a *burdened* suit, an Inner Sphere one holding a reload for +/// a body-mounted launcher, which needs the link between a bin and the weapon +/// it feeds and comes out wrong without it. +pub fn can_make_anti_mek_attacks(unit: &Unit, catalogue: &Catalogue) -> bool { + unit.unit_type + .as_deref() + .is_some_and(|t| t.eq_ignore_ascii_case("battlearmor")) + && unit.suit_weight_class().is_some_and(|wc| wc < 3) + && can_ride_a_mek(unit, catalogue) +} + +/// Whether the suit has enough of a hand to hold on with. +/// +/// `BattleArmor.canDoMechanizedBA`, and the thresholds are not the ones a +/// reading of the rules would suggest. A magnetic clamp is always enough. Past +/// that it is the manipulators, counted, against the weight class: a PA(L) or +/// light suit needs **two** armoured gloves, or one basic manipulator, or one +/// battle claw; a medium or heavy suit needs a manipulator or a claw and a +/// glove will not do; an assault suit cannot however it is fitted out. +/// +/// A quad has no hands at all. +pub fn can_ride_a_mek(unit: &Unit, catalogue: &Catalogue) -> bool { + if unit.is_quad() { + return false; + } + let items = mounted_items(unit, catalogue); + let count = |flag: &str| -> i64 { + items + .iter() + .filter(|(e, _)| e.has_flag(flag)) + .map(|(_, n)| n) + .sum() + }; + if count("F_MAGNETIC_CLAMP") > 0 { + return true; + } + let (basic, gloves, claws) = ( + count("F_BASIC_MANIPULATOR"), + count("F_ARMORED_GLOVE"), + count("F_BATTLE_CLAW"), + ); + match unit.suit_weight_class() { + // Two hands, or one good one. A claw is enough on its own; a glove or + // a plain manipulator is only half a grip and wants a pair. + // + // MegaMek's own `canDoMechanizedBA` reads `basic > 0` here rather than + // `basic > 1`, and every design in the library behaves as though it + // read the second: 90 suits carry exactly one basic manipulator and + // not one of them is given the attacks, while 124 of the 175 carrying + // two are. Where the bytecode and the answers disagree, helm follows + // the answers - they are what it is measured against. + Some(0 | 1) => gloves > 1 || basic > 1 || claws > 0, + Some(2 | 3) => basic > 1 || claws > 0, + _ => false, + } +} + /// How far the design jumps, which is its jets and not its `jump mp:` line. /// /// The line is unreliable in four separate ways, all of them recorded in @@ -1140,3 +1202,109 @@ mod reading_the_slots { assert_eq!(jumping_mp(&tank, &catalogue), None); } } + +/// What a battle armour suit can do with its hands, which decides whether it +/// is given the attacks for climbing a Mek. +#[cfg(test)] +mod anti_mek { + use super::*; + use crate::{EquipmentEntry, Mount}; + + fn hand(internal: &str, flag: &str) -> EquipmentEntry { + EquipmentEntry { + internal_name: internal.into(), + name: internal.into(), + flags: vec![format!("MiscType.{flag}")], + ..Default::default() + } + } + + fn catalogue() -> Catalogue { + Catalogue::new(vec![ + hand("BABasicManipulator", "F_BASIC_MANIPULATOR"), + hand("BAArmoredGlove", "F_ARMORED_GLOVE"), + hand("BABattleClaw", "F_BATTLE_CLAW"), + hand("BAMagneticClamp", "F_MAGNETIC_CLAMP"), + ]) + } + + fn suit(weight_class: i64, quad: bool, hands: &[&str]) -> Unit { + let mut unit = Unit { + chassis: "Test".into(), + model: "1".into(), + format_str: "blk".into(), + unit_type: Some("BattleArmor".into()), + equipment: hands + .iter() + .map(|name| Mount { + name: (*name).into(), + location: Some("LA".into()), + rear: false, + }) + .collect(), + ..Default::default() + }; + unit.fields + .insert("weightclass".into(), weight_class.to_string()); + unit.fields.insert( + "chassis".into(), + if quad { "quad".into() } else { "biped".into() }, + ); + unit + } + + #[test] + fn one_hand_is_not_enough_and_two_are() { + let c = catalogue(); + assert!(!can_ride_a_mek( + &suit(1, false, &["BABasicManipulator"]), + &c + )); + assert!(can_ride_a_mek( + &suit(1, false, &["BABasicManipulator", "BABasicManipulator"]), + &c + )); + assert!(!can_ride_a_mek(&suit(1, false, &["BAArmoredGlove"]), &c)); + assert!(can_ride_a_mek( + &suit(1, false, &["BAArmoredGlove", "BAArmoredGlove"]), + &c + )); + } + + #[test] + fn a_claw_is_enough_on_its_own() { + assert!(can_ride_a_mek( + &suit(2, false, &["BABattleClaw"]), + &catalogue() + )); + } + + #[test] + fn a_glove_is_no_use_to_a_suit_past_light() { + let c = catalogue(); + let gloves = ["BAArmoredGlove", "BAArmoredGlove"]; + assert!(can_ride_a_mek(&suit(1, false, &gloves), &c)); + assert!(!can_ride_a_mek(&suit(2, false, &gloves), &c)); + } + + #[test] + fn a_quad_has_no_hands_and_a_clamp_serves_for_any() { + let c = catalogue(); + assert!(!can_ride_a_mek(&suit(1, true, &["BABattleClaw"]), &c)); + // A clamp is enough whatever the weight class, though not to a quad. + assert!(can_ride_a_mek(&suit(3, false, &["BAMagneticClamp"]), &c)); + assert!(!can_ride_a_mek(&suit(3, true, &["BAMagneticClamp"]), &c)); + } + + #[test] + fn an_assault_suit_is_too_big_to_climb_however_it_is_fitted() { + let c = catalogue(); + let armed = suit(4, false, &["BABattleClaw", "BABattleClaw"]); + assert!(!can_ride_a_mek(&armed, &c)); + assert!(!can_make_anti_mek_attacks(&armed, &c)); + // And a heavy suit can ride one without being able to climb it. + let heavy = suit(3, false, &["BABattleClaw"]); + assert!(can_ride_a_mek(&heavy, &c)); + assert!(!can_make_anti_mek_attacks(&heavy, &c)); + } +} diff --git a/crates/helm-facet/tests/loadout.rs b/crates/helm-facet/tests/loadout.rs index d16bcf0..8b955dd 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 = 5_272; +const DIFFERING: usize = 5_218; #[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 e09c4ce..6ed706d 100644 --- a/crates/helm-unitfile/src/blk.rs +++ b/crates/helm-unitfile/src/blk.rs @@ -94,10 +94,13 @@ pub fn parse_blk(path: &str, text: &str) -> Result { continue; } slots.push(entry.clone()); - let (name, rear) = strip_mount_suffixes(entry); + let (name, rear, suit_mount) = strip_mount_suffixes_at(entry); unit.equipment.push(Mount { name, - location: Some(location.clone()), + // 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. + location: Some(suit_mount.unwrap_or_else(|| location.clone())), rear, }); } @@ -163,6 +166,26 @@ pub fn parse_blk(path: &str, text: &str) -> Result { /// the 3-character facings never appear in an equipment block, and all 128 /// `(ST)` occurrences are 4 characters written flush against the name. pub(crate) fn strip_mount_suffixes(entry: &str) -> (String, bool) { + let (name, rear, _) = strip_mount_suffixes_at(entry); + (name, rear) +} + +/// The same, and where on a battle armour suit the mount sits. +/// +/// `CLBAMG:RA` is a machine gun on the right arm and `ISBASRM4:Body` is a +/// launcher on the body. Which of the two something is decides whether a suit +/// has its hands full - an Inner Sphere suit with loaded missiles on its body +/// is burdened, and cannot climb a Mek. +pub(crate) fn strip_mount_suffixes_at(entry: &str) -> (String, bool, Option) { + let suit_mount = [":Body", ":LA", ":RA", ":TU"] + .into_iter() + .find(|tag| entry.contains(tag)) + .map(|tag| tag.trim_start_matches(':').to_string()); + let (name, rear) = strip_mount_suffixes_inner(entry); + (name, rear, suit_mount) +} + +fn strip_mount_suffixes_inner(entry: &str) -> (String, bool) { let mut name = entry.trim().to_string(); // Battle armour tags, from BLKBattleArmorFile rather than BLKFile: where