import megamek.client.ratgenerator.FormationType; import megamek.client.ratgenerator.MissionRole; import megamek.common.loaders.MekSummary; import megamek.common.loaders.MekSummaryCache; import megamek.common.units.UnitType; import java.io.BufferedWriter; import java.io.FileOutputStream; import java.io.OutputStreamWriter; import java.io.PrintWriter; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Iterator; import java.util.List; /** * Dump the formation blueprints MegaMek carries, and its verdict on a unit. * *

Campaign Operations defines what makes a lance a Fire Lance rather than * four Meks: an ideal role, weight bounds, and requirements like "at least * three units with the Sniper or Missile Boat role". MegaMek holds all of it in * {@code FormationType}, so helm reads the blueprints off the shelf the way it * reads the unit library - nothing here is committed. * *

The predicates themselves are lambdas and cannot be read out, only their * display strings. helm reimplements them from those strings and is measured * against MegaMek's own answers, which is the arrangement battle value already * uses. Two oracle modes serve that: * *

* *

Usage: DumpFormations out.jsonl [--matches N | --force "Chassis|Model" ...] */ public final class DumpFormations { /** The range bands the damage requirements ask about. */ private static final int[] RANGES = { 6, 7, 9, 18 }; /** The bands asked about for one weapon rather than a total. */ private static final int[] SINGLE_RANGES = { 15, 18 }; public static void main(String[] args) throws Exception { if (args.length < 1) { System.err.println("usage: DumpFormations [--matches N]" + " [--force \"Chassis|Model\" ...]"); System.exit(2); } MekSummaryCache cache = MekSummaryCache.getInstance(); long deadline = System.currentTimeMillis() + 600_000L; while (!cache.isInitialized()) { if (System.currentTimeMillis() > deadline) { System.err.println("units.cache did not initialise within 600s"); System.exit(1); } Thread.sleep(250L); } FormationType.createFormationTypes(); List force = new ArrayList<>(); boolean wantForce = false; int sample = 0; for (int i = 1; i < args.length; i++) { if (args[i].equals("--force")) { wantForce = true; continue; } if (args[i].equals("--matches")) { sample = Integer.parseInt(args[++i]); continue; } force.add(lookUp(cache, args[i])); } int written; try (PrintWriter out = new PrintWriter(new BufferedWriter( new OutputStreamWriter(new FileOutputStream(args[0]), StandardCharsets.UTF_8)))) { written = sample > 0 ? writeMatches(out, cache, sample) : writeBlueprints(out, force, wantForce); } System.err.println("wrote " + written + " rows"); } private static MekSummary lookUp(MekSummaryCache cache, String arg) { String[] parts = arg.split("\\|", 2); String name = parts.length > 1 && !parts[1].isBlank() ? parts[0] + " " + parts[1] : parts[0]; MekSummary ms = cache.getMek(name); if (ms == null) { System.err.println("no such design: " + name); System.exit(1); } return ms; } /** One row per blueprint: everything a validator has to reproduce. */ private static int writeBlueprints(PrintWriter out, List force, boolean wantForce) { int written = 0; for (FormationType type : FormationType.getAllFormations()) { StringBuilder sb = new StringBuilder(1024); sb.append('{'); field(sb, "name", type.getName()); sb.append(','); field(sb, "category", type.getCategory()); sb.append(",\"ground\":").append(type.isGround()); sb.append(",\"minWeightClass\":").append(type.getMinWeightClass()); sb.append(",\"maxWeightClass\":").append(type.getMaxWeightClass()); sb.append(','); field(sb, "idealRole", String.valueOf(type.getIdealRole())); sb.append(','); field(sb, "mainDescription", type.getMainDescription()); sb.append(",\"unitTypes\":["); boolean firstType = true; for (int t = 0; t < UnitType.SIZE; t++) { if (!type.isAllowedUnitType(t)) { continue; } if (!firstType) { sb.append(','); } firstType = false; quote(sb, UnitType.getTypeName(t)); } sb.append(']'); sb.append(",\"missionRoles\":["); boolean firstRole = true; for (MissionRole role : type.getMissionRoles()) { if (!firstRole) { sb.append(','); } firstRole = false; quote(sb, role.toString()); } sb.append(']'); sb.append(",\"criteria\":["); Iterator it = type.getOtherCriteria(); boolean first = true; while (it.hasNext()) { if (!first) { sb.append(','); } first = false; constraint(sb, it.next()); } sb.append(']'); // The grouping requirement, where there is one: "two units of the // same chassis" and its kin. Held apart from the others upstream // because it is a rule about pairs rather than about a unit. FormationType.GroupingConstraint grouping = type.getGroupingCriteria(); sb.append(",\"grouping\":"); if (grouping == null) { sb.append("null"); } else { constraint(sb, grouping); sb.setLength(sb.length() - 1); sb.append(",\"groupSize\":").append(grouping.getGroupSize()); sb.append(",\"numGroups\":").append(grouping.getNumGroups()); sb.append('}'); } if (wantForce) { // Upstream can throw here rather than answer - see UPSTREAM.md // - and one blueprint that cannot make up its mind must not // cost the answer for the other forty. Boolean verdict = null; String report = null; String error = null; try { verdict = type.qualifies(force); report = type.qualificationReport(force); } catch (Throwable t) { error = t.toString(); } // Which units the grouping requirement looks at, and which // pairs it accepts. Neither is visible in the verdict, and a // reimplementation that gets the verdict right by accident is // worth nothing. if (grouping != null) { sb.append(",\"groupMatches\":["); for (int u = 0; u < force.size(); u++) { if (u > 0) { sb.append(','); } sb.append(grouping.matches(force.get(u))); } sb.append("],\"groupPairs\":["); boolean firstPair = true; for (int a = 0; a < force.size(); a++) { for (int b = a + 1; b < force.size(); b++) { if (!firstPair) { sb.append(','); } firstPair = false; sb.append('[').append(a).append(',').append(b).append(',') .append(grouping.matches(force.get(a), force.get(b))) .append(']'); } } sb.append(']'); } sb.append(",\"qualifies\":").append(verdict); sb.append(','); if (error == null) { field(sb, "report", report); } else { field(sb, "error", error); } } sb.append('}'); out.println(sb); written++; } return written; } /** * The minimum each requirement asks of a force of 1 through 20. Dumped as * the whole curve rather than a percentage, because the two kinds of * requirement - a fixed count and a proportion - are private fields, and a * curve says what either of them means without reading one. */ private static void constraint(StringBuilder sb, FormationType.Constraint c) { sb.append('{'); field(sb, "description", c.getDescription()); sb.append(",\"pairedWithNext\":").append(c.isPairedWithNext()); sb.append(",\"pairedWithPrevious\":").append(c.isPairedWithPrevious()); sb.append(",\"minimums\":["); for (int size = 1; size <= 20; size++) { if (size > 1) { sb.append(','); } sb.append(c.getMinimum(size)); } sb.append("]}"); } /** One row per design: which requirements MegaMek says that design meets. */ private static int writeMatches(PrintWriter out, MekSummaryCache cache, int sample) { MekSummary[] all = cache.getAllMeks(); // Strided rather than the first N, so a sample of 400 spans the whole // library instead of whatever the file walk happened to reach first. int step = Math.max(1, all.length / Math.max(1, sample)); int written = 0; for (int i = 0; i < all.length && written < sample; i += step) { MekSummary ms = all[i]; StringBuilder sb = new StringBuilder(2048); sb.append('{'); field(sb, "chassis", ms.getChassis()); sb.append(','); field(sb, "model", ms.getModel()); sb.append(','); field(sb, "unitType", ms.getUnitType()); // The figures the damage requirements are worked out from, so a // disagreement can be read as "helm says 9, MegaMek says 40" // rather than "some requirement about damage". sb.append(",\"damage\":{"); for (int r = 0; r < RANGES.length; r++) { if (r > 0) { sb.append(','); } quote(sb, String.valueOf(RANGES[r])); sb.append(':').append(damageAt("getDamageAtRange", ms, RANGES[r])); } sb.append("},\"single\":{"); for (int r = 0; r < SINGLE_RANGES.length; r++) { if (r > 0) { sb.append(','); } quote(sb, String.valueOf(SINGLE_RANGES[r])); sb.append(':') .append(damageAt("getSingleWeaponDamageAtRange", ms, SINGLE_RANGES[r])); } sb.append('}'); sb.append(",\"formations\":{"); boolean firstType = true; for (FormationType type : FormationType.getAllFormations()) { if (!firstType) { sb.append(','); } firstType = false; quote(sb, type.getName()); sb.append(":{\"main\":").append(type.getMainCriteria().test(ms)); sb.append(",\"criteria\":["); Iterator it = type.getOtherCriteria(); boolean first = true; while (it.hasNext()) { if (!first) { sb.append(','); } first = false; sb.append(it.next().matches(ms)); } sb.append("]}"); } sb.append("}}"); out.println(sb); written++; } return written; } /** * Call one of {@code FormationType}'s private damage helpers. * * Reflection rather than a reimplementation on purpose: the figure has to * be MegaMek's to be worth anything here, and copying the calculation into * this file would make the oracle agree with helm by construction. */ private static long damageAt(String method, MekSummary ms, int range) { try { java.lang.reflect.Method m = FormationType.class .getDeclaredMethod(method, MekSummary.class, int.class); m.setAccessible(true); return (long) m.invoke(null, ms, range); } catch (ReflectiveOperationException e) { throw new IllegalStateException("no " + method + " to call: " + e, e); } } private static void field(StringBuilder sb, String key, String value) { quote(sb, key); sb.append(':'); quote(sb, value == null ? "" : value); } private static void quote(StringBuilder sb, String value) { sb.append('"'); for (int i = 0; i < value.length(); i++) { char c = value.charAt(i); switch (c) { case '"' -> sb.append("\\\""); case '\\' -> sb.append("\\\\"); case '\n' -> sb.append("\\n"); case '\r' -> sb.append("\\r"); case '\t' -> sb.append("\\t"); default -> { if (c < 0x20) { sb.append(String.format("\\u%04x", (int) c)); } else { sb.append(c); } } } } sb.append('"'); } }