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