From f3f3a195c4fd3aaa7cf86edf79ef4064d38f7427 Mon Sep 17 00:00:00 2001 From: @permadeath.com Date: Thu, 20 Aug 2026 21:51:52 +0000 Subject: [PATCH] fix(unit-rules): read the design's slots, not its header lines A `.mtf` says what it carries twice and its header lines are the unreliable half. The `Weapons:` block spells a weapon the way a person would, and only the critical slots settle which of two weapons sharing that name is meant - a Yinghuochong YHC-3E is an Inner Sphere design carrying a Clan ER PPC. `mounted_items` reads the slots; the weapons block is the fallback for a `.blk`, which has none. `jump mp:` is unreliable four separate ways, all already written up in UPSTREAM.md, so `jumping_mp` counts the jets instead - with the partial wing's lift, the booster, the shields and modular armour that cost a point, and nought for a design with UMUs, which swims. Of 4,294 Meks the line is wrong for 57 and this is wrong for none. The formation oracle now forgives nothing: 42,453 requirement checks over 477 Meks and no disagreement about any figure helm computes. --- TODO.md | 35 ++++++++++++++++++++++------------- crates/helm-bridge/src/lib.rs | 1 + crates/helm-bv/tests/conformance.rs | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ crates/helm-core/src/computed.rs | 2 ++ crates/helm-core/src/lib.rs | 4 ++-- crates/helm-core/src/metrics.rs | 334 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------- crates/helm-db/src/lib.rs | 2 +- crates/helm-db/tests/differential.rs | 24 ++++++++++++++++++++++++ crates/helm-facet/src/query.rs | 3 ++- crates/helm-force/tests/formations.rs | 25 +++++++++++++++---------- 10 file(s) changed, 437 insertion(s)(+), 50 deletion(s)(-) diff --git a/TODO.md b/TODO.md --- a/TODO.md +++ b/TODO.md @@ -250,19 +250,28 @@ Deliberately not hardened armour, which costs a Mek a point in the battle value calculation and nothing in what MegaMek reports about the design. The two figures are not the same figure. -- [ ] **The last of the damage tail: mixed-tech designs.** Five requirement - checks still disagree, and they are designs whose tech base does not - settle which weapon is meant - a Yinghuochong YHC-3E is Inner Sphere and - carries a Clan ER PPC, worth 15 where the Inner Sphere one is worth 10. - The critical slots spell it `CLERPPC` and settle it outright, so the fix - is to read the loadout from the slots the way `helm_bv::loadout` does - rather than from the weapons block. -- [ ] **Jumping MP is the declared line too.** A Spindrift Aquatic SecurityMech - declares a jump it has no jets for, and MegaMek reports it as UMU - movement with a jump of nought - one of the four conventions written up - in `crates/helm-bv/UPSTREAM.md`. `helm_bv::Machine` works all of this out - already; the movement computation wants moving into `helm-core` so a - search and a formation rule can read it too. +- [x] **The loadout is read from the slots.** A `.mtf` says what it carries + twice: the `Weapons:` block spells a weapon the way a person would and + the critical slots spell it the way MegaMek looks it up. Only the second + settles which of two weapons sharing a display name is meant, which the + tech base only usually does - a Yinghuochong YHC-3E is an Inner Sphere + design carrying a Clan ER PPC. `helm_core::mounted_items` reads the slots + and the weapons block is the fallback for a `.blk`, which has no slots. + + That took the formation oracle's damage disagreements from 5 to 0, and + the test no longer forgives any: 42,453 requirement checks over 477 Meks, + and every figure helm computes for them is MegaMek's. +- [x] **Jumping MP is the jets, not the line.** `helm_core::jumping_mp` counts + them, with the partial wing's lift, the mechanical jump booster, the + shields and the modular armour that cost a point, and nought for a design + with UMUs - which swims rather than jumps and declares a figure it has no + jets for. Of 4,294 Meks the declared line is wrong for 57 and this is + wrong for none. + + Deliberately not the same figure `helm_bv` works out. Battle value runs + under a setting that says `noModularArmor`, so a Koshi (Mist Lynx) Z is + scored jumping six there and reported jumping five here. Two figures, + both right, and a test each. ## Force value diff --git a/crates/helm-bridge/src/lib.rs b/crates/helm-bridge/src/lib.rs --- a/crates/helm-bridge/src/lib.rs +++ b/crates/helm-bridge/src/lib.rs @@ -345,6 +345,7 @@ clan: b(v, "clan"), walk_mp: i(v, "walkMp"), run_mp: i(v, "runMp"), + jump_mp: i(v, "jumpMp"), alpha_strike: AlphaStrike { point_value: i(v, "pointValue"), unit_type: s(v, "asUnitType"), diff --git a/crates/helm-bv/tests/conformance.rs b/crates/helm-bv/tests/conformance.rs --- a/crates/helm-bv/tests/conformance.rs +++ b/crates/helm-bv/tests/conformance.rs @@ -38,6 +38,8 @@ /// no column for - it carries running and jumping, since those are what /// the two ratings are worked out from. walk: BTreeMap, + /// And what it jumps, for the same reason. + jump: BTreeMap, version: String, } @@ -59,6 +61,10 @@ let walk: BTreeMap = computed .iter() .filter_map(|s| Some((s.name.clone(), s.walk_mp?))) + .collect(); + let jump: BTreeMap = computed + .iter() + .filter_map(|s| Some((s.name.clone(), s.jump_mp?))) .collect(); let mut megamek: BTreeMap = computed .into_iter() @@ -91,6 +97,7 @@ catalogue, megamek, walk, + jump, version, }) } @@ -1714,6 +1721,56 @@ assert!( wrong.is_empty(), "{} designs the rule decides are a point away from MegaMek", + wrong.len() + ); +} + +/// Jumping, which is the jets rather than the line that claims to count them. +/// +/// The `jump mp:` line is unreliable four separate ways, all recorded in +/// `UPSTREAM.md`, and MegaMek reads none of it. A search that offers "jumps 4 +/// or more" has to mean what MegaMek means by it. +#[test] +#[ignore = "needs a MegaMek install and a bridge dump; set HELM_MEGAMEK and HELM_BRIDGE"] +fn jumping_is_the_jets_not_the_line() { + let Some(inputs) = inputs() else { + panic!("set HELM_MEGAMEK to a MegaMek install and HELM_BRIDGE to a bridge dump"); + }; + + let (mut checked, mut declared_wrong) = (0u32, 0u32); + let mut wrong: Vec = Vec::new(); + for unit in &inputs.library.units { + // Meks only: a `.blk` has no critical list to count jets in, so its + // declared figure is all there is and this has nothing to add. + if unit.format_str != "mtf" { + continue; + } + let Some(theirs) = inputs.jump.get(&unit.display_name()).copied() else { + continue; + }; + checked += 1; + if unit.jump_mp.unwrap_or(0) != theirs { + declared_wrong += 1; + } + let ours = helm_core::jumping_mp(unit, &inputs.catalogue).unwrap_or(0); + if ours != theirs { + wrong.push(format!("{}: {ours}, megamek {theirs}", unit.display_name())); + } + } + + println!( + "{checked} designs with a jumping figure; the declared line is wrong for \ + {declared_wrong} of them, and this is wrong for {}", + wrong.len() + ); + for line in wrong.iter().take(20) { + println!(" {line}"); + } + assert!(checked > 1_000, "only {checked} designs were checked"); + assert!( + wrong.is_empty(), + "{} of {checked} designs disagree about jumping, against {declared_wrong} \ + that the declared line gets wrong", wrong.len() ); } diff --git a/crates/helm-core/src/computed.rs b/crates/helm-core/src/computed.rs --- a/crates/helm-core/src/computed.rs +++ b/crates/helm-core/src/computed.rs @@ -62,6 +62,8 @@ pub walk_mp: Option, /// Derived from walk MP and the engine, so computed rather than declared. pub run_mp: Option, + /// What the design jumps, which is its jets and not its `jump mp:` line. + pub jump_mp: Option, pub alpha_strike: AlphaStrike, } diff --git a/crates/helm-core/src/lib.rs b/crates/helm-core/src/lib.rs --- a/crates/helm-core/src/lib.rs +++ b/crates/helm-core/src/lib.rs @@ -40,8 +40,8 @@ StructureKind, }; pub use metrics::{ - CombatMetrics, FORMATION_RANGES, FORMATION_SINGLE_RANGES, damage_at_range, - max_single_damage_at_range, walking_mp, + CombatMetrics, FORMATION_RANGES, FORMATION_SINGLE_RANGES, damage_at_range, 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 --- a/crates/helm-core/src/metrics.rs +++ b/crates/helm-core/src/metrics.rs @@ -14,6 +14,8 @@ //! when it exists. The point of these is to make a filter able to say //! "long-ranged and can sustain fire" instead of "70 tons, Sniper". +use std::collections::BTreeMap; + use crate::{Catalogue, EquipmentEntry, Unit}; /// How a design performs, derived rather than declared. @@ -42,12 +44,16 @@ /// the figure the file declares. /// /// A `walk mp:` line describes the engine and the tonnage. A shield is - /// heavy and in the way, and hardened plate is heavy full stop, so a Mek - /// carrying either walks a point less than its own file says - a Black - /// Knight BLK-NT-3A declares 5 and MegaMek reports 4. `None` when nothing - /// on the design changes it, so a caller can tell "unchanged" from + /// heavy and in the way and modular armour throws the machine off balance, + /// so a Mek carrying either walks a point less than its own file says - a + /// Black Knight BLK-NT-3A declares 5 and MegaMek reports 4. `None` when + /// nothing on the design changes it, so a caller can tell "unchanged" from /// "recomputed and came out the same". pub walk_mp: Option, + /// Jumping MP counted off the jets, which is what MegaMek reports and not + /// what the `jump mp:` line says. `None` for a design with no critical + /// slots to count them in. + pub jump_mp: Option, } impl CombatMetrics { @@ -80,6 +86,7 @@ } m.walk_mp = walking_mp(unit, catalogue); + m.jump_mp = jumping_mp(unit, catalogue); let clan = unit.is_clan(); for mount in &unit.equipment { let Some(entry) = catalogue.resolve_for(&mount.name, clan) else { @@ -140,20 +147,9 @@ if catalogue.is_empty() { return Vec::new(); } - // A weapons block names a weapon the way a person would, and two weapons - // answer to most of those names. Which one is meant is the design's tech - // base and nothing else. - let clan = unit.is_clan(); - - // Collapse to name and count first: a mount list repeats an entry per - // mount, and both the weapon walk and the ammunition lookup want counts. - let mut loadout: Vec<(&str, i64)> = Vec::new(); - for mount in &unit.equipment { - match loadout.iter_mut().find(|(n, _)| *n == mount.name) { - Some((_, qty)) => *qty += 1, - None => loadout.push((mount.name.as_str(), 1)), - } - } + // Read from the critical slots, which name a weapon the way MegaMek looks + // it up rather than the way a person writes it - see `mounted_items`. + let loadout = mounted_items(unit, catalogue); // Ammunition is looked up separately, and deliberately not in `loadout`. // A .mtf `Weapons:` block lists weapons only - its ammunition appears in @@ -172,15 +168,14 @@ // writes `(OMNIPOD)` on everything in a pod - which is where an // omni keeps its ammunition. Unstripped, a Daishi S looks like it // has nothing to feed its LB 20-X with. - .filter_map(|n| catalogue.resolve_for(&crate::strip_slot_markers(n), clan)) + // A slot names its contents unambiguously, so no tech base is needed + // to read one. + .filter_map(|n| catalogue.resolve(&crate::strip_slot_markers(n))) .filter(|e| e.is_ammo()) .collect(); let mut out = Vec::new(); - for (name, qty) in &loadout { - let Some(weapon) = catalogue.resolve_for(name, clan) else { - continue; - }; + for (weapon, qty) in &loadout { // Weapons only, by MegaMek's own test rather than by whether the // catalogue holds an average for them. if !weapon.is_weapon() || weapon.is_ammo() { @@ -248,6 +243,95 @@ /// The bands asked about for a single weapon rather than a total. pub const FORMATION_SINGLE_RANGES: &[i64] = &[15, 18]; +/// 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 +/// `Weapons:` block names each weapon the way a person would - `Large Pulse +/// Laser`, `ER PPC` - and the critical slots name it the way MegaMek looks it +/// up: `CLLargePulseLaser`, `ISERPPC`. The second spelling settles which of +/// two weapons sharing a display name is meant, which the design's tech base +/// only usually does: a Yinghuochong YHC-3E is an Inner Sphere design carrying +/// a Clan ER PPC, worth fifteen where the Inner Sphere one is worth ten. +/// +/// So the slots are the source and the weapons block is the fallback, for the +/// designs that have no slots to read - a `.blk` writes one line per mounting +/// and no critical list at all. +/// +/// Returned as one entry per distinct item with a count, because a weapon +/// fills as many slots as it has criticals and MegaMek counts weapons rather +/// than slots. A superheavy packs two items into each physical slot, which +/// halves the footprint rather than doubling the count. +pub fn mounted_items<'a>(unit: &Unit, catalogue: &'a Catalogue) -> Vec<(&'a EquipmentEntry, i64)> { + // Slots and the locations they are spread over, both: an item with a fixed + // size is counted by dividing the slots, and one without a size - a hatchet + // takes a slot per fifteen tons - is counted one per location. + let mut slots: BTreeMap<&str, (i64, usize)> = BTreeMap::new(); + for (location, lines) in &unit.criticals { + let mut here: BTreeMap<&str, ()> = BTreeMap::new(); + for line in lines { + for part in line.split('|') { + let name = crate::strip_slot_markers(part); + if name.is_empty() || name == "-Empty-" { + continue; + } + if let Some(entry) = catalogue.resolve(&name) { + let key = entry.internal_name.as_str(); + slots.entry(key).or_default().0 += 1; + here.insert(key, ()); + } + } + } + let _ = location; + for key in here.keys() { + slots.entry(key).or_default().1 += 1; + } + } + if slots.is_empty() { + // No critical list: a `.blk`, where a mount line is a whole item and + // the name is the only spelling there is. + let clan = unit.is_clan(); + let mut counts: Vec<(&EquipmentEntry, i64)> = Vec::new(); + for mount in &unit.equipment { + let Some(entry) = catalogue.resolve_for(&mount.name, clan) else { + continue; + }; + match counts + .iter_mut() + .find(|(e, _)| e.internal_name == entry.internal_name) + { + Some((_, n)) => *n += 1, + None => counts.push((entry, 1)), + } + } + return counts; + } + + let per_slot = if unit.mass.is_some_and(|t| t > 100.0) { + 2 + } else { + 1 + }; + slots + .into_iter() + .filter_map(|(name, (filled, locations))| { + let entry = catalogue.get(name)?; + 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. + Some(per_item) => { + let footprint = (per_item as usize).div_ceil(per_slot).max(1) as i64; + (filled / footprint).max(1) + } + // Nothing to divide by, so one per location - which is right + // for everything built that way, and is what `helm_bv` does + // with the same problem. + None => locations as i64, + }; + Some((entry, count)) + }) + .collect() +} + /// Walking MP after the things a design carries that slow it down. /// /// Two things do it, both weight rather than damage: a medium or large shield @@ -294,6 +378,82 @@ ); let cost = shields + modular; (cost > 0).then(|| (declared - cost).max(0)) +} + +/// 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 +/// `crates/helm-bv/UPSTREAM.md`: a partial wing's contribution is left out of +/// it, a mechanical jump booster is not jets at all, a design with UMUs +/// declares a figure it has no jets for and swims rather than jumps, and one +/// design in the library simply has the wrong number in it. MegaMek reads none +/// of it - `Mek.getJumpMP` counts the jets - so neither does this. +/// +/// `None` when the design has no critical slots to count jets in, which is +/// every `.blk`: there the declared line is all there is. +pub fn jumping_mp(unit: &Unit, catalogue: &Catalogue) -> Option { + if unit.criticals.is_empty() { + return None; + } + // Counted as items rather than slots: an improved jump jet fills two of + // them, and counting slots gives a Firestarter FS9-B sixteen points of + // jump where it has eight. + let items = mounted_items(unit, catalogue); + let total = |flag: &str| -> i64 { + items + .iter() + .filter(|(e, _)| e.has_flag(flag)) + .map(|(_, n)| n) + .sum() + }; + let jets = total("F_JUMP_JET"); + let umus = total("F_UMU"); + let wing = total("F_PARTIAL_WING") > 0; + let large_shield = total("S_SHIELD_LARGE") > 0; + // A shield is one item however many slots it fills, so this is the same + // count `walking_mp` makes. + let shields = total("S_SHIELD_MEDIUM"); + // A booster writes its own size into the slot - it is the design that says + // how far it throws, not the catalogue, and the line resolves to no + // equipment at all. + let booster = unit + .criticals + .values() + .flatten() + .filter(|line| crate::normalize(line).contains("mechanicaljumpbooster")) + .filter_map(|line| line.split(":SIZE:").nth(1)) + .filter_map(|size| size.trim().parse::().ok()) + .map(|size| size.round() as i64) + .max() + .unwrap_or(0); + + // A design with UMUs swims instead, and one carrying a large shield cannot + // get off the ground at all. + if umus > 0 || large_shield { + return Some(0); + } + let bonus = if wing { + partial_wing_bonus(unit.mass?) + } else { + 0 + }; + // A wing amplifies lift rather than providing it: it does nothing for a + // design with no jets. + let lifted = if jets > 0 { jets + bonus } else { 0 }; + let thrown = if booster > 0 { booster + bonus } else { 0 }; + // Modular armour throws the machine off balance and costs a point of + // jumping as well as a point of walking - a Koshi (Mist Lynx) Z jumps five + // rather than six. The battle value calculation ignores this and helm-bv + // follows it there: the setting it runs under says `noModularArmor` + // outright. This is what MegaMek reports about the design, which is the + // other figure. + let modular = i64::from(total("F_MODULAR_ARMOR") > 0); + Some((lifted.max(thrown) - shields - modular).max(0)) +} + +/// What a partial wing adds to a jump, which is more for a lighter design. +fn partial_wing_bonus(tons: f64) -> i64 { + if tons > 55.0 { 1 } else { 2 } } /// How much heat a design sheds in a turn. @@ -729,5 +889,133 @@ .insert(location.into(), vec!["ISModularArmor".into()]); } assert_eq!(walking_mp(&unit, &catalogue), Some(3)); + } +} + +#[cfg(test)] +mod reading_the_slots { + use super::*; + use crate::EquipmentEntry; + + fn item(internal: &str, criticals: Option, flags: &[&str]) -> EquipmentEntry { + EquipmentEntry { + internal_name: internal.into(), + name: internal.into(), + criticals, + flags: flags.iter().map(|f| format!("MiscType.{f}")).collect(), + ..Default::default() + } + } + + fn mek(tons: f64, slots: &[(&str, &[&str])]) -> Unit { + Unit { + chassis: "Test".into(), + model: "1".into(), + format_str: "mtf".into(), + mass: Some(tons), + walk_mp: Some(4), + jump_mp: Some(0), + criticals: slots + .iter() + .map(|(location, lines)| { + ( + (*location).to_string(), + lines.iter().map(|l| (*l).to_string()).collect(), + ) + }) + .collect(), + ..Default::default() + } + } + + #[test] + fn a_weapon_is_counted_once_however_many_slots_it_fills() { + let catalogue = Catalogue::new(vec![item("ISLRM20", Some(5), &[])]); + let unit = mek(100.0, &[("Left Torso", &["ISLRM20"; 5])]); + assert_eq!(mounted_items(&unit, &catalogue)[0].1, 1); + } + + #[test] + fn an_item_with_no_size_is_counted_once_per_location() { + // A hatchet takes a slot per fifteen tons, so there is no size to + // divide by - and a Berserker carrying one in each arm has two. + let catalogue = Catalogue::new(vec![item("Hatchet", None, &[])]); + let unit = mek( + 100.0, + &[ + ("Left Arm", &["Hatchet", "Hatchet", "Hatchet"]), + ("Right Arm", &["Hatchet", "Hatchet", "Hatchet"]), + ], + ); + assert_eq!(mounted_items(&unit, &catalogue)[0].1, 2); + } + + #[test] + fn a_superheavy_packs_two_items_into_each_slot() { + let catalogue = Catalogue::new(vec![item("ISERMediumPulseLaser", Some(2), &[])]); + // Three lasers of two slots each, in three slots. + let unit = mek(110.0, &[("Right Torso", &["ISERMediumPulseLaser"; 3])]); + assert_eq!(mounted_items(&unit, &catalogue)[0].1, 3); + } + + #[test] + fn jumping_counts_the_jets_and_not_the_slots_they_fill() { + let catalogue = Catalogue::new(vec![ + // An improved jet fills two slots and lifts one point. + item("ISImprovedJump Jet", Some(2), &["F_JUMP_JET"]), + ]); + let unit = mek(45.0, &[("Left Leg", &["ISImprovedJump Jet"; 8])]); + assert_eq!(jumping_mp(&unit, &catalogue), Some(4)); + } + + #[test] + fn a_partial_wing_lifts_only_a_design_that_has_jets() { + let catalogue = Catalogue::new(vec![ + item("Jump Jet", Some(1), &["F_JUMP_JET"]), + item("PartialWing", Some(3), &["F_PARTIAL_WING"]), + ]); + let winged = mek( + 45.0, + &[ + ("Left Leg", &["Jump Jet", "Jump Jet", "Jump Jet"]), + ("Left Torso", &["PartialWing", "PartialWing", "PartialWing"]), + ], + ); + // Three jets and a wing on a light design: three plus two. + assert_eq!(jumping_mp(&winged, &catalogue), Some(5)); + + // The same wing on a design with no jets lifts nothing. + let wingless = mek( + 45.0, + &[("Left Torso", &["PartialWing", "PartialWing", "PartialWing"])], + ); + assert_eq!(jumping_mp(&wingless, &catalogue), Some(0)); + } + + #[test] + fn a_design_with_umus_swims_rather_than_jumps() { + let catalogue = Catalogue::new(vec![ + item("Jump Jet", Some(1), &["F_JUMP_JET"]), + item("UMU", Some(1), &["F_UMU"]), + ]); + let unit = mek( + 45.0, + &[ + ("Left Leg", &["Jump Jet", "Jump Jet"]), + ("Right Leg", &["UMU"]), + ], + ); + assert_eq!(jumping_mp(&unit, &catalogue), Some(0)); + } + + #[test] + fn a_blk_has_no_slots_to_count_and_says_so() { + let catalogue = Catalogue::new(vec![item("Jump Jet", Some(1), &["F_JUMP_JET"])]); + let tank = Unit { + format_str: "blk".into(), + jump_mp: Some(3), + ..Default::default() + }; + assert_eq!(jumping_mp(&tank, &catalogue), None); } } diff --git a/crates/helm-db/src/lib.rs b/crates/helm-db/src/lib.rs --- a/crates/helm-db/src/lib.rs +++ b/crates/helm-db/src/lib.rs @@ -176,7 +176,7 @@ u.armor, u.motion_type, metrics.as_ref().and_then(|m| m.walk_mp).or(u.walk_mp), - u.jump_mp, + metrics.as_ref().and_then(|m| m.jump_mp).or(u.jump_mp), u.total_armor(), u.armor_percent(), u.equipment.len() as i64, diff --git a/crates/helm-db/tests/differential.rs b/crates/helm-db/tests/differential.rs --- a/crates/helm-db/tests/differential.rs +++ b/crates/helm-db/tests/differential.rs @@ -170,6 +170,30 @@ .map(str::to_string), rack_size: i("rackSize").filter(|r| *r > 0), damage_per_shot: i("damagePerShot"), + // How many slots an item fills, which is how many of it a + // critical list is saying there are. Without this an Atlas + // reads as carrying one medium laser rather than four. + criticals: i("criticalSlots"), + classes: v + .get("_classes") + .and_then(|x| x.as_array()) + .map(|a| { + a.iter() + .filter_map(|c| c.as_str()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(), + flags: v + .get("_flags") + .and_then(|x| x.as_array()) + .map(|a| { + a.iter() + .filter_map(|f| f.as_str()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(), ..Default::default() }) }) diff --git a/crates/helm-facet/src/query.rs b/crates/helm-facet/src/query.rs --- a/crates/helm-facet/src/query.rs +++ b/crates/helm-facet/src/query.rs @@ -172,7 +172,8 @@ // What it walks rather than what it declares: a shield or hardened // plate costs a point, and MegaMek reports the reduced figure. walk_mp: metrics.as_ref().and_then(|m| m.walk_mp).or(unit.walk_mp), - jump_mp: unit.jump_mp, + // The jets rather than the line that claims to count them. + jump_mp: metrics.as_ref().and_then(|m| m.jump_mp).or(unit.jump_mp), canon: stats.and_then(|s| s.canon), invalid: stats.and_then(|s| s.invalid), omni: stats.and_then(|s| s.omni), diff --git a/crates/helm-force/tests/formations.rs b/crates/helm-force/tests/formations.rs --- a/crates/helm-force/tests/formations.rs +++ b/crates/helm-force/tests/formations.rs @@ -118,7 +118,9 @@ // modular armour panels costs a point, and MegaMek's rules read the // reduced figure. walk_mp: helm_core::walking_mp(unit, &inputs.catalogue).or(unit.walk_mp), - jump_mp: unit.jump_mp, + // The jets, not the line: a design with UMUs declares a jump it has + // no jets for and swims instead. + jump_mp: helm_core::jumping_mp(unit, &inputs.catalogue).or(unit.jump_mp), armor: Some(unit.total_armor()), damage_at: ranges .iter() @@ -270,15 +272,18 @@ // disagreement there is a misread label. assert!(wrong.is_empty(), "{} labels are misread", wrong.len()); - // The other two are printed rather than asserted. They are real - helm - // reads a Clan design's weapons by their display names and scores them as - // the Inner Sphere versions, and it takes walking MP from the `walk mp:` - // line rather than from the design, so a Mek carrying a shield keeps a - // point it does not have - but both are gaps in the metrics rather than in - // the reading of a label, and both are written up in `TODO.md`. A count - // would make a poor ratchet either way: how many there are depends on how - // large a sample `--matches` was asked for, which is chosen at dump time - // and not recorded anywhere a test could read. + // Nothing is forgiven any more. This used to report the disagreements a + // rule reading a damage total or a movement point turned up - 179 and 16 + // of them - because the figures helm fed those rules were its own and not + // MegaMek's. They are MegaMek's now: a Clan design's weapons are Clan + // weapons, a variable weapon does what it does at the range asked about, a + // one-shot launcher arrives loaded, and walking and jumping are counted + // off the design rather than read off its header lines. + let by_figure: u64 = by_fact.values().sum(); + assert_eq!( + by_figure, 0, + "{by_figure} requirements disagree about a figure helm computes" + ); assert!(designs > 50, "only {designs} designs were checked"); assert!(checked > 1_000, "only {checked} requirements were checked"); } -- tangled.sh