diff --git a/crates/helm-core/src/lib.rs b/crates/helm-core/src/lib.rs index 1b9d9c4..7982cb9 100644 --- a/crates/helm-core/src/lib.rs +++ b/crates/helm-core/src/lib.rs @@ -46,10 +46,11 @@ pub use expected::{ }; pub use metrics::{ CombatMetrics, FORMATION_RANGES, FORMATION_SINGLE_RANGES, REACH, can_make_anti_mek_attacks, - can_ride_a_mek, count_carrying, damage_at_range, damage_curve, equipment_list, + can_ride_a_mek, count_carrying, damage_at_range, damage_curve, equipment_list, fights_in_melee, 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, slot_size, walking_mp, weapon_bays, + infantry_is_mechanized, is_conventional_infantry, is_melee_kit, jumping_mp, + max_single_damage_at_range, mounted_items, one_shot_ammo, platoon_weapons, slot_size, + 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 a8d11f4..73b907f 100644 --- a/crates/helm-core/src/metrics.rs +++ b/crates/helm-core/src/metrics.rs @@ -297,6 +297,55 @@ pub const FORMATION_RANGES: &[i64] = &[6, 7, 9, 18]; /// The bands asked about for a single weapon rather than a total. pub const FORMATION_SINGLE_RANGES: &[i64] = &[15, 18]; +/// Whether a design is built to fight at arm's length. +/// +/// A filter rather than a figure: the question a person asks is "which of +/// these wants to be next to me", and that has a yes and a no rather than a +/// number. Every Mek can punch and kick, so what is being asked is whether +/// this one was *built* for it. +/// +/// What counts, and it is MegaMek's own flags rather than a list of names: +/// +/// * `F_CLUB` - the hatchets, swords, maces and vibroblades, and the +/// backhoes, dual saws, mining drills and chainsaws an IndustrialMek or a +/// support vehicle swings. A shield is `F_CLUB` too and counts: a design +/// carrying one is built for a physical exchange, whichever side of it. +/// * `F_HAND_WEAPON` - a Claw. +/// * `F_TALON`, and `F_SPIKES`, which is damage in a physical exchange even +/// though it is the other machine that starts it. +/// * `F_TSM` - triple strength myomer, which is what makes a Berserker's +/// punch worth throwing. +/// * `F_PROTOMEK_MELEE`. +/// +/// What does not, and why: +/// +/// * **Anything wearing a suit rather than a machine.** A battle claw is the +/// ordinary battle armour manipulator - 605 of 10,988 designs carry one - +/// and swarming a Mek is what infantry does rather than a way this design +/// was built. Answering `None` for infantry and battle armour keeps them +/// out of both sides of the filter rather than sorting them into "no". +/// * **Industrial TSM**, which is a lifting aid rather than a damage bonus. +pub fn fights_in_melee(unit: &Unit, catalogue: &Catalogue) -> Option { + if unit.is_battle_armor() || unit.is_conventional_infantry() { + return None; + } + Some( + mounted_items(unit, catalogue) + .iter() + .any(|(entry, _)| is_melee_kit(entry)), + ) +} + +/// Equipment that makes a design better at arm's length. +/// +/// See [`fights_in_melee`] for what is in and what is out. +pub fn is_melee_kit(entry: &EquipmentEntry) -> bool { + ["F_CLUB", "F_HAND_WEAPON", "F_TALON", "F_SPIKES", "F_TSM"] + .iter() + .any(|flag| entry.has_flag(flag)) + || entry.has_flag("F_PROTOMEK_MELEE") +} + /// What is actually bolted to the design, read from its critical slots. /// /// A `.mtf` says the same thing twice and not quite the same way. The @@ -1093,6 +1142,108 @@ mod tests { ]) } + /// A design is built for melee, or it is not, or the question does not + /// apply to it. + /// + /// The three cases are the whole of the facet. What counts came from a + /// survey of the 0.51.0 library and a decision about each edge, and the + /// edges are what this pins: the ones that look like they should count + /// and do not are the ones a later change will get wrong. + #[test] + fn what_counts_as_being_built_for_melee() { + let kit = |flags: &[&str]| EquipmentEntry { + internal_name: "thing".into(), + name: "thing".into(), + classes: vec!["MiscType".into()], + flags: flags.iter().map(|f| format!("MiscType.{f}")).collect(), + ..Default::default() + }; + + for flags in [ + // A hatchet, a sword, a mace, a vibroblade. + &["F_CLUB", "S_HATCHET"][..], + // And a shield, which is the same flag and counts: a design + // carrying one is built for a physical exchange either way. + &["F_CLUB", "S_SHIELD_LARGE"][..], + // The industrial tools, which are swung at things in the rules + // whatever they were built for. + &["F_CLUB", "S_DUAL_SAW"][..], + &["F_CLUB", "S_MINING_DRILL"][..], + &["F_HAND_WEAPON", "S_CLAW"][..], + &["F_TALON"][..], + &["F_SPIKES"][..], + &["F_TSM"][..], + &["F_PROTOMEK_MELEE"][..], + ] { + assert!(is_melee_kit(&kit(flags)), "{flags:?}"); + } + + for flags in [ + // A lifting aid rather than a damage bonus. + &["F_INDUSTRIAL_TSM"][..], + // The ordinary battle armour manipulator, which 605 designs + // carry: counting it would flag nearly every suit in the game. + &["F_BATTLE_CLAW"][..], + &["F_MAGNET_CLAW"][..], + &["F_VIBROCLAW"][..], + // And the things that only sound like it. + &["F_BLUE_SHIELD"][..], + &["F_CHAMELEON_SHIELD"][..], + &["F_MASC"][..], + ] { + assert!(!is_melee_kit(&kit(flags)), "{flags:?}"); + } + } + + /// Infantry is asked a question it cannot answer, and answers nothing. + /// + /// `None` rather than `false`, because [`crate::Tri`]-style filtering + /// only accepts a design that answers - so neither "melee" nor "not + /// melee" returns a platoon. Swarming a Mek is what infantry does rather + /// than a thing one platoon is built for and another is not. + #[test] + fn the_question_is_not_asked_of_anything_wearing_a_suit() { + let catalogue = Catalogue::new(vec![ + weapon("Medium Laser", 5.0, 3, 9), + EquipmentEntry { + internal_name: "Hatchet".into(), + name: "Hatchet".into(), + classes: vec!["MiscType".into()], + flags: vec!["MiscType.F_CLUB".into(), "MiscTypeFlag.S_HATCHET".into()], + ..Default::default() + }, + ]); + + let armed = |kind: &str| Unit { + unit_type: Some(kind.into()), + format_str: "blk".into(), + equipment: vec![Mount { + name: "Hatchet".into(), + location: Some("Right Arm".into()), + ..Default::default() + }], + ..Default::default() + }; + + assert_eq!(fights_in_melee(&armed("Tank"), &catalogue), Some(true)); + assert_eq!(fights_in_melee(&armed("BattleArmor"), &catalogue), None); + assert_eq!(fights_in_melee(&armed("Infantry"), &catalogue), None); + + // And a machine carrying nothing of the sort says so, rather than + // saying nothing. + let plain = Unit { + unit_type: Some("Tank".into()), + format_str: "blk".into(), + equipment: vec![Mount { + name: "Medium Laser".into(), + location: Some("Front".into()), + ..Default::default() + }], + ..Default::default() + }; + assert_eq!(fights_in_melee(&plain, &catalogue), Some(false)); + } + fn unit(sinks: &str, mounts: &[&str]) -> Unit { Unit { heat_sinks: Some(sinks.into()),