From 762f6f98deb44e7f9675f9051cde13ffd73f3ffe Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 14 Aug 2026 23:50:35 -0400 Subject: [PATCH] feat(scenarios): a tool that loads a scenario and says what is in it ScenarioReport loads .mms files the way MatchHost does and prints JSON: the factions, their units, and what each side is worth. Battle value is not in the file - it is computed from the unit and its pilot's skills - so loading the scenario and asking MegaMek is the only honest way to know it. Generating a fight needs both halves: that it loads at all, and what it is worth. tests/scenario-load.sh runs it over every scenario in the image and checks the set that fails matches tests/scenarios-known-bad.txt. Eleven of fifty-one do not load today: seven name boards no release ships, two are Strategic BattleForce, and two are broken by our own portrait prune, whose comment says that cannot happen. The list is exact both ways, so a MegaMek bump that breaks a twelfth fails the build instead of a player's match. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I180c74e49c01d78b92a9855a390bf2027b620023 --- TODO.md | 12 +++ src/arena/ScenarioReport.java | 180 ++++++++++++++++++++++++++++++++++ tests/run.sh | 5 + tests/scenario-load.sh | 90 +++++++++++++++++ tests/scenarios-known-bad.txt | 38 +++++++ 5 files changed, 325 insertions(+) create mode 100644 src/arena/ScenarioReport.java create mode 100755 tests/scenario-load.sh create mode 100644 tests/scenarios-known-bad.txt diff --git a/TODO.md b/TODO.md index 19eeda2..ed994de 100644 --- a/TODO.md +++ b/TODO.md @@ -596,6 +596,18 @@ method are already in the repo. into the slot's player, or handed to the client the way the lounge's own "Load Unit List" works — and validation, since a manifest URL is trusted but the file it stages was authored by a player. +- [ ] **The portrait prune breaks two scenarios.** `megamek/prune-data.sh` + removes `data/images/portraits/{Male,Female}` on the stated grounds that + "a scenario that does name one degrades to the default rather than + failing". That holds for `Portrait.getBaseImage()` and not for the path + an MMSVersion 2 scenario takes: a `messages:` block naming a portrait + goes through `MessageDeserializer`, which throws. Kell Hounds' + DeathOfTheLegion and LoweringTheBoom do not load in this image for that + reason, and both load in a stock release. Found by + `tests/scenario-load.sh`. The fix is either keeping the ~30 portraits the + shipped scenarios name, or accepting it and correcting the comment - but + 252MB is the largest single block in the image, so measure before + keeping anything. - [ ] **Maps from the manifest.** `scenario.content` now carries a whole `.mms`, so a fight nobody baked into the image can be launched - but the boards that file names still have to be in this image, and a scenario naming one diff --git a/src/arena/ScenarioReport.java b/src/arena/ScenarioReport.java new file mode 100644 index 0000000..9eab765 --- /dev/null +++ b/src/arena/ScenarioReport.java @@ -0,0 +1,180 @@ +package arena; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +import megamek.common.Player; +import megamek.common.board.Board; +import megamek.common.compute.Compute; +import megamek.common.game.IGame; +import megamek.common.game.InGameObject; +import megamek.common.interfaces.IStartingPositions; +import megamek.common.loaders.MekSummaryCache; +import megamek.common.scenario.Scenario; +import megamek.common.scenario.ScenarioLoader; +import megamek.common.units.Entity; + +/** + * Load scenarios the way a match does, and print what is in them as JSON. + * + *

Two jobs, and the first one is the point: a scenario that loads here is a + * scenario {@link MatchHost} can launch. Every way an {@code .mms} can be wrong + * - a unit name no cache entry matches, a board file this image does not have, + * a faction line that parses to nothing - fails here, in a second, instead of + * failing a player's match. Anything generating scenarios has to be able to ask + * that question before a player is told the fight exists. + * + *

The second job is battle value. BV is not in the {@code .mms} file; it is + * computed from the unit and its pilot's skills, so the only honest way to know + * what a side is worth is to load the scenario and ask MegaMek. That number is + * what a daily challenge is scored against, and what the site shows before + * anybody commits to the fight. + * + *

