diff --git a/crates/helm-core/src/lib.rs b/crates/helm-core/src/lib.rs index 7982cb9..0ca539b 100644 --- a/crates/helm-core/src/lib.rs +++ b/crates/helm-core/src/lib.rs @@ -45,12 +45,12 @@ pub use expected::{ firepower_of, one_shot_share, }; 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, fights_in_melee, - infantry_armor_is_encumbering, infantry_can_make_anti_mek_attacks, infantry_can_swarm, - 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, + CombatMetrics, FORMATION_RANGES, FORMATION_SINGLE_RANGES, MIRRORED_LOCATIONS, REACH, + can_make_anti_mek_attacks, 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, is_melee_kit, is_symmetrical, 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 73b907f..fd9f36a 100644 --- a/crates/helm-core/src/metrics.rs +++ b/crates/helm-core/src/metrics.rs @@ -346,6 +346,89 @@ pub fn is_melee_kit(entry: &EquipmentEntry) -> bool { || entry.has_flag("F_PROTOMEK_MELEE") } +/// The locations a design mirrors, by the names a `.mtf` writes. +/// +/// A biped and a tripod mirror their arms, their side torsos and their two +/// legs; a quad mirrors its side torsos and both pairs of legs. A tripod's +/// centre leg has nothing to be compared with, and neither does a head or a +/// centre torso, so neither is asked about. +pub const MIRRORED_LOCATIONS: &[(&str, &str)] = &[ + ("Left Arm", "Right Arm"), + ("Left Torso", "Right Torso"), + ("Left Leg", "Right Leg"), + ("Front Left Leg", "Front Right Leg"), + ("Rear Left Leg", "Rear Right Leg"), +]; + +/// Whether everything the design carries is allocated the same way on both +/// sides of it. +/// +/// A filter rather than a figure, and a question about the critical hit table +/// rather than about the inventory: a Warhammer carries a PPC in each arm and +/// is symmetrical, while a Hatchetman carries a medium laser in each arm and +/// is not, because only the right one holds the hatchet. Losing a side of a +/// symmetrical design costs it half of something rather than all of one +/// thing, and it is a shape a player recognises on sight. +/// +/// Every slot counts, not just the weapons. An actuator removed to make room +/// is an allocation, a heat sink in one torso and not the other is an +/// allocation, and so is the structure and plate a design spreads through +/// whatever slots are free: a design whose Endo Steel sits three slots in the +/// left torso and two in the right was not built to the same plan on both +/// sides. That is the strict reading of the question, and it is the one that +/// needs no list of what to overlook. +/// +/// The slot's own text is compared rather than what it resolves to, which is +/// what keeps a rear-facing mounting, a pod mounting and an armoured slot +/// distinct from the plain item - all three are allocations too. It also +/// means no catalogue is needed to answer this. +/// +/// `None` for anything that is not a Mek, and for a Mek with no critical hit +/// table to read: a tank has a left and a right side, but no rules-level +/// notion of mirroring them, and answering "no" for one would put every +/// vehicle in the library on the wrong side of the filter. +pub fn is_symmetrical(unit: &Unit) -> Option { + if !unit.is_mek() { + return None; + } + let mut asked = false; + let mut mirrored = true; + for (left, right) in MIRRORED_LOCATIONS { + let (Some(left), Some(right)) = (unit.criticals.get(*left), unit.criticals.get(*right)) + else { + continue; + }; + asked = true; + mirrored &= allocation(left) == allocation(right); + } + asked.then_some(mirrored) +} + +/// What one location holds, counted by item and ignoring the order it was +/// written in. +/// +/// Empty slots are left out: which slots a location has spare is a fact about +/// the design, but a hatchet fills three slots in one arm and a laser one in +/// the other, and the free slots left over say the same thing twice. +/// +/// A superheavy packs two items into one slot and writes them either side of +/// a pipe, so the parts are counted rather than the lines. Compared without +/// regard to case, so that two spellings of the same marker cannot make a +/// design asymmetrical. +fn allocation(slots: &[String]) -> BTreeMap { + let mut held: BTreeMap = BTreeMap::new(); + for line in slots { + for part in crate::slot_parts(line) { + let part = part.trim(); + if part.is_empty() || part.eq_ignore_ascii_case("-Empty-") { + continue; + } + *held.entry(part.to_ascii_uppercase()).or_default() += 1; + } + } + held +} + /// 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 @@ -1244,6 +1327,123 @@ mod tests { assert_eq!(fights_in_melee(&plain, &catalogue), Some(false)); } + /// A Mek is symmetrical when both sides hold the same things, and one + /// slot is enough to say it is not. + /// + /// The Hatchetman is the case worth writing down: a medium laser in each + /// arm, and a hatchet in only one of them. The question is about the + /// critical hit table rather than about the inventory, so the arms are + /// what settle it. + #[test] + fn a_design_is_symmetrical_when_both_sides_hold_the_same_things() { + let mek = |left: &[&str], right: &[&str]| Unit { + format_str: "mtf".into(), + config: Some("Biped".into()), + criticals: [ + ("Left Arm".to_string(), lines(left)), + ("Right Arm".to_string(), lines(right)), + ("Left Torso".to_string(), lines(&["Heat Sink"])), + ("Right Torso".to_string(), lines(&["Heat Sink"])), + ] + .into_iter() + .collect(), + ..Default::default() + }; + + let arm = ["Shoulder", "Upper Arm Actuator", "Medium Laser", "-Empty-"]; + assert_eq!(is_symmetrical(&mek(&arm, &arm)), Some(true)); + + // The Hatchetman: the same laser in each arm, and three slots of + // hatchet in one of them. + let hatchet = [ + "Shoulder", + "Upper Arm Actuator", + "Medium Laser", + "Hatchet", + "Hatchet", + "Hatchet", + ]; + assert_eq!(is_symmetrical(&mek(&arm, &hatchet)), Some(false)); + + // Order is not part of it - a location holds what it holds - but a + // count is: two heat sinks in one torso and one in the other is not + // the same design on both sides. + assert_eq!( + is_symmetrical(&mek( + &["Medium Laser", "Shoulder"], + &["Shoulder", "Medium Laser"] + )), + Some(true) + ); + let uneven = Unit { + format_str: "mtf".into(), + criticals: [ + ("Left Torso".to_string(), lines(&["Heat Sink", "Heat Sink"])), + ("Right Torso".to_string(), lines(&["Heat Sink", "-Empty-"])), + ] + .into_iter() + .collect(), + ..Default::default() + }; + assert_eq!(is_symmetrical(&uneven), Some(false)); + + // How a mounting faces is an allocation too: the same laser, one of + // them pointing backwards, is not a mirrored pair. + assert_eq!( + is_symmetrical(&mek(&["Medium Laser"], &["Medium Laser (R)"])), + Some(false) + ); + } + + /// A quad mirrors four legs and two torsos, and nothing is asked of a + /// machine with no critical hit table. + #[test] + fn the_question_is_asked_of_meks_and_of_nothing_else() { + let quad = Unit { + format_str: "mtf".into(), + config: Some("Quad".into()), + criticals: [ + ( + "Front Left Leg".to_string(), + lines(&["Hip", "Medium Laser"]), + ), + ("Front Right Leg".to_string(), lines(&["Hip"])), + ("Rear Left Leg".to_string(), lines(&["Hip"])), + ("Rear Right Leg".to_string(), lines(&["Hip"])), + ("Left Torso".to_string(), lines(&["Heat Sink"])), + ("Right Torso".to_string(), lines(&["Heat Sink"])), + ] + .into_iter() + .collect(), + ..Default::default() + }; + assert_eq!(is_symmetrical(&quad), Some(false)); + + // A tank has a left side and a right side and no rules-level notion + // of mirroring them, so it is not asked rather than answering no. + let tank = Unit { + unit_type: Some("Tank".into()), + format_str: "blk".into(), + criticals: [("Front".to_string(), lines(&["Medium Laser"]))] + .into_iter() + .collect(), + ..Default::default() + }; + assert_eq!(is_symmetrical(&tank), None); + + // And a Mek read without its critical hit table - a `.mul` names a + // design rather than describing one - has nothing to compare. + let bare = Unit { + format_str: "mtf".into(), + ..Default::default() + }; + assert_eq!(is_symmetrical(&bare), None); + } + + fn lines(slots: &[&str]) -> Vec { + slots.iter().map(|s| (*s).to_string()).collect() + } + fn unit(sinks: &str, mounts: &[&str]) -> Unit { Unit { heat_sinks: Some(sinks.into()),