From 55794ea464b22e20fbb53c10fb32ad5727a7e1dc Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Mon, 17 Aug 2026 19:51:22 -0400 Subject: [PATCH] test(scenarios): gate the generated figures against what they came from Two committed artifacts that nothing checked against each other, which is the same shape as the report drift #240 guarded. unit-stats.json is collected from the two report.json files and neither regenerates the other, so a refreshed report left every weight on a force card describing a machine that may no longer be in the fight. The script has had a --check since it was written and nothing ran it; prek does now. It is hermetic and takes 46ms, unlike boards.json or megamek-text.ts, which need MegaMek and so cannot be gated this way. daily.json's per-unit battle values and its side totals come from different parts of the same report, and nothing compared them. Summing one against the other is also what says `mms index`'s positional join still lines up: the report lists a faction in MegaMek's order, and if that stopped matching the file's order the units would keep their names and swap their values - no total would move, every row would be wrong. Two of Bug Company's Stingers differ by 124 BV on skills alone. Both checked by mutation: a hand-edited weight fails the hook, a hand-edited per-unit value fails the test. --- prek.toml | 16 ++++++++++++ web/scripts/daily.test.mjs | 52 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/prek.toml b/prek.toml index 7d4799f..6e4697d 100644 --- a/prek.toml +++ b/prek.toml @@ -92,6 +92,22 @@ files = '^plan/.*\.md$' # touching plan/ is a human writing prose in these same files, and a hook that # edited the register underneath them would be changing the thing being # written rather than reporting on it. +# The unit table against the reports it is collected from. Both are committed +# and neither regenerates the other, so a report refreshed without re-running +# the script leaves every weight on a force card describing a machine that is +# no longer in the fight - and a stale figure renders exactly as well as a +# current one. +# +# Hermetic and instant: the inputs are the two report.json files in this repo, +# so unlike boards.json or megamek-text.ts this needs no MegaMek to check. +[[repos.hooks]] +id = "unit-stats" +name = "unit stats" +language = "system" +entry = "scripts/unit-stats.py --out web/src/content/unit-stats.json --check" +pass_filenames = false +files = '^(scripts/unit-stats\.py|web/src/content/unit-stats\.json|services/api/src/matches/(daily|scenarios)/report\.json)$' + [[repos.hooks]] id = "plan-register" name = "plan register" diff --git a/web/scripts/daily.test.mjs b/web/scripts/daily.test.mjs index 8339ce1..2bf54a1 100644 --- a/web/scripts/daily.test.mjs +++ b/web/scripts/daily.test.mjs @@ -304,6 +304,58 @@ test("every rule a challenge switches has MegaMek's own description", async () = } }); +test("each side's battle value is the sum of the units in it", () => { + // daily.json is generated from the scenarios and arena's report of what they + // are worth, and the per-unit figures and the side total come from different + // parts of that report. If they ever disagree, one of them is describing a + // fight nobody is playing - and a wrong battle value renders exactly as well + // as a right one, so nothing else would notice. + // + // This is also what says the positional join in `mms index` still lines up: + // the report lists a faction in MegaMek's own order, and if that stopped + // matching the file's order the units would keep their names and swap their + // values, which changes no total but every row. Two of Bug Company's + // Stingers differ by 124 BV on skills alone. + for (const challenge of challenges) { + for (const side of challenge.sides) { + const known = side.units.filter((unit) => unit.bv !== undefined); + if (known.length === 0) continue; + assert.equal( + known.length, + side.units.length, + `${challenge.slug} ${side.name}: some units carry a battle value and ` + + `some do not, so the total cannot be checked against them`, + ); + const summed = known.reduce((total, unit) => total + unit.bv, 0); + assert.equal( + summed, + side.bv, + `${challenge.slug} ${side.name}: the units add up to ${summed} but ` + + `the side says ${side.bv}`, + ); + } + } +}); + +test("a daily's crews are carried, because the battle values depend on them", () => { + // BV 2.0 multiplies by gunnery and piloting, so a figure without the skills + // that produced it cannot be checked by anything, here or by a reader. + for (const challenge of challenges) { + for (const side of challenge.sides) { + for (const unit of side.units) { + if (unit.bv === undefined) continue; + assert.equal( + typeof unit.gunnery, + "number", + `${challenge.slug} ${side.name} ${unit.name}: a battle value with ` + + `no gunnery behind it`, + ); + assert.equal(typeof unit.piloting, "number", `${unit.name} piloting`); + } + } + } +}); + test("a rule the card shows is one somebody chose to show", async () => { // Disclosure is a property of the lock, not a second kind of lock: a daily // pins its map and its planetary conditions too, and those are equally -- 2.51.2