+ * ScenarioReport data/scenarios/TrainingScenarios/1-FirstRun.mms
+ * ScenarioReport $(find data/scenarios -name '*.mms')
+ * 
+ * + *

Always a JSON array, one entry per file in the order given, so one run + * covers a whole library: the unit cache takes far longer to warm than a + * scenario takes to load, and a JVM per file turns a second of work into + * minutes. A file that does not load gets an {@code error} entry rather than + * ending the run, and the exit status is non-zero if any did - which is what + * makes this usable as a build gate. + */ +public final class ScenarioReport { + + private ScenarioReport() { + } + + public static void main(String[] args) throws Exception { + if (args.length == 0) { + System.err.println("usage: ScenarioReport [ ...]"); + System.exit(2); + } + + // The same warm-up MatchHost does. Entity construction reads the unit + // cache, so a scenario loaded before it is ready fails on units that + // are in fact present. + Compute.d6(); + MekSummaryCache cache = MekSummaryCache.getInstance(); + while (!cache.isInitialized()) { + Thread.sleep(250L); + } + + List reports = new ArrayList<>(); + boolean failed = false; + for (String arg : args) { + File file = new File(arg); + try { + reports.add(report(file)); + } catch (Exception e) { + failed = true; + // The loader's own message: it is the one that says which unit + // or which board file is the problem. + String message = e.getMessage() == null ? e.toString() : e.getMessage(); + reports.add(" {\"file\": " + quote(file.getPath()) + + ", \"error\": " + quote(message) + "}"); + System.err.println("[report] " + file + ": " + message); + } + } + + System.out.println("[\n" + String.join(",\n", reports) + "\n]"); + if (failed) { + System.exit(1); + } + } + + private static String report(File file) throws Exception { + if (!file.isFile()) { + throw new IllegalArgumentException("no such scenario"); + } + Scenario scenario = new ScenarioLoader(file).load(); + IGame game = scenario.createGame(); + + StringBuilder json = new StringBuilder(); + json.append(" {\n"); + json.append(" \"file\": ").append(quote(file.getPath())).append(",\n"); + json.append(" \"name\": ").append(quote(scenario.getName())).append(",\n"); + json.append(" \"description\": ").append(quote(scenario.getDescription())).append(",\n"); + json.append(" \"singlePlayer\": ").append(scenario.isSinglePlayer()).append(",\n"); + + Board board = game.getBoard(); + json.append(" \"board\": {\"width\": ").append(board.getWidth()) + .append(", \"height\": ").append(board.getHeight()).append("},\n"); + + json.append(" \"factions\": [\n"); + List players = game.getPlayersList(); + for (int i = 0; i < players.size(); i++) { + Player p = players.get(i); + // getStrength() is the unit's current battle value, which for a + // freshly loaded scenario is its full one - unless the file + // damaged it on purpose, in which case this is still the right + // number. + List owned = game.getInGameObjects().stream() + .filter(o -> o.getOwnerId() == p.getId()) + .toList(); + List units = new ArrayList<>(); + int bv = 0; + for (InGameObject o : owned) { + int unitBv = o.getStrength(); + bv += unitBv; + String name = (o instanceof Entity e) ? e.getShortName() : o.generalName(); + // The year the design was introduced, which is the only fact + // in here that says when a fight is set. Anything picking an + // era for a scenario should read this rather than infer one + // from the unit's name. + String year = (o instanceof Entity e) ? String.valueOf(e.getYear()) : "null"; + units.add(" {\"name\": " + quote(name) + + ", \"year\": " + year + + ", \"bv\": " + unitBv + "}"); + } + json.append(" {\"name\": ").append(quote(p.getName())) + .append(", \"team\": ").append(p.getTeam()) + .append(", \"start\": ").append(quote(startingPosition(p))) + .append(", \"bv\": ").append(bv) + .append(", \"units\": [\n").append(String.join(",\n", units)) + .append(units.isEmpty() ? "" : "\n").append(" ]}"); + json.append(i + 1 < players.size() ? ",\n" : "\n"); + } + json.append(" ]\n }"); + return json.toString(); + } + + /** + * The deployment edge as the scenario file spells it. The scenario carries + * a name ("S", "NW"); MegaMek stores the index it resolved to, so this + * turns it back. + */ + private static String startingPosition(Player p) { + String[] names = IStartingPositions.START_LOCATION_NAMES; + int i = p.getStartingPos(); + return (i >= 0 && i < names.length) ? names[i] : "Custom"; + } + + private static String quote(String s) { + if (s == null) { + return "null"; + } + StringBuilder out = new StringBuilder("\""); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"' -> out.append("\\\""); + case '\\' -> out.append("\\\\"); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + default -> { + if (c < 0x20) { + out.append(String.format("\\u%04x", (int) c)); + } else { + out.append(c); + } + } + } + } + return out.append('"').toString(); + } +} diff --git a/tests/run.sh b/tests/run.sh index d6a4b29..78d2640 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -112,6 +112,11 @@ if want compile; then check "arena.jar compiles against stock MegaMek" \ env OUT_JAR=/tmp/arena-test.jar ./src/build-jar.sh check "benchmark harness compiles" ./tests/build.sh + # After the jar, because it loads every scenario with it. One JVM for the + # whole library: warming the unit cache costs far more than loading a + # scenario does. + check "every scenario in the image loads, or is known not to" \ + env ARENA_JAR=/tmp/arena-test.jar ./tests/scenario-load.sh if [ -n "${PATCHED_JAR:-}" ] && [ -f "$PATCHED_JAR" ]; then check "patched jar differs from stock in exactly the patched classes" \ ./megamek/verify-patches.sh diff --git a/tests/scenario-load.sh b/tests/scenario-load.sh new file mode 100755 index 0000000..ba923e4 --- /dev/null +++ b/tests/scenario-load.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Load every scenario in the image and check the set that fails is the set we +# know about. +# +# MM_HOME extracted MegaMek release, with data/scenarios and MegaMek.jar +# JAVA_HOME a JDK +# ARENA_JAR arena.jar; built into a temporary file if unset +# +# Why a gate and not a fix: a scenario that does not load is a launch failure +# for whoever picked it, and eleven of the fifty-one in this image do not load +# today - some because upstream ships a scenario naming a board it does not +# ship, some because of what megamek/prune-data.sh removes. Neither is fixable +# here in passing. What is fixable is finding out when the set changes: a +# MegaMek bump that breaks a twelfth, or a prune that breaks one, should fail +# this build rather than a player's match. +# +# The list is exact in both directions. A scenario that starts loading is as +# much of a signal as one that stops - it means upstream fixed something and +# the entry should go. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +KNOWN="$ROOT/tests/scenarios-known-bad.txt" + +: "${MM_HOME:?MM_HOME is required}" +: "${JAVA_HOME:?JAVA_HOME is required}" +[ -f "$KNOWN" ] || { echo "ERROR: no known-bad list at $KNOWN" >&2; exit 1; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +JAR="${ARENA_JAR:-}" +if [ -z "$JAR" ]; then + JAR="$TMP/arena.jar" + OUT_JAR="$JAR" CLASSES="$TMP/classes" "$ROOT/src/build-jar.sh" >/dev/null +fi + +CP="$MM_HOME/MegaMek.jar" +for jar in "$MM_HOME"/lib/*.jar; do CP="$CP:$jar"; done +CP="$CP:$JAR" + +# Relative paths, run from MM_HOME: MegaMek resolves its data directory against +# the working directory, and the report names each file back as it was given - +# which is what the known-bad list holds. +mapfile -t SCENARIOS < <(cd "$MM_HOME" && find data/scenarios -name '*.mms' | sort) +[ "${#SCENARIOS[@]}" -gt 0 ] || { echo "ERROR: no scenarios under $MM_HOME/data/scenarios" >&2; exit 1; } + +# Non-zero exit just means something failed to load, which is what is being +# measured; the report is on stdout either way. +( cd "$MM_HOME" && "$JAVA_HOME/bin/java" -Djava.awt.headless=true -cp "$CP" \ + arena.ScenarioReport "${SCENARIOS[@]}" ) > "$TMP/report.json" 2>"$TMP/report.err" || true + +python3 - "$TMP/report.json" "$KNOWN" "${#SCENARIOS[@]}" <<'PY' +import json, sys + +report_path, known_path, expected_count = sys.argv[1], sys.argv[2], int(sys.argv[3]) + +try: + with open(report_path) as fh: + reports = json.load(fh) +except (OSError, ValueError) as exc: + sys.exit(f"ERROR: the report is not readable JSON ({exc}); the run itself failed") + +if len(reports) != expected_count: + sys.exit(f"ERROR: reported on {len(reports)} scenarios, expected {expected_count}") + +failed = {r["file"] for r in reports if "error" in r} +reasons = {r["file"]: r["error"] for r in reports if "error" in r} + +with open(known_path) as fh: + known = { + line.strip() + for line in fh + if line.strip() and not line.lstrip().startswith("#") + } + +new = sorted(failed - known) +fixed = sorted(known - failed) + +for path in new: + print(f" BROKE {path}\n {reasons[path]}") +for path in fixed: + print(f" FIXED {path} now loads; drop it from {known_path}") + +if new or fixed: + sys.exit(f"{len(new)} newly broken, {len(fixed)} no longer broken") + +print(f" {len(reports) - len(failed)}/{len(reports)} scenarios load; " + f"{len(failed)} known-bad, unchanged") +PY diff --git a/tests/scenarios-known-bad.txt b/tests/scenarios-known-bad.txt new file mode 100644 index 0000000..def0e36 --- /dev/null +++ b/tests/scenarios-known-bad.txt @@ -0,0 +1,38 @@ +# Scenarios in this image that do not load, and why. tests/scenario-load.sh +# checks this list is exactly the set that fails: a scenario that starts or +# stops loading fails the build until this file is updated to match. +# +# Established against mm0.51.0-sur26.4.7 on 2026-08-15. +# +# None of these are reachable through headquarters' catalog, which is curated +# by hand; the point of the list is that nothing new joins it unnoticed. + +# --- boards upstream does not ship ------------------------------------------ +# The scenario names a board file that is in no MegaMek release. Nothing on our +# side can fix these; they are broken in stock MegaMek too. +data/scenarios/DownOnOurLevel/DownOnOurLevel.mms +data/scenarios/FistAndDragon.mms +data/scenarios/Lucho/Citadel_Blitz_multiplayer.mms +data/scenarios/Lucho/Citadel_Blitz_oneonone.mms +data/scenarios/Lucho/Last_Stand_at_Guanabara.mms +data/scenarios/Lucho/Uninvited_Guests.mms + +# A board named with a backslash - "Map Set 3\16x17 Rolling Hills 2" - which +# resolves literally on Linux and so names a file that cannot exist. +data/scenarios/OntheProwl/OntheProwl.mms + +# --- portraits megamek/prune-data.sh removes -------------------------------- +# Both are MMSVersion 2 scenarios whose `messages:` blocks embed a crew +# portrait, and MessageDeserializer throws when the image is missing rather +# than falling back. The prune's own comment says a scenario naming a portrait +# "degrades to the default rather than failing", which is true of +# Portrait.getBaseImage() and not of this path. See TODO.md. +data/scenarios/Kell Hounds/DeathOfTheLegion/DeathOfTheLegion.mms +data/scenarios/Kell Hounds/LoweringTheBoom/LoweringTheBoom.mms + +# --- a different game ------------------------------------------------------- +# Strategic BattleForce scenarios. createGame() builds a Total Warfare game and +# casts the options, which SBFRuleOptions is not. Not a bug on our side and not +# a game arena hosts. +data/scenarios/Examples/SBFTestScenario/TestSBF.mms +data/scenarios/Examples/SBFTestScenario/TestSBF2.mms -- 2.51.2