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"