From 144eeeea83dc833f174eac007aacb07d96449a76 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 19 Aug 2026 10:21:18 -0400 Subject: [PATCH] feat(result): record what happened to every machine MatchHost writes a units array per player at victory: armour, structure and critical slots per location, the equipment each carried, ammunition, the crew, and MegaMek's own reading of how hurt the machine is. Vehicles carry their own criticals and infantry its remaining troopers. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I5ae6cf35d767295ff421a81ab89c177b953deed3 --- src/arena/MatchHost.java | 13 +- src/arena/UnitReport.java | 515 +++++++++++++++++++++++++++ tests/run.sh | 4 + tests/src/check/UnitReportCheck.java | 94 +++++ tests/unit-report.sh | 144 ++++++++ 5 files changed, 769 insertions(+), 1 deletion(-) create mode 100644 src/arena/UnitReport.java create mode 100644 tests/src/check/UnitReportCheck.java create mode 100755 tests/unit-report.sh diff --git a/src/arena/MatchHost.java b/src/arena/MatchHost.java index 5ad446d..eec0a28 100644 --- a/src/arena/MatchHost.java +++ b/src/arena/MatchHost.java @@ -20,6 +20,7 @@ import megamek.client.bot.princess.BehaviorSettings; import megamek.client.bot.princess.Princess; import megamek.common.Player; import megamek.common.compute.Compute; +import megamek.common.units.Entity; import megamek.common.enums.GamePhase; import megamek.common.game.Game; import megamek.common.game.IGame; @@ -590,7 +591,17 @@ public final class MatchHost { .append(", \"unitsRemaining\": ").append(remaining) .append(", \"unitsStart\": ").append(p.getInitialEntityCount()) .append(", \"bvRemaining\": ").append(bvLeft) - .append(", \"bvStart\": ").append(bvStart).append("}"); + .append(", \"bvStart\": ").append(bvStart); + // What each machine ended up as, which is the difference between + // a result that says a force lost and one that says how. Written + // for every seat including the observer's, which owns nothing and + // therefore writes an empty list. + List units = UnitReport.unitsOf(live, p.getId()); + json.append(", \"units\": ["); + for (int u = 0; u < units.size(); u++) { + json.append(u > 0 ? ", " : "").append(UnitReport.unit(units.get(u))); + } + json.append("]}"); json.append(i + 1 < players.size() ? ",\n" : "\n"); } json.append(" ]\n}\n"); diff --git a/src/arena/UnitReport.java b/src/arena/UnitReport.java new file mode 100644 index 0000000..4127db3 --- /dev/null +++ b/src/arena/UnitReport.java @@ -0,0 +1,515 @@ +package arena; + +import java.util.ArrayList; +import java.util.List; + +import megamek.common.CriticalSlot; +import megamek.common.equipment.IArmorState; +import megamek.common.equipment.AmmoMounted; +import megamek.common.equipment.ArmorType; +import megamek.common.equipment.EquipmentType; +import megamek.common.equipment.Mounted; +import megamek.common.game.Game; +import megamek.common.game.IGame; +import megamek.common.interfaces.IEntityRemovalConditions; +import megamek.common.units.Crew; +import megamek.common.units.Entity; +import megamek.common.units.Infantry; +import megamek.common.units.Mek; +import megamek.common.units.Tank; + +/** + * What was left of every machine when the game ended, as JSON. + * + *

The result document used to carry two numbers per force - units left and + * battle value left - which is enough to say who won and nothing about what + * the fight was. This is the rest of it: each unit, its crew, the armour and + * structure on every location, and every critical slot that took a hit. It is + * read by the end-of-match report and by the public page a player shares, and + * it is the seam {@code plan/after-action.md} asks for in headquarters. + * + *

Read from the live game at victory, which is the only moment all of it + * exists: the server holds both the units still standing and the ones in its + * graveyard, and the container is minutes from being deleted. + * + *

