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 +}