From 2e43e8724b51f4dc3db2b605750d9aebd48fef94 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 21 Aug 2026 18:08:17 -0400 Subject: [PATCH] test(unit-rules): 863 varied units, scored against MegaMek Generated rather than written, because the coverage that matters is breadth: 859 units over 60 families of rounds, in five files each holding one question - a munition swapped, rounds from a launcher the design does not have, a bin run dry, what a .mul may not vary, and a swap on a machine that has been shot at. The oracle test now separates a design helm already scores differently as built, which is conformance.txt's, from one it scores right as built and wrong once varied, which is this test's. There are none of the second. --- TODO.md | 61 +- crates/helm-bv/tests/conformance.rs | 52 +- .../helm-bv/tests/mul/varied-cross-family.mul | 1200 ++++++++++ crates/helm-bv/tests/mul/varied-damaged.mul | 978 ++++++++ crates/helm-bv/tests/mul/varied-empty.mul | 1879 ++++++++++++++++ crates/helm-bv/tests/mul/varied-munitions.mul | 1991 +++++++++++++++++ crates/helm-bv/tests/mul/varied-refused.mul | 304 +++ crates/helm-bv/tests/varied.rs | 489 ++++ 8 files changed, 6943 insertions(+), 11 deletions(-) create mode 100644 crates/helm-bv/tests/mul/varied-cross-family.mul create mode 100644 crates/helm-bv/tests/mul/varied-damaged.mul create mode 100644 crates/helm-bv/tests/mul/varied-empty.mul create mode 100644 crates/helm-bv/tests/mul/varied-munitions.mul create mode 100644 crates/helm-bv/tests/mul/varied-refused.mul create mode 100644 crates/helm-bv/tests/varied.rs diff --git a/TODO.md b/TODO.md index 418b360..77de7b9 100644 --- a/TODO.md +++ b/TODO.md @@ -35,10 +35,9 @@ with MegaMek except where a deviation is registered. compiled twice, agreeing. The smoke test checks a few designs; the proof is the whole library scored in Node and natively with no disagreement. It is the reason `helm-bv` does no I/O, and it is currently unproven. -3. **Battle value for a varied unit.** `Condition` models damage and not - variation. A player swapping an LRM bin for inferno rounds, or changing an - omni pod, is exactly what the epic says players will do - and it is the - largest gap between what helm computes and what the product needs. +3. **Battle value for a varied unit.** Done. 863 varied units are scored + against MegaMek and all of them agree, and a `.mul` turns out to vary + ammunition and nothing else. See the entry below. 4. **Pin the rounding.** Also named in the epic. Battle value is full of half-point steps and nothing proves the two builds round alike, or that a total does not depend on the order floats were added in. @@ -369,9 +368,57 @@ The long pole, and the reason `helm-core` does no I/O. Tracked as An unknown name in a slot is ignored rather than treated as an empty one: a newer MegaMek's spelling should cost nothing, and the design's own - answer is a better guess than a hole. An omni pod swap - different - equipment rather than different rounds - should ride the same mechanism - and has not been checked. + answer is a better guess than a hole. + + **A `.mul` varies ammunition and nothing else**, which was worth finding + out before building on the assumption that it varies anything. + `MULParser` calls `changeAmmoType` on a magazine, and for every other + slot prints `XML file expects X equipment at index N of location L, but + Entity has Y there` and leaves the design alone. Both halves of that are + a rule: a file naming a laser where a bin sits is refused, and so is one + naming rounds where a laser sits. helm was applying either, which made an + ER medium laser written over a Shadow Hawk's SRM bin worth 116 points it + does not have. **An omni pod swap cannot be expressed in a `.mul` at + all** - a pod swap is a different design, and the library already holds + both. + + `crates/helm-bv/tests/varied.rs` generates the fixtures, because the + coverage that matters here is breadth: 859 units over 60 families of + rounds, on designs that carry them. Five files, each holding one + question - a munition swapped, rounds from a launcher the design does + not have, a bin run dry, what a `.mul` may not vary, and a swap on a + machine that has been shot at. Regenerate with `HELM_MUL_BLESS=1` and + re-run `bridge/dump.sh`; without it the same test checks the committed + fixtures against what it would write, so one edited by hand fails. + + The oracle test separates the two kinds of disagreement it can find. A + design helm already scores differently *as built* is one of + `conformance.txt`'s and is counted and not failed; a design helm scores + right as built and wrong once varied is this test's, and there are none. + + Four rules came out of building it, none of which the library on its own + would have shown: + + - **An empty magazine loses its rounds and its risk of going off, and + nothing else.** helm dropped the whole item, which is the same thing + for an LRM bin and not for a coolant pod: a 'Wing' Wraith TR7 with a + spent pod was 61 points light because the pod's heat budget went with + it. A C3 remote sensor bay and an AP Gauss bin are the same shape of + mistake. + - **A superheavy's shared slot is one mounting to a `.mul`.** MegaMek + reaches `CriticalSlot.getMount()` and never the second, so an Orca + OC-1X's `IS Ammo LRM-20|IS Ammo LRM-20` loses one bin and not two. + Reading the line whole also resolved nothing, so the swap was silently + dropped on every superheavy. + - **A Clan Mek's structural CASE is where the design needed it**, not + everywhere. MegaMek's `addClanCase` fits a real `CLCASE` mounting to + each location holding something explosive *while it loads the design*, + and nothing later can add one. The two readings agree on every design + in the library and differ the moment a magazine goes into a bay built + inert: a Butcherbird (Ion Sparrow) Prime carries AP Gauss rounds, + which do not go off, so its right torso has no CASE. + - **Shot count is a yes or a no.** A bin with one round left is worth + what a full one is; only nought changes the figure. - [x] **The rounding is pinned, and the designs it decides are named.** Battle value is built from half points - two and a half a point of armour, one and a half a point of structure - so a total landing exactly on a half is diff --git a/crates/helm-bv/tests/conformance.rs b/crates/helm-bv/tests/conformance.rs index 114ed9c..845d735 100644 --- a/crates/helm-bv/tests/conformance.rs +++ b/crates/helm-bv/tests/conformance.rs @@ -1604,6 +1604,13 @@ fn a_varied_unit_is_worth_what_megamek_says_it_is() { let mut checked = 0; let mut varied = 0; let mut wrong: Vec = Vec::new(); + // A design helm already scores wrong as it left the factory is not a + // variation this crate got wrong: it is one of the disagreements + // `conformance.txt` already records, and counting it here would say a + // magazine was misread when the armour type was never supported. These + // are reported and not failed. + let mut inherited: std::collections::BTreeMap = Default::default(); + let mut by_file: std::collections::BTreeMap<&str, (usize, usize)> = Default::default(); for (file, expected) in &rows { let text = std::fs::read_to_string(fixtures.join(file)).expect("a fixture"); let mul = helm_unitfile::parse_mul(&text).expect("a readable fixture"); @@ -1631,21 +1638,58 @@ fn a_varied_unit_is_worth_what_megamek_says_it_is() { varied += 1; } checked += 1; - match helm_bv::battle_value_in(unit, &inputs.catalogue, &condition) { - Ok(ours) if ours == *theirs => {} - other => wrong.push(format!("{file}: {name}: ours {other:?}, megamek {theirs}")), + let counts = by_file.entry(file.as_str()).or_default(); + counts.0 += 1; + let ours = helm_bv::battle_value_in(unit, &inputs.catalogue, &condition); + if ours.as_ref().ok() == Some(theirs) { + continue; + } + // Whose disagreement is this? A design helm cannot score whole, or + // scores differently whole, was already wrong before anybody + // varied it. + let built = helm_bv::battle_value(unit, &inputs.catalogue).ok(); + let theirs_built = inputs + .megamek + .get(&unit.display_name()) + .and_then(|b| b.battle_value); + if built.is_none() || built != theirs_built { + *inherited.entry(unit.display_name()).or_default() += 1; + continue; } + counts.1 += 1; + wrong.push(format!("{file}: {name}: ours {ours:?}, megamek {theirs}")); } } println!("{checked} units read from .mul files, {varied} of them varied"); - for line in &wrong { + for (file, (seen, bad)) in &by_file { + println!(" {file:28} {seen:>4} units, {bad} wrong"); + } + if !inherited.is_empty() { + let total: usize = inherited.values().sum(); + println!( + " {total} rows on {} designs helm already scores differently as built, \ + which are conformance.txt's and not this test's:", + inherited.len() + ); + for (name, count) in inherited.iter().take(12) { + println!(" {name} ({count})"); + } + } + for line in wrong.iter().take(30) { println!(" {line}"); } assert!(wrong.is_empty(), "{} disagree", wrong.len()); // A fixture set with no variation in it would pass without checking the // thing this test is for. assert!(varied > 0, "no fixture varies a unit"); + // And one that lost its generated half would pass on four hand-written + // units, which is not the coverage this claims. + assert!( + checked > 500, + "only {checked} varied units were checked; run the varied test with \ + HELM_MUL_BLESS=1 and re-run bridge/dump.sh" + ); } /// The designs a rounding rule decides, and whether the rule is the right one. diff --git a/crates/helm-bv/tests/mul/varied-cross-family.mul b/crates/helm-bv/tests/mul/varied-cross-family.mul new file mode 100644 index 0000000..eac9593 --- /dev/null +++ b/crates/helm-bv/tests/mul/varied-cross-family.mul @@ -0,0 +1,1200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/helm-bv/tests/mul/varied-damaged.mul b/crates/helm-bv/tests/mul/varied-damaged.mul new file mode 100644 index 0000000..532d5a1 --- /dev/null +++ b/crates/helm-bv/tests/mul/varied-damaged.mul @@ -0,0 +1,978 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/helm-bv/tests/mul/varied-empty.mul b/crates/helm-bv/tests/mul/varied-empty.mul new file mode 100644 index 0000000..fb2fa54 --- /dev/null +++ b/crates/helm-bv/tests/mul/varied-empty.mul @@ -0,0 +1,1879 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/helm-bv/tests/mul/varied-munitions.mul b/crates/helm-bv/tests/mul/varied-munitions.mul new file mode 100644 index 0000000..3160486 --- /dev/null +++ b/crates/helm-bv/tests/mul/varied-munitions.mul @@ -0,0 +1,1991 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/helm-bv/tests/mul/varied-refused.mul b/crates/helm-bv/tests/mul/varied-refused.mul new file mode 100644 index 0000000..d55fdd8 --- /dev/null +++ b/crates/helm-bv/tests/mul/varied-refused.mul @@ -0,0 +1,304 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/helm-bv/tests/varied.rs b/crates/helm-bv/tests/varied.rs new file mode 100644 index 0000000..e04e2c9 --- /dev/null +++ b/crates/helm-bv/tests/varied.rs @@ -0,0 +1,489 @@ +//! The fixtures for a unit somebody varied, and what a `.mul` may vary at all. +//! +//! A `.mtf` says what a design left the factory with. A `.mul` says what is in +//! every critical slot *now*, and that is how a player changes a unit without +//! designing one. What is easy to assume, and wrong, is that it can change +//! anything: `MULParser` calls `changeAmmoType` on a magazine and for every +//! other slot prints +//! +//! ```text +//! XML file expects ISERMediumLaser equipment at index 4 of location 4, +//! but Entity has Medium Laser there. +//! ``` +//! +//! and leaves the design alone. So a `.mul` varies **ammunition and nothing +//! else** - an omni pod swap is a different design, not a varied one - and +//! that rule is what most of these fixtures exist to hold. +//! +//! The fixtures are generated rather than written, because the interesting +//! coverage is breadth: every ammunition family in the library, several +//! munitions apiece, on designs that actually carry them. Regenerate them +//! deliberately: +//! +//! ```text +//! HELM_MUL_BLESS=1 HELM_MEGAMEK=/path/to/megamek \ +//! HELM_BRIDGE=/path/to/bridge-output \ +//! cargo test -p helm-bv --test varied -- --ignored +//! ``` +//! +//! then re-run `bridge/dump.sh` so `mul.jsonl` carries MegaMek's answers for +//! them, and `conformance.rs` will score them. +//! +//! Without `HELM_MUL_BLESS` the same test *checks* the committed fixtures +//! against what it would write, so a fixture edited by hand, or a generator +//! changed without regenerating, fails rather than drifting. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; + +use helm_core::{Catalogue, EquipmentEntry, Unit}; + +/// Where the fixtures live. +fn fixtures() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/mul") +} + +/// The library and the catalogue, or nothing when the environment has neither. +fn inputs() -> Option<(helm_unitfile::Library, Catalogue)> { + let mm = PathBuf::from(std::env::var("HELM_MEGAMEK").ok()?); + let bridge = PathBuf::from(std::env::var("HELM_BRIDGE").ok()?); + let zip = std::fs::File::open(mm.join("data/mekfiles/unit_files.zip")).ok()?; + let library = helm_unitfile::read_zip(zip).ok()?; + let catalogue = helm_bridge::read_catalogue(&bridge.join("equipment.jsonl")).ok()?; + Some((library, catalogue)) +} + +/// One magazine on one design. +struct Bin<'a> { + location: &'a str, + /// Counted from nought, the way everything but a `.mul` counts. + index: usize, + entry: &'a EquipmentEntry, +} + +/// A `.mul`'s word for a Mek's shape, which decides how its locations are +/// numbered. A tripod's ninth location and a quad's four legs are the reason +/// this cannot be assumed. +fn shape(unit: &Unit) -> &'static str { + let config = unit.config.as_deref().unwrap_or("").to_ascii_lowercase(); + if config.contains("tripod") { + "Tripod" + } else if config.contains("quad") { + "Quad" + } else { + "Biped" + } +} + +/// Which of a design's slots hold ammunition. +fn bins<'a>(unit: &'a Unit, catalogue: &'a Catalogue) -> Vec> { + let mut out = Vec::new(); + for (code, name) in helm_unitfile::locations_for(Some(shape(unit))) { + let _ = code; + let Some(slots) = unit.criticals.get(*name) else { + continue; + }; + for (index, line) in slots.iter().enumerate() { + // A superheavy writes two items either side of a pipe. Neither + // half is a magazine on any design that gets this far, and taking + // the first keeps the name a single lookup. + let first = line.split('|').next().unwrap_or(line); + let Some(entry) = catalogue.resolve(&helm_core::strip_slot_markers(first)) else { + continue; + }; + if entry.is_ammo() { + out.push(Bin { + location: name, + index, + entry, + }); + } + } + } + out +} + +/// The rounds that could go in a bin instead of what is in it. +/// +/// `same_rack` asks for what a quartermaster would actually load - inferno +/// rounds for an SRM 2 rather than for an SRM 4. The other form is the case +/// nobody would field and MegaMek allows anyway: `changeAmmoType` does not +/// check the family, so LRM 15 rounds go into an SRM 2 bin and are scored +/// there. +fn alternatives<'a>( + catalogue: &'a Catalogue, + bin: &EquipmentEntry, + same_rack: bool, +) -> Vec<&'a EquipmentEntry> { + let mut out: Vec<&EquipmentEntry> = catalogue + .entries() + .iter() + .filter(|e| e.is_ammo() && e.internal_name != bin.internal_name) + .filter(|e| { + if same_rack { + e.ammo_type == bin.ammo_type && e.rack_size == bin.rack_size + } else { + e.ammo_type.is_some() && e.ammo_type != bin.ammo_type + } + }) + // A round with no battle value of its own would make a fixture that + // passes whatever the rule is. + .filter(|e| e.battle_value.is_some_and(|bv| bv > 0.0)) + .collect(); + out.sort_by(|a, b| a.internal_name.cmp(&b.internal_name)); + out.dedup_by(|a, b| a.internal_name == b.internal_name); + out +} + +/// One entity element, with whatever the caller wants said about its slots. +struct Entity { + chassis: String, + model: String, + shape: &'static str, + /// Location index to the slots written under it. + slots: BTreeMap>, + /// Location index to a front armour figure. + armor: BTreeMap, + note: String, +} + +impl Entity { + fn new(unit: &Unit, note: impl Into) -> Self { + // MegaMek looks a unit up by the chassis it displays, which for a Clan + // design includes the name the Inner Sphere gave it: a file saying + // `Black Hawk` finds nothing and `Black Hawk (Nova)` finds the Nova. + let chassis = match unit.clan_name.as_deref().map(str::trim) { + Some(clan) if !clan.is_empty() => format!("{} ({clan})", unit.chassis.trim()), + _ => unit.chassis.trim().to_string(), + }; + Entity { + chassis, + model: unit.model.clone(), + shape: shape(unit), + slots: BTreeMap::new(), + armor: BTreeMap::new(), + note: note.into(), + } + } + + /// The `.mul` index of a location, which is MegaMek's own constant for it. + fn location_of(&self, name: &str) -> Option { + helm_unitfile::locations_for(Some(self.shape)) + .iter() + .position(|(_, n)| *n == name) + } + + fn slot(&mut self, location: &str, index: usize, attributes: String) -> &mut Self { + if let Some(at) = self.location_of(location) { + self.slots.entry(at).or_default().push(format!( + " ", + index + 1 + )); + } + self + } + + /// A magazine holding something else. A `.mul` numbers slots from one. + fn load(&mut self, bin: &Bin, what: &EquipmentEntry) -> &mut Self { + let shots = what.shots.unwrap_or(1).max(1); + let attributes = format!( + " type=\"{}\" shots=\"{shots}\"", + escape(&what.internal_name) + ); + self.slot(bin.location, bin.index, attributes) + } + + fn named(&mut self, location: &str, index: usize, what: &str, shots: Option) -> &mut Self { + let attributes = match shots { + Some(n) => format!(" type=\"{}\" shots=\"{n}\"", escape(what)), + None => format!(" type=\"{}\"", escape(what)), + }; + self.slot(location, index, attributes) + } + + fn front_armor(&mut self, location: &str, points: i64) -> &mut Self { + if let Some(at) = self.location_of(location) { + self.armor.insert(at, points); + } + self + } + + fn write(&self, out: &mut String) { + // XML forbids a double hyphen inside a comment, and a MegaMek name + // is full of them - `LRM-15`, `Anti-Radiation`. + let _ = writeln!(out, " ", comment(&self.note)); + let _ = writeln!( + out, + " ", + escape(&self.chassis), + escape(&self.model), + self.shape + ); + out.push_str(" \n"); + let locations: BTreeSet = self + .slots + .keys() + .chain(self.armor.keys()) + .copied() + .collect(); + for at in locations { + let _ = writeln!(out, " "); + if let Some(points) = self.armor.get(&at) { + let _ = writeln!(out, " "); + } + for line in self.slots.get(&at).into_iter().flatten() { + out.push_str(line); + out.push('\n'); + } + out.push_str(" \n"); + } + out.push_str(" \n"); + } +} + +/// A note that can go inside an XML comment. Two hyphens together end one, +/// and MegaMek's names are full of them - `LRM-15 Anti-Radiation`. +fn comment(text: &str) -> String { + let mut out = text.replace("--", "-"); + while out.ends_with('-') { + out.pop(); + } + out +} + +/// XML's five, of which a MegaMek name realistically holds one. +fn escape(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +/// A whole file: the header that says it is generated, then the entities. +fn file_of(purpose: &str, entities: &[Entity]) -> String { + let mut out = String::from("\n\n"); + out.push_str("\n"); + for entity in entities { + entity.write(&mut out); + } + out.push_str("\n"); + out +} + +/// How many designs to take for each family of rounds, and how many munitions +/// to try in each bin. Breadth over depth: a second design carrying the same +/// ammunition tests the same rule again, and a fifth munition rarely does. +const DESIGNS_PER_FAMILY: usize = 3; +const MUNITIONS_PER_BIN: usize = 4; + +/// Write - or check - every generated fixture. +#[test] +#[ignore = "needs a MegaMek install and a bridge dump; set HELM_MEGAMEK and HELM_BRIDGE"] +fn the_generated_fixtures_are_what_the_generator_would_write() { + let Some((library, catalogue)) = inputs() else { + panic!("set HELM_MEGAMEK to a MegaMek install and HELM_BRIDGE to a bridge dump"); + }; + + // Sorted, so that the same library and the same catalogue give the same + // files however the reader happened to order them. + let mut meks: Vec<&Unit> = library.units.iter().filter(|u| u.is_mek()).collect(); + meks.sort_by_key(|u| u.display_name()); + + // One family of rounds to the designs that carry it, so the sweep covers + // ammunition rather than covering the designs that happen to sort first. + let mut by_family: BTreeMap> = BTreeMap::new(); + for unit in &meks { + for bin in bins(unit, &catalogue) { + let Some(family) = bin.entry.ammo_type.clone() else { + continue; + }; + let carriers = by_family.entry(family).or_default(); + if carriers.len() < DESIGNS_PER_FAMILY + && !carriers.iter().any(|u| std::ptr::eq(*u, *unit)) + { + carriers.push(unit); + } + } + } + + let mut munitions: Vec = Vec::new(); + let mut cross: Vec = Vec::new(); + let mut empty: Vec = Vec::new(); + let mut damaged: Vec = Vec::new(); + + for (family, carriers) in &by_family { + for unit in carriers { + let carried = bins(unit, &catalogue); + // The first bin of this family on this design. A second bin of the + // same rounds would be the same test. + let Some(bin) = carried + .iter() + .find(|b| b.entry.ammo_type.as_deref() == Some(family.as_str())) + else { + continue; + }; + + for what in alternatives(&catalogue, bin.entry, true) + .into_iter() + .take(MUNITIONS_PER_BIN) + { + let mut entity = Entity::new(unit, format!("{family}: {}", what.name)); + entity.load(bin, what); + munitions.push(entity); + } + + // Rounds from another launcher entirely, which MegaMek loads + // without complaint and scores as what they are. Stepped through + // the list rather than always the first, so the file holds a + // spread of wrong rounds instead of the same flamer ammunition a + // hundred and seventy times. + let elsewhere = alternatives(&catalogue, bin.entry, false); + if let Some(what) = elsewhere + .get(cross.len() * 7 % elsewhere.len().max(1)) + .copied() + { + let mut entity = Entity::new(unit, format!("{family} bin holding {}", what.name)); + entity.load(bin, what); + cross.push(entity); + } + + // Run dry, and run dry after being loaded with something else: an + // empty bin is worth nothing whatever is written on it. + let mut dry = Entity::new(unit, format!("{family}: the bin run dry")); + dry.named(bin.location, bin.index, &bin.entry.internal_name, Some(0)); + empty.push(dry); + if let Some(what) = alternatives(&catalogue, bin.entry, true).into_iter().next() { + let mut dry = Entity::new(unit, format!("{family}: {} and none left", what.name)); + dry.named(bin.location, bin.index, &what.internal_name, Some(0)); + empty.push(dry); + } + + // Varied and shot at. The centre torso holds more than a point of + // plate on every design in the library, so one point is damage + // rather than an impossibility. + if let Some(what) = alternatives(&catalogue, bin.entry, true).into_iter().next() { + let mut hurt = Entity::new(unit, format!("{family}: {} on a wreck", what.name)); + hurt.load(bin, what); + hurt.front_armor("Center Torso", 1); + damaged.push(hurt); + } + } + } + + // What a `.mul` may not vary, which is everything that is not a magazine. + // These read as an unvaried design or they are wrong. + let mut refused: Vec = Vec::new(); + for unit in meks.iter().take(400) { + let carried = bins(unit, &catalogue); + let Some(bin) = carried.first() else { continue }; + if refused.len() >= 40 { + break; + } + // A weapon named where a magazine sits. + let mut swap = Entity::new(unit, "a laser where the rounds are: refused"); + swap.named(bin.location, bin.index, "ISERMediumLaser", None); + refused.push(swap); + // Rounds named where a weapon sits. + if let Some((location, index)) = first_weapon(unit, &catalogue) { + let mut swap = Entity::new(unit, "rounds where the laser is: refused"); + swap.named(&location, index, &bin.entry.internal_name, Some(50)); + refused.push(swap); + } + // A name no MegaMek knows, which should cost nothing at all. + let mut unknown = Entity::new(unit, "a name from a MegaMek nobody has: ignored"); + unknown.named( + bin.location, + bin.index, + &format!("{} Wintermute", bin.entry.internal_name), + Some(50), + ); + refused.push(unknown); + } + + let written = [ + ( + "varied-munitions.mul", + "A magazine loaded with something other than what the design shipped with.\n\ + Every family of rounds in the library, on designs that carry them.", + munitions, + ), + ( + "varied-cross-family.mul", + "Rounds from a launcher the design does not have. Nobody would field this;\n\ + MegaMek's changeAmmoType does not check the family, so it is scored anyway.", + cross, + ), + ( + "varied-empty.mul", + "A magazine with nothing left in it, which is worth nothing and no longer\n\ + explodes - and the same bin loaded with something else and then emptied.", + empty, + ), + ( + "varied-refused.mul", + "What a .mul may not vary. MegaMek warns and leaves the design alone, so\n\ + every unit here is worth exactly what it is worth as built.", + refused, + ), + ( + "varied-damaged.mul", + "Varied and shot at: a swapped magazine on a machine down to a point of\n\ + plate, so the two mechanisms are exercised on one unit.", + damaged, + ), + ]; + + let bless = std::env::var("HELM_MUL_BLESS").is_ok(); + let mut wrong: Vec = Vec::new(); + let mut total = 0; + for (name, purpose, entities) in &written { + assert!(!entities.is_empty(), "{name} would be empty"); + total += entities.len(); + let text = file_of(purpose, entities); + let path = fixtures().join(name); + if bless { + std::fs::write(&path, &text).expect("write a fixture"); + println!("wrote {name}: {} units", entities.len()); + } else { + match std::fs::read_to_string(&path) { + Ok(committed) if committed == text => { + println!("{name}: {} units, unchanged", entities.len()) + } + Ok(_) => wrong.push(format!("{name} is not what the generator writes")), + Err(e) => wrong.push(format!("{name}: {e}")), + } + } + } + println!( + "{total} varied units over {} families of rounds", + by_family.len() + ); + assert!( + wrong.is_empty(), + "{}\nrun with HELM_MUL_BLESS=1 to regenerate, then re-run bridge/dump.sh", + wrong.join("\n") + ); +} + +/// The first slot on a design holding a weapon rather than a magazine, so a +/// fixture can name rounds somewhere they cannot go. +fn first_weapon(unit: &Unit, catalogue: &Catalogue) -> Option<(String, usize)> { + for (_, name) in helm_unitfile::locations_for(Some(shape(unit))) { + let slots = unit.criticals.get(*name)?; + for (index, line) in slots.iter().enumerate() { + let first = line.split('|').next().unwrap_or(line); + if let Some(entry) = catalogue.resolve(&helm_core::strip_slot_markers(first)) + && entry.is_weapon() + { + return Some(((*name).to_string(), index)); + } + } + } + None +} -- 2.51.2