Every class reports its critical slots, not only Meks: a vehicle carries + * twenty-five a location and its equipment sits in them the same way. What a + * Mek has and the others do not is names for its system slots, which + * {@code Mek.getSystemName} gives; a system slot on anything else is reported + * as one without inventing a word for it. + * + *

Each class also carries what only it has - a vehicle's locked turret, + * motive damage and crew hits, a platoon's remaining troopers - because that + * is the half of those fights the slots do not hold. + * + *

Hand-rolled JSON for the same reason {@link MatchHost} writes its own: + * this runs inside the match container, and a JSON dependency here is a + * dependency in the image. + */ +final class UnitReport { + + private UnitReport() { + } + + /** + * Every unit that fought under one player, in the order the game holds + * them: the ones still on the board, then the ones that left it. + * + *

The graveyard is not optional. A destroyed machine is removed from + * play, so a report built from the board alone describes the winner's + * force and says nothing about what it beat - and "what happened to it" is + * most of what there is to read about the losing side. + */ + static List unitsOf(IGame live, int playerId) { + List out = new ArrayList<>(); + // Both lists are on Game rather than on IGame, which is the same + // reason MatchHost casts before reading the winner: anything that is + // not a Game reports no units rather than failing the result, and the + // counts beside them are still worth having. + if (!(live instanceof Game game)) { + return out; + } + for (Entity entity : game.getEntitiesVector()) { + if (entity.getOwnerId() == playerId) { + out.add(entity); + } + } + for (Entity entity : game.getOutOfGameEntitiesVector()) { + if (entity.getOwnerId() == playerId) { + out.add(entity); + } + } + return out; + } + + /** One unit, as one JSON object. */ + static String unit(Entity e) { + StringBuilder json = new StringBuilder(); + json.append("{\"id\": ").append(e.getId()) + .append(", \"name\": ").append(quote(e.getShortName())) + .append(", \"chassis\": ").append(quote(e.getChassis())) + .append(", \"model\": ").append(quote(e.getModel())) + .append(", \"kind\": ").append(quote(kind(e))) + .append(", \"tons\": ").append(round(e.getWeight())) + .append(", \"bv\": ").append(e.getInitialBV()) + .append(", \"status\": ").append(quote(status(e))) + .append(", \"armorPercent\": ").append(percent(e.getArmorRemainingPercent())) + // MegaMek's own reading of how badly hurt the machine is, and + // the one thing in this document a player does not have to be a + // record-sheet reader to use. It is not a fraction of anything: + // "crippled" is a rules state about what the machine can still + // do, which is why it is asked for rather than derived from the + // armour left. + .append(", \"damage\": ").append(quote(damage(e))) + .append(", \"crippled\": ").append(e.isCrippled()) + .append(", \"heat\": ").append(Math.max(e.getHeat(), 0)) + .append(", \"heatCapacity\": ").append(Math.max(e.getHeatCapacity(), 0)) + .append(", \"crew\": ").append(crew(e.getCrew())) + .append(", \"ammo\": ").append(ammo(e)) + .append(", \"equipment\": ").append(equipment(e)) + .append(", \"vehicle\": ").append(vehicle(e)) + .append(", \"infantry\": ").append(infantry(e)) + .append(", \"locations\": ["); + + for (int loc = 0; loc < e.locations(); loc++) { + if (loc > 0) { + json.append(", "); + } + json.append(location(e, loc)); + } + json.append("]}"); + return json.toString(); + } + + /** + * One location: what armour and structure is on it, and what is in it. + * + *

Armour is reported as the pair MegaMek keeps - what is left and what + * it started with - rather than as a fraction, because a location's shape + * on a record sheet is the count of its pips. MegaMek's negative values + * are states rather than numbers ({@code ARMOR_DESTROYED}, + * {@code ARMOR_DOOMED}, {@code ARMOR_NA}), and they come out as zero with + * the state said in {@code destroyed} - a reader that treats -3 as an + * amount draws a bar pointing the wrong way. + */ + private static String location(Entity e, int loc) { + StringBuilder json = new StringBuilder(); + json.append("{\"abbr\": ").append(quote(e.getLocationAbbr(loc))) + .append(", \"name\": ").append(quote(e.getLocationName(loc))) + .append(", \"armor\": ").append(amount(e.getArmor(loc))) + .append(", \"armorMax\": ").append(amount(e.getOArmor(loc))) + .append(", \"internal\": ").append(amount(e.getInternal(loc))) + .append(", \"internalMax\": ").append(amount(e.getOInternal(loc))) + .append(", \"destroyed\": ").append(e.getInternal(loc) == IArmorState.ARMOR_DESTROYED + || e.getInternal(loc) == IArmorState.ARMOR_DOOMED) + .append(", \"blownOff\": ").append(e.isLocationBlownOff(loc)); + // Rear armour only where there is any: every location carrying a null + // pair would double the size of this document to say "no" eight times + // per unit. + if (e.hasRearArmor(loc)) { + json.append(", \"rear\": ").append(amount(e.getArmor(loc, true))) + .append(", \"rearMax\": ").append(amount(e.getOArmor(loc, true))); + } + // Every unit type that has slots reports them - a vehicle carries 25 + // per location and its equipment sits in them the same way a Mek's + // does. A location with none writes an empty list rather than being + // silent about it, so a reader can tell "nothing there" from "this + // class does not work that way". + json.append(", \"slots\": [").append(slots(e, loc)).append("]"); + json.append("}"); + return json.toString(); + } + + /** + * The critical slots of one location, in the order they sit on the record + * sheet. + * + *

Empty slots are reported as empty rather than skipped: which slot a + * hit landed in is a position in a column, and a list with the gaps taken + * out moves everything below the gap up a line. + */ + private static String slots(Entity e, int loc) { + StringBuilder json = new StringBuilder(); + int count = e.getNumberOfCriticalSlots(loc); + for (int i = 0; i < count; i++) { + if (i > 0) { + json.append(", "); + } + CriticalSlot slot = e.getCritical(loc, i); + if (slot == null) { + json.append("null"); + continue; + } + json.append("{\"name\": ").append(quote(slotName(e, slot))) + .append(", \"hit\": ").append(slot.isHit()) + .append(", \"destroyed\": ").append(slot.isDestroyed()) + .append(", \"breached\": ").append(slot.isBreached()) + .append(", \"missing\": ").append(slot.isMissing()) + .append("}"); + } + return json.toString(); + } + + /** + * What a slot holds, said the way the unit display says it. + * + *

System names are the unit's own - a Mek's gyro and cockpit lines + * depend on which kind it has, which is why this asks the Mek rather than + * indexing a table. Equipment names itself. + */ + private static String slotName(Entity e, CriticalSlot slot) { + if (slot.getType() == CriticalSlot.TYPE_EQUIPMENT) { + Mounted mount = slot.getMount(); + return mount == null ? "Equipment" : mount.getName(); + } + if (e instanceof Mek mek) { + String name = mek.getSystemName(slot.getIndex()); + return name == null ? "System" : name; + } + return "System"; + } + + /** + * The crew, and how much of the machine's trouble is theirs. + * + *

Skills as well as hits: battle value is computed from them, so a + * force worth more than its tonnage suggests is explained here and nowhere + * else in the document. + */ + private static String crew(Crew crew) { + if (crew == null) { + return "null"; + } + StringBuilder json = new StringBuilder(); + json.append("{\"name\": ").append(quote(crew.getName())) + .append(", \"gunnery\": ").append(crew.getGunnery()) + .append(", \"piloting\": ").append(crew.getPiloting()) + .append(", \"hits\": ").append(crew.getHits()) + .append(", \"dead\": ").append(crew.isDead()) + .append(", \"ejected\": ").append(crew.isEjected()) + .append("}"); + return json.toString(); + } + + /** + * What became of the unit. + * + *

MegaMek keeps this in two places and they disagree usefully: a unit + * still on the board is described by its own destroyed flag, and one that + * has left has a removal condition saying why it left. A machine that + * retreated intact and one that was blown apart are both "not on the + * board" and are not the same story. + */ + private static String status(Entity e) { + int removal = e.getRemovalCondition(); + switch (removal) { + case IEntityRemovalConditions.REMOVE_IN_RETREAT: + return "retreated"; + case IEntityRemovalConditions.REMOVE_PUSHED: + return "pushed"; + case IEntityRemovalConditions.REMOVE_CAPTURED: + return "captured"; + case IEntityRemovalConditions.REMOVE_SALVAGEABLE: + return "salvage"; + case IEntityRemovalConditions.REMOVE_EJECTED: + return "ejected"; + case IEntityRemovalConditions.REMOVE_DEVASTATED: + return "devastated"; + case IEntityRemovalConditions.REMOVE_NEVER_JOINED: + return "never joined"; + default: + return e.isDestroyed() ? "destroyed" : "active"; + } + } + + /** + * Everything the machine carries, wherever it sits. + * + * The critical slots hold this too, for a unit type that has them - but a + * slot list is a record of where damage landed, and this is the list of + * what the machine *is*: its weapons, its heat sinks, its equipment, each + * where it was mounted and whether it still works. It is also the only + * such list for a class whose slots are empty. + * + * Ammunition carries what is left in it, because a launcher with no + * rounds behind it is a different machine from one with a full bin. + */ + private static String equipment(Entity e) { + StringBuilder json = new StringBuilder("["); + boolean first = true; + for (Mounted mount : e.getEquipment()) { + if (mount == null) { + continue; + } + // The armour and the internal structure are mounted equipment as + // far as MegaMek is concerned, and are not equipment as far as a + // record sheet is concerned - they are the diagram. Left in, a + // Griffin's list opens with two lines both saying "Standard". + if (mount.getType() instanceof ArmorType + || EquipmentType.isStructureType(mount.getType())) { + continue; + } + if (!first) { + json.append(", "); + } + first = false; + json.append("{\"name\": ").append(quote(mount.getName())) + .append(", \"loc\": ").append(quote(locationAbbr(e, mount.getLocation()))) + .append(", \"hit\": ").append(mount.isHit()) + .append(", \"destroyed\": ").append(mount.isDestroyed()); + if (mount instanceof AmmoMounted bin) { + json.append(", \"shots\": ").append(Math.max(bin.getBaseShotsLeft(), 0)) + .append(", \"shotsMax\": ").append(fullLoad(bin)); + } + json.append("}"); + } + return json.append("]").toString(); + } + + /** A location's short name, or nothing for equipment mounted nowhere. */ + private static String locationAbbr(Entity e, int loc) { + if (loc < 0 || loc >= e.locations()) { + return ""; + } + String abbr = e.getLocationAbbr(loc); + return abbr == null ? "" : abbr; + } + + /** + * What is true about a vehicle and about nothing else. + * + * A tank's worst damage is not in its critical slots: a locked turret, a + * wrecked drive train, a hit driver. MegaMek keeps each of those as its + * own flag, and a record of a vehicle fight that leaves them out is + * missing the half that decided it. + */ + private static String vehicle(Entity e) { + if (!(e instanceof Tank tank)) { + return "null"; + } + StringBuilder stabilisers = new StringBuilder("["); + boolean first = true; + for (int loc = 0; loc < tank.locations(); loc++) { + if (tank.isStabiliserHit(loc)) { + if (!first) { + stabilisers.append(", "); + } + first = false; + stabilisers.append(quote(locationAbbr(tank, loc))); + } + } + stabilisers.append("]"); + return "{\"turretLocked\": " + tank.isTurretLocked(tank.getLocTurret()) + + ", \"motiveDamage\": " + tank.getMotiveDamage() + + ", \"driverHit\": " + tank.isDriverHit() + + ", \"commanderHit\": " + tank.isCommanderHit() + + ", \"stabilisers\": " + stabilisers + "}"; + } + + /** + * What is true about infantry and about nothing else: how many of them + * are left. A platoon's armour is its people. + */ + private static String infantry(Entity e) { + if (!(e instanceof Infantry troops)) { + return "null"; + } + return "{\"troopers\": " + Math.max(troops.getShootingStrength(), 0) + + ", \"troopersMax\": " + Math.max(troops.getOriginalTrooperCount(), 0) + "}"; + } + + /** + * How hurt the machine is, in MegaMek's own terms. + * + * `getDamageLevel` is what the game itself uses to describe a unit, and + * it is a rules judgement rather than an average: crippled means the + * machine can no longer fight, whatever its armour says. Reported as the + * word rather than the number so a reader never has to know that 4 is + * worse than 3. + */ + private static String damage(Entity e) { + switch (e.getDamageLevel()) { + case Entity.DMG_CRIPPLED: + return "crippled"; + case Entity.DMG_HEAVY: + return "heavy"; + case Entity.DMG_MODERATE: + return "moderate"; + case Entity.DMG_LIGHT: + return "light"; + default: + return "none"; + } + } + + /** + * What the machine has left to shoot. + * + * The bins are in the critical slots already, but "LRM 10 Ammo" in a slot + * says the bin is there and nothing about whether it is empty - and a + * force that walked away dry is a different result from one that walked + * away loaded. Named per bin because two bins of the same type are two + * different amounts. + */ + private static String ammo(Entity e) { + StringBuilder json = new StringBuilder("["); + boolean first = true; + for (AmmoMounted bin : e.getAmmo()) { + if (!first) { + json.append(", "); + } + first = false; + json.append("{\"name\": ").append(quote(bin.getName())) + .append(", \"loc\": ").append(quote(e.getLocationAbbr(bin.getLocation()))) + .append(", \"shots\": ").append(Math.max(bin.getBaseShotsLeft(), 0)) + .append(", \"shotsMax\": ").append(fullLoad(bin)) + .append(", \"destroyed\": ").append(bin.isDestroyed()) + .append("}"); + } + return json.append("]").toString(); + } + + /** + * How many shots the bin holds when it is full. + * + * `getOriginalShots` is only set where something set it - a unit loaded + * from a MUL, an ammo swap - and is zero on a bin that came straight off + * the scenario's unit, which is most of them. The ammo type's own count + * is what a full bin is, and it is what the record sheet prints. + */ + private static int fullLoad(AmmoMounted bin) { + int original = bin.getOriginalShots(); + if (original > 0) { + return original; + } + return bin.getType() == null ? 0 : Math.max(bin.getType().getShots(), 0); + } + + /** + * The broad class of machine, for a reader that has to draw it. + * + *

Deliberately coarse. This is what picks a silhouette and decides + * whether a record sheet's worth of locations is worth drawing; the + * unit's own name is what says what it actually is. + */ + private static String kind(Entity e) { + // The three classes with a record sheet of their own are asked by + // type rather than by name: a VTOL is a Tank and a battle armour + // squad is Infantry, and both would otherwise be read off a class + // name that says neither. + if (e instanceof Mek) { + return "mek"; + } + if (e instanceof Infantry) { + return e.getClass().getSimpleName().toLowerCase(java.util.Locale.ROOT) + .contains("battlearmor") ? "battle armor" : "infantry"; + } + if (e instanceof Tank) { + return e.getClass().getSimpleName().toLowerCase(java.util.Locale.ROOT) + .contains("vtol") ? "vtol" : "vehicle"; + } + String simple = e.getClass().getSimpleName().toLowerCase(java.util.Locale.ROOT); + if (simple.contains("infantry") || simple.contains("battlearmor")) { + return simple.contains("battlearmor") ? "battle armor" : "infantry"; + } + if (simple.contains("protomek")) { + return "protomek"; + } + if (simple.contains("dropship") || simple.contains("jumpship") + || simple.contains("warship") || simple.contains("smallcraft")) { + return "spacecraft"; + } + if (simple.contains("aero") || simple.contains("fighter") || simple.contains("vtol")) { + return simple.contains("vtol") ? "vtol" : "aerospace"; + } + if (simple.contains("tank") || simple.contains("vehicle")) { + return "vehicle"; + } + return "unit"; + } + + /** MegaMek's negative armour states are not amounts; they read as none. */ + private static int amount(int value) { + return Math.max(value, 0); + } + + /** 0-100, from MegaMek's fraction. Negative means "does not apply". */ + private static int percent(double fraction) { + if (fraction < 0) { + return 0; + } + return (int) Math.round(fraction * 100); + } + + /** Tonnage to one decimal place, which is as fine as MegaMek's ever is. */ + private static String round(double tons) { + return String.format(java.util.Locale.ROOT, "%.1f", tons); + } + + 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 -> { + // Control characters would make the document unparseable, + // and a unit name is whatever the scenario file said. + if (c < 0x20) { + out.append(String.format(java.util.Locale.ROOT, "\\u%04x", (int) c)); + } else { + out.append(c); + } + } + } + } + return out.append('"').toString(); + } +} diff --git a/tests/run.sh b/tests/run.sh index 78d2640..67b5221 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -117,6 +117,10 @@ if want compile; then # 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 + # Same jar, one scenario, damage applied by hand: the per-unit half of the + # result document is read back for the damage that was put in. + check "a damaged machine reports its damage" \ + env ARENA_JAR=/tmp/arena-test.jar ./tests/unit-report.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/src/check/UnitReportCheck.java b/tests/src/check/UnitReportCheck.java new file mode 100644 index 0000000..363a1ce --- /dev/null +++ b/tests/src/check/UnitReportCheck.java @@ -0,0 +1,94 @@ +package arena; + +import java.io.File; +import java.util.List; + +import megamek.common.CriticalSlot; +import megamek.common.compute.Compute; +import megamek.common.game.Game; +import megamek.common.game.IGame; +import megamek.common.loaders.MekSummaryCache; +import megamek.common.scenario.Scenario; +import megamek.common.scenario.ScenarioLoader; +import megamek.common.units.Entity; +import megamek.common.units.Mek; + +/** + * Damage a scenario's first Mek by hand and print what {@link UnitReport} + * makes of it, as one JSON document. + * + *

+ * UnitReportCheck data/scenarios/TrainingScenarios/1-FirstRun.mms
+ * 
+ * + *

In package {@code arena} so it can call the report directly. The damage + * below is applied to the loaded game rather than played for: a match that + * actually shoots a Mek's arm off takes minutes and a container, and what is + * being checked here is the shape of the document and the reading of MegaMek's + * state - a blown-off location, a destroyed one, a hit critical slot and a + * wounded pilot all coming out as themselves. + * + *

{@code tests/shell/test-unit-report.sh} is what asserts against it. + */ +public final class UnitReportCheck { + + private UnitReportCheck() { + } + + public static void main(String[] args) throws Exception { + if (args.length != 1) { + System.err.println("usage: UnitReportCheck "); + System.exit(2); + } + + Compute.d6(); + MekSummaryCache cache = MekSummaryCache.getInstance(); + while (!cache.isInitialized()) { + Thread.sleep(250L); + } + + ScenarioLoader loader = new ScenarioLoader(new File(args[0])); + Scenario scenario = loader.load(); + IGame game = scenario.createGame(); + + Entity mek = null; + for (Entity e : ((Game) game).getEntitiesVector()) { + if (e instanceof Mek) { + mek = e; + break; + } + } + if (mek == null) { + System.err.println("no Mek in " + args[0]); + System.exit(1); + } + + // A torso stripped to structure, an arm blown off, a hit critical slot + // and a pilot who took one. Between them these cover every branch the + // report has: an amount, a state MegaMek stores as a negative number, + // a slot flag and the crew. + int torso = Mek.LOC_CENTER_TORSO; + mek.setArmor(0, torso); + mek.setInternal(3, torso); + for (int i = 0; i < mek.getNumberOfCriticalSlots(torso); i++) { + CriticalSlot slot = mek.getCritical(torso, i); + if (slot != null) { + slot.setHit(true); + slot.setDestroyed(true); + break; + } + } + mek.setLocationBlownOff(Mek.LOC_LEFT_ARM, true); + mek.getCrew().setHits(2, 0); + + // Every unit in the scenario, not just the damaged one: the sheet a + // reader gets depends on what the machine is, and a scenario with a + // tank or a platoon in it is how the other layouts get looked at. + StringBuilder out = new StringBuilder("{\"units\": ["); + List all = ((Game) game).getEntitiesVector(); + for (int i = 0; i < all.size(); i++) { + out.append(i > 0 ? ", " : "").append(UnitReport.unit(all.get(i))); + } + System.out.println(out.append("]}")); + } +} diff --git a/tests/unit-report.sh b/tests/unit-report.sh new file mode 100755 index 0000000..94a4f43 --- /dev/null +++ b/tests/unit-report.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# What the result document says about one damaged machine. +# +# 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 +# +# src/arena/UnitReport.java reads MegaMek's end-of-match state and writes the +# per-unit half of result.json - the armour and structure on every location, +# every critical slot, and what became of the machine and its pilot. Nothing +# downstream can tell a misreading from a quiet fight: a report saying every +# location is untouched is exactly what an undamaged force looks like. +# +# So the damage is applied here, deliberately, and the document is checked for +# it. tests/src/check/UnitReportCheck.java is the harness; this is the +# assertion. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +: "${MM_HOME:?MM_HOME is required}" +: "${JAVA_HOME:?JAVA_HOME is required}" + +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" + +"$JAVA_HOME/bin/javac" -nowarn -Xlint:-options -classpath "$CP" -d "$TMP/classes-check" \ + "$ROOT/tests/src/check/UnitReportCheck.java" + +# Any scenario with a Mek in it; the first training one is the smallest. +SCENARIO="${UNIT_REPORT_SCENARIO:-$MM_HOME/data/scenarios/TrainingScenarios/1-FirstRun.mms}" +[ -f "$SCENARIO" ] || { echo "ERROR: no scenario at $SCENARIO" >&2; exit 1; } + +cd "$MM_HOME" +"$JAVA_HOME/bin/java" -classpath "$CP:$TMP/classes-check" arena.UnitReportCheck "$SCENARIO" \ + > "$TMP/unit.json" + +# Asserted against the whole document rather than one unit. +whole() { # + if [ "$(jq -r "$2" "$TMP/unit.json")" = "true" ]; then + printf ' ok %s\n' "$1" + else + printf ' FAIL %s\n' "$1" + fail=1 + fi +} + +fail=0 +# The harness prints every unit in the scenario; the damaged Mek is the first +# of them, and is what these assert on. +check() { # + if [ "$(jq -r ".units[0] | $2" "$TMP/unit.json")" = "true" ]; then + printf ' ok %s\n' "$1" + else + printf ' FAIL %s\n' "$1" + fail=1 + fi +} + +check "it is JSON at all" '. != null' +check "every unit is reported" '. != null' +check "the machine is named" '(.name | length) > 0 and (.chassis | length) > 0' +check "it knows what kind it is" '.kind == "mek"' +check "it carries tonnage and BV" '.tons > 0 and .bv > 0' +check "every location is reported" '(.locations | length) >= 8' +check "armour comes with its start" '[.locations[] | select(.armorMax > 0)] | length > 0' +# The damage UnitReportCheck applied, read back out of the document. +check "the stripped torso shows it" '[.locations[] | select(.abbr == "CT")][0] + | .armor == 0 and .internal == 3 and .internalMax > 3' +check "the blown-off arm says so" '[.locations[] | select(.abbr == "LA")][0].blownOff == true' +check "the hit slot is a hit" '[.locations[].slots // [] | .[] | select(. != null) + | select(.hit)] | length > 0' +check "slots are named" '[.locations[].slots // [] | .[] | select(. != null) + | select(.name | length > 0)] | length > 0' +# Empty slots are kept as nulls: a slot is a position on a record sheet, and a +# list with the gaps closed up moves every slot below a gap one line. +check "empty slots keep their place" '[.locations[] | select(.abbr == "HD") | .slots[]] | length >= 6' +check "the wounded pilot is wounded" '.crew.hits == 2' +check "the pilot has skills" '.crew.gunnery >= 0 and .crew.piloting >= 0' +# Everything the machine carries, wherever it sits. The slot list says where +# damage landed; this says what the machine is, and for a class whose slots +# are empty it is the only such list. +check "its equipment is listed" '(.equipment | length) > 0' +check "equipment says where it sits" '[.equipment[] | select(.loc | length > 0)] | length > 0' +whole "every unit in the scenario is reported" '(.units | length) > 1' +# The armour and the internal structure are mounted equipment as far as +# MegaMek is concerned and are the diagram as far as a record sheet is +# concerned. Left in, every machine's list opens with two lines saying +# "Standard". +check "the armour is not listed as equipment" '[.equipment[] | select(.name == "Standard")] | length == 0' + +# --- the classes that are not Meks ------------------------------------------- +# +# A vehicle is not a Mek and its record is not a Mek's: it has its own body +# plan, its own criticals - a locked turret, a wrecked drive train, a hit +# driver - and twenty-five slots a location with four of them filled. A +# reader drawing it needs to be able to tell that from the document. +VEHICLE_SCENARIO="${UNIT_REPORT_VEHICLE_SCENARIO:-$MM_HOME/data/scenarios/1stSomersetStrikers/6-EnemyOfMyEnemy.mms}" +if [ -f "$VEHICLE_SCENARIO" ]; then + # MegaMek's own logging goes to stderr and is not this test's business. + "$JAVA_HOME/bin/java" -classpath "$CP:$TMP/classes-check" arena.UnitReportCheck "$VEHICLE_SCENARIO" \ + > "$TMP/vehicles.json" 2>/dev/null + vcheck() { # + if [ "$(jq -r "[.units[] | select(.kind == \"vehicle\")][0] | $2" "$TMP/vehicles.json")" = "true" ]; then + printf ' ok %s\n' "$1" + else + printf ' FAIL %s\n' "$1" + fail=1 + fi + } + vcheck "a vehicle is reported as one" '. != null' + vcheck "it carries its own criticals" '.vehicle != null and (.vehicle | has("turretLocked"))' + vcheck "it is not given a crew of Meks" '.infantry == null' + vcheck "its equipment is listed" '(.equipment | length) > 0' + vcheck "its locations are the vehicle's" '[.locations[].abbr] | contains(["FR"]) and contains(["BD"])' +else + echo " skip a vehicle is reported as one (no scenario with one)" +fi +check "a live machine is active" '.status == "active"' +# MegaMek's own reading of how hurt the machine is - a rules judgement about +# what it can still do, not an average of its armour. It is the one thing in +# this document a reader does not have to know record sheets to use. +check "it says how hurt the machine is" '.damage == "heavy" or .damage == "crippled"' +# A bin's full load is the ammo type's own count: getOriginalShots is only set +# where something set it, and is zero on a bin that came off the scenario. +check "ammo bins report a full load" '[.ammo[] | select(.shotsMax > 0)] | length > 0' +check "ammo bins say where they are" '[.ammo[] | select(.loc | length > 0)] | length > 0' + +if [ "$fail" -ne 0 ]; then + echo + echo "the document was:" >&2 + jq . "$TMP/unit.json" >&2 || cat "$TMP/unit.json" >&2 +fi +exit "$fail" -- 2.51.2