diff --git a/TODO.md b/TODO.md index e511d64..efa6be4 100644 --- a/TODO.md +++ b/TODO.md @@ -197,6 +197,28 @@ The long pole, and the reason `helm-core` does no I/O. Tracked as almost unarmoured, a Zeus-X at 28 points where MegaMek says 247. Total armour now agrees with MegaMek on all 10,749 designs it reports one for. +- [x] **Battle value for a unit somebody varied.** A `.mtf` says what a design + left the factory with; a `.mul` says what is in every critical slot now, + and MegaMek reads those back - a magazine whose `type` names acid rounds + is loaded with acid rounds and scored as such. That is how a player + changes a unit without designing one, and helm read the design's own + slots and answered as though nothing had been touched. + + A Shadow Hawk SHD-2H is 1,064 as built and 1,069 with acid rounds in its + SRM bin. Both figures are MegaMek's, and both are now helm's. + + `bridge/DumpMul.java` is the oracle, and it is the first one that reads a + file rather than shooting a design itself - which makes it the harness + for anything expressed in a `.mul` rather than derived from a `.mtf`. The + fixtures in `crates/helm-bv/tests/mul/` are this repository's own, + because MegaMek ships no file that varies a unit. + + An unknown name in a slot is ignored rather than treated as an empty one: + a newer MegaMek's spelling should cost nothing, and the design's own + answer is a better guess than a hole. An omni pod swap - different + equipment rather than different rounds - should ride the same mechanism + and has not been checked. + - [ ] **The equipment MegaMek synthesises while loading** rather than reading from the file, which battle value counts. Recorded on `UnitFacets::loadout`. - [x] **The oracle says where, not just whether.** `bridge/DumpBv.java` runs diff --git a/bridge/DumpMul.java b/bridge/DumpMul.java new file mode 100644 index 0000000..d9d91b2 --- /dev/null +++ b/bridge/DumpMul.java @@ -0,0 +1,125 @@ +import megamek.common.battleValue.BVCalculator; +import megamek.common.loaders.MekSummaryCache; +import megamek.common.units.Entity; +import megamek.common.loaders.MULParser; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; + +/** + * Dump MegaMek's battle value for the units in a `.mul`. + * + *

The other dumps score a design as it leaves the factory, or a design this + * program shot at in a way it chose. Neither can answer for a unit somebody + * else varied: a `.mul` records what is in every critical slot, and MegaMek + * reads those types back - a magazine whose `type` names inferno rounds is + * loaded with inferno rounds and scored as such. That is how a player changes + * a unit without designing one, and it is the case helm has to agree about. + * + *

Usage: DumpMul out.jsonl file.mul [file.mul ...] + * + *

Must run with MegaMek's install dir as the working directory. The summary + * cache has to be up before a `.mul` can name a design. + */ +public final class DumpMul { + + public static void main(String[] args) throws Exception { + if (args.length < 2) { + System.err.println("usage: DumpMul [file.mul ...]"); + 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); + } + + int written = 0; + try (PrintWriter out = new PrintWriter(new BufferedWriter( + new OutputStreamWriter(new FileOutputStream(args[0]), StandardCharsets.UTF_8)))) { + for (int i = 1; i < args.length; i++) { + File file = new File(args[i]); + MULParser parser = new MULParser(file, null); + for (Entity entity : parser.getEntities()) { + BVCalculator calc = BVCalculator.getBVCalculator(entity); + // Crew and C3 left out: the crew is arithmetic helm does + // itself from a table, and C3 needs a game to see the + // other units. What is being asked here is what the unit + // itself is worth once somebody has varied it. + int bv = calc.calculateBV(true, true); + StringBuilder sb = new StringBuilder(256); + sb.append("{\"file\":"); + quote(sb, file.getName()); + sb.append(",\"name\":"); + quote(sb, entity.getShortNameRaw()); + sb.append(",\"bv\":").append(bv); + // Which half is wrong is the first question every + // disagreement asks, and the fields are not public. + for (String field : new String[] { "defensiveValue", "offensiveValue" }) { + try { + java.lang.reflect.Field f = findField(calc.getClass(), field); + if (f != null) { + f.setAccessible(true); + sb.append(",\"").append(field).append("\":").append(f.get(calc)); + } + } catch (Throwable ignored) { + // A MegaMek that renames these should lose the + // columns rather than fail. + } + } + sb.append('}'); + out.println(sb); + written++; + } + String warning = parser.getWarningMessage(); + if (warning != null && !warning.isBlank()) { + System.err.println(file.getName() + ": " + warning.trim()); + } + } + } + System.err.println("wrote " + written + " rows"); + } + + /** A field on this class or any above it. */ + private static java.lang.reflect.Field findField(Class type, String name) { + for (Class at = type; at != null; at = at.getSuperclass()) { + try { + return at.getDeclaredField(name); + } catch (NoSuchFieldException ignored) { + // Keep walking up. + } + } + return null; + } + + 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('"'); + } +} diff --git a/bridge/dump.sh b/bridge/dump.sh index d61436f..056da58 100755 --- a/bridge/dump.sh +++ b/bridge/dump.sh @@ -91,4 +91,27 @@ run DumpBv.java bv.jsonl Mek echo "dumping damaged designs ..." run DumpDamaged.java damaged.jsonl -echo "wrote $OUT_DIR/units.jsonl, $OUT_DIR/equipment.jsonl, $OUT_DIR/bv.jsonl and $OUT_DIR/damaged.jsonl" +# And the units in a `.mul`, which is the only oracle for a unit somebody +# varied rather than designed: a magazine whose `type` names inferno rounds is +# loaded with inferno rounds and scored as such. The fixtures are this +# repository's own, because MegaMek ships no file that varies a unit. +# +# Copied into the output directory first: the container is given `bridge/`, +# MegaMek and the output directory, and the fixtures are in none of those. +echo "dumping the .mul fixtures ..." +FIXTURES="$HERE/../crates/helm-bv/tests/mul" +if compgen -G "$FIXTURES/*.mul" > /dev/null; then + mkdir -p "$OUT_DIR/muls" + cp "$FIXTURES"/*.mul "$OUT_DIR/muls/" + muls=() + for f in "$OUT_DIR"/muls/*.mul; do + # The container sees the output directory as /out; a local JDK sees it + # where it is. + if [ -n "${JAVA_HOME:-}" ]; then muls+=("$f"); else muls+=("/out/muls/$(basename "$f")"); fi + done + run DumpMul.java mul.jsonl "${muls[@]}" +else + echo "no .mul fixtures to dump" +fi + +echo "wrote $OUT_DIR/units.jsonl, $OUT_DIR/equipment.jsonl, $OUT_DIR/bv.jsonl, $OUT_DIR/damaged.jsonl and $OUT_DIR/mul.jsonl" diff --git a/crates/helm-bv/src/lib.rs b/crates/helm-bv/src/lib.rs index 508e146..0a0aed9 100644 --- a/crates/helm-bv/src/lib.rs +++ b/crates/helm-bv/src/lib.rs @@ -189,6 +189,16 @@ pub struct Condition { /// An empty bin is worth nothing and, just as usefully, no longer explodes /// - which is why running a Locust dry *raises* its battle value. pub empty_ammo: std::collections::BTreeMap>, + /// What a slot holds now, where it is not what the design put there. + /// + /// Damage is not the only thing that happens to a machine between the + /// factory and the field. A player loads inferno rounds instead of + /// standard, or swaps an omni pod, and MegaMek scores the machine that + /// results - a `.mul` records the type in every slot for exactly that + /// reason. A name here that the catalogue does not know is ignored rather + /// than treated as an empty slot: the design's own answer is the better + /// guess. + pub loaded: std::collections::BTreeMap>, } impl From<&helm_unitfile::MulUnit> for Condition { @@ -203,6 +213,7 @@ impl From<&helm_unitfile::MulUnit> for Condition { structure: unit.structure.clone(), destroyed: unit.destroyed.clone(), empty_ammo: unit.empty_ammo.clone(), + loaded: unit.loaded.clone(), } } } diff --git a/crates/helm-bv/src/loadout.rs b/crates/helm-bv/src/loadout.rs index 78c7632..a424aaa 100644 --- a/crates/helm-bv/src/loadout.rs +++ b/crates/helm-bv/src/loadout.rs @@ -240,7 +240,21 @@ impl<'a> Loadout<'a> { } } } + // What the machine is carrying now, which is the design's slots + // unless something says otherwise. + let none_loaded = BTreeMap::new(); + let loaded = condition.loaded.get(location).unwrap_or(&none_loaded); for (index, line) in slots.iter().enumerate() { + // A slot the caller says holds something else holds something + // else - an inferno bin where the sheet says standard rounds. + // Only when the catalogue knows the name: an unknown one is a + // newer MegaMek's spelling, and the design's own answer is a + // better guess than nothing at all. + let swapped = loaded + .get(&index) + .filter(|what| catalogue.resolve(what).is_some()) + .map(String::as_str); + let line = swapped.unwrap_or(line); if gone.contains(&index) || empty.contains(&index) { // A heat sink shot out of an arm is cooling the design no // longer has, and the `heat sinks:` line still counts it. diff --git a/crates/helm-bv/tests/conformance.rs b/crates/helm-bv/tests/conformance.rs index 3a391f2..b553d34 100644 --- a/crates/helm-bv/tests/conformance.rs +++ b/crates/helm-bv/tests/conformance.rs @@ -367,6 +367,8 @@ fn condition_from(row: &helm_bridge::Damaged) -> helm_bv::Condition { structure: row.structure.clone(), destroyed: row.destroyed.clone(), empty_ammo: row.empty_ammo.clone(), + // The damaged oracle shoots designs; it does not vary them. + loaded: Default::default(), } } @@ -1466,3 +1468,89 @@ fn a_design_worn_down_here_reads_back_as_the_same_machine() { assert!(wrong.is_empty(), "{} did not survive the trip", wrong.len()); assert!(checked > 5, "only {checked} designs were tried"); } + +/// Units somebody varied rather than designed, scored against MegaMek. +/// +/// A `.mtf` says what a design left the factory with. A `.mul` says what is in +/// every critical slot *now*, and MegaMek reads those back: a magazine whose +/// `type` names acid rounds is loaded with acid rounds and scored as such. +/// That is how a player changes a unit without designing one - the case +/// `plan/unit-rules.md` says the product is built around - and helm read the +/// design's own slots and answered as though nothing had been touched. +/// +/// The fixtures are this repository's own, in `tests/mul/`, because MegaMek +/// ships no file that varies a unit. `bridge/dump.sh` scores them into +/// `mul.jsonl`. +#[test] +#[ignore = "needs a MegaMek install and a bridge dump; set HELM_MEGAMEK and HELM_BRIDGE"] +fn a_varied_unit_is_worth_what_megamek_says_it_is() { + let Some(inputs) = inputs() else { + panic!("set HELM_MEGAMEK to a MegaMek install and HELM_BRIDGE to a bridge dump"); + }; + let path = PathBuf::from(std::env::var("HELM_BRIDGE").unwrap()).join("mul.jsonl"); + if !path.is_file() { + println!("no mul.jsonl in the bridge dump; run bridge/dump.sh to make one"); + return; + } + let oracle = std::fs::read_to_string(&path).expect("mul.jsonl"); + + // The rows come back in the order the entities appear in each file, which + // is the order the parser reads them in. + let mut rows: std::collections::BTreeMap> = Default::default(); + for line in oracle.lines().filter(|l| !l.trim().is_empty()) { + let row: serde_json::Value = serde_json::from_str(line).expect("a mul row"); + rows.entry(row["file"].as_str().unwrap_or_default().to_string()) + .or_default() + .push(( + row["name"].as_str().unwrap_or_default().to_string(), + row["bv"].as_i64().unwrap_or_default(), + )); + } + + let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/mul"); + let mut checked = 0; + let mut varied = 0; + let mut wrong: Vec = Vec::new(); + for (file, expected) in &rows { + let text = std::fs::read_to_string(fixtures.join(file)).expect("a fixture"); + let mul = helm_unitfile::parse_mul(&text).expect("a readable fixture"); + let machines: Vec<&helm_unitfile::MulUnit> = mul.machines().collect(); + assert_eq!( + machines.len(), + expected.len(), + "{file}: helm reads {} entities and MegaMek read {}", + machines.len(), + expected.len() + ); + + for (entity, (name, theirs)) in machines.iter().zip(expected) { + let Some(unit) = inputs + .library + .units + .iter() + .find(|u| u.answers_to(&entity.chassis, &entity.model)) + else { + wrong.push(format!("{file}: {name} is not in the library")); + continue; + }; + let condition = helm_bv::Condition::from(*entity); + if !condition.loaded.is_empty() { + varied += 1; + } + checked += 1; + match helm_bv::battle_value_in(unit, &inputs.catalogue, &condition) { + Ok(ours) if ours == *theirs => {} + other => wrong.push(format!("{file}: {name}: ours {other:?}, megamek {theirs}")), + } + } + } + + println!("{checked} units read from .mul files, {varied} of them varied"); + for line in &wrong { + println!(" {line}"); + } + assert!(wrong.is_empty(), "{} disagree", wrong.len()); + // A fixture set with no variation in it would pass without checking the + // thing this test is for. + assert!(varied > 0, "no fixture varies a unit"); +} diff --git a/crates/helm-bv/tests/mul/varied.mul b/crates/helm-bv/tests/mul/varied.mul new file mode 100644 index 0000000..4321612 --- /dev/null +++ b/crates/helm-bv/tests/mul/varied.mul @@ -0,0 +1,37 @@ + + + + + + Center Torso + + + + + + Center Torso + + + + + + Center Torso + + + + + + Right Torso + + + + diff --git a/crates/helm-cli/src/main.rs b/crates/helm-cli/src/main.rs index 1c2ba92..0ae88bb 100644 --- a/crates/helm-cli/src/main.rs +++ b/crates/helm-cli/src/main.rs @@ -1121,6 +1121,7 @@ fn print_damaged( structure: row.structure.clone(), destroyed: row.destroyed.clone(), empty_ammo: row.empty_ammo.clone(), + loaded: Default::default(), }; checked += 1; if row.name != last { diff --git a/crates/helm-unitfile/src/mul.rs b/crates/helm-unitfile/src/mul.rs index b703bfd..770af60 100644 --- a/crates/helm-unitfile/src/mul.rs +++ b/crates/helm-unitfile/src/mul.rs @@ -174,6 +174,18 @@ pub struct MulUnit { pub structure: BTreeMap, pub destroyed: BTreeMap>, pub empty_ammo: BTreeMap>, + /// What each slot holds *now*, by location and slot, where the file names + /// something. + /// + /// A `.mul` records a `type` on every slot it writes, and MegaMek reads + /// them back: a magazine whose type names inferno rounds is loaded with + /// inferno rounds and scored as such. That is how a player varies a unit + /// without designing one, and it is the difference between the design a + /// name resolves to and the machine that is actually on the field. + /// + /// System slots are left out - a `.mul` writes `System` for those, which + /// names no equipment. + pub loaded: BTreeMap>, } impl MulUnit { @@ -520,6 +532,17 @@ fn read_slot(unit: &mut MulUnit, name: &str, slot_tag: &Kept) { let Some(slot) = slot.checked_sub(1) else { return; }; + // What the file says is in the slot. `System` is a Mek's own machinery + // rather than equipment, and `-Empty-` is nothing at all. + if let Some(what) = slot_tag.get("type").map(str::trim).filter(|what| { + !what.is_empty() && !what.eq_ignore_ascii_case("System") && *what != "-Empty-" + }) { + unit.loaded + .entry(name.to_string()) + .or_default() + .insert(slot, what.to_string()); + } + if slot_tag.get("isDestroyed") == Some("true") || slot_tag.get("isMissing") == Some("true") { unit.destroyed .entry(name.to_string()) diff --git a/crates/helm-wasm/src/lib.rs b/crates/helm-wasm/src/lib.rs index fb880f0..ca01d76 100644 --- a/crates/helm-wasm/src/lib.rs +++ b/crates/helm-wasm/src/lib.rs @@ -213,6 +213,9 @@ pub unsafe extern "C" fn helm_mul_from_json(ptr: *const u8, len: usize) -> i64 { structure: condition.structure, destroyed: condition.destroyed, empty_ammo: condition.empty_ammo, + // A variant is what a slot holds, and this call rebuilds one unit + // from a summary that does not carry the slots. + loaded: Default::default(), }); } give(helm_unitfile::write_mul(&mul))