diff --git a/README.md b/README.md index 6096f83..6190baf 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,11 @@ two so a MekBay force renders the way MekBay wrote it. Order is the file's: a force appears where its first unit does, which is what MegaMek wrote and what a reader expects to see. Ids are matched, not sorted. +The same tree crosses the wasm boundary: `helm_mul_summary` carries `forces` +and `loose` beside `units`, and `helm.mjs` exposes them as `force.forces` and +`force.loose`, whose indices are into `force.units`. That is what lets a page +draw a MekBay force the way MekBay wrote it without parsing XML of its own. + A `.mul` is a force in a state rather than a design, so scoring one is its own command - and `--why` says where a damaged machine's value went, term by term, against the design as it left the factory: diff --git a/crates/helm-wasm/helm.mjs b/crates/helm-wasm/helm.mjs index 29de966..f3f144d 100644 --- a/crates/helm-wasm/helm.mjs +++ b/crates/helm-wasm/helm.mjs @@ -20,7 +20,7 @@ // including when the call throws. /** The boundary this wrapper was written against; the module must agree. */ -const ABI_VERSION = 1n; +const ABI_VERSION = 2n; const ERRORS = new Map([ [-1n, "no equipment catalogue is loaded"], @@ -200,14 +200,37 @@ export class Force { return this.#handle; } + /** Everything the summary carries, read once. */ + get #summary() { + return JSON.parse(this.#take(this.#api.helm_mul_summary(this.#live))); + } + /** What the force says, for showing: designs, crews, and what each has lost. */ get units() { - return JSON.parse(this.#take(this.#api.helm_mul_summary(this.#live))).units; + return this.#summary.units; + } + + /** + * How the units are organised, outermost forces first. + * + * A `.mul` stores no tree - every entity repeats its own chain and helm + * rebuilds one - so this is derived rather than read. Each node carries + * `units`, which are indices into `units` above, and `children`. MekBay + * writes forces; MegaMek's own bot files do not, and for those this is + * empty and everything is in `loose`. + */ + get forces() { + return this.#summary.forces; + } + + /** The units in no force at all, as indices into `units`. */ + get loose() { + return this.#summary.loose; } /** The version the file said wrote it, or null. */ get version() { - return JSON.parse(this.#take(this.#api.helm_mul_summary(this.#live))).version; + return this.#summary.version; } /** diff --git a/crates/helm-wasm/smoke.mjs b/crates/helm-wasm/smoke.mjs index 549afec..ba763f0 100644 --- a/crates/helm-wasm/smoke.mjs +++ b/crates/helm-wasm/smoke.mjs @@ -279,4 +279,75 @@ if (mulFile) { throws("a freed force", () => force.units, -7); } +// --- forces --------------------------------------------------------------- +// +// MekBay writes forces with sub-forces; a `.mul` carries them as a chain per +// entity and no tree at all. This is the boundary's half of rebuilding one: +// what a page needs to draw the brackets. +{ + const withForces = ` + + + + + + + + + + + + + +`; + const force = helm.force(withForces); + + check("one top-level force", force.forces.length, 1); + check("named as the file names it", force.forces[0].name, "Steiner Ceremonial"); + check("two sub-forces under it", force.forces[0].children.length, 2); + check("the first holds the Atlas", force.forces[0].children[0].units[0], 0); + check( + "a sub-force is named too", + force.forces[0].children[1].name, + "Recon Lance", + ); + check("the top-level force holds nothing directly", force.forces[0].units.length, 0); + + // The indices are into `units`, so a page joins the two without a second + // read - and the unit they point at is the one the file named. + check( + "an index names its unit", + force.units[force.forces[0].children[1].units[0]].chassis, + "Griffin", + ); + + // Everything in no force is reported rather than dropped: drawing forces + // and loose units covers each unit exactly once. + check("the Locust is loose", JSON.stringify(force.loose), "[2]"); + const drawn = + force.forces[0].children.reduce((n, f) => n + f.units.length, 0) + + force.forces[0].units.length + + force.loose.length; + check("every unit is drawn once", drawn, force.units.length); + + force.free(); +} + +// A file with no forces says so, rather than looking like a broken tree. +// MegaMek's own bot files are the common case of that, and they are what a +// match writes - so this is the shape the site sees most. +{ + const noForces = ` + + + + + +`; + const force = helm.force(noForces); + check("no forces in a file that has none", force.forces.length, 0); + check("so every unit is loose", force.loose.length, force.units.length); + force.free(); +} + process.exit(failed === 0 ? 0 : 1); diff --git a/crates/helm-wasm/src/lib.rs b/crates/helm-wasm/src/lib.rs index 8215a15..ba69de1 100644 --- a/crates/helm-wasm/src/lib.rs +++ b/crates/helm-wasm/src/lib.rs @@ -343,10 +343,42 @@ pub extern "C" fn helm_mul_summary(handle: i64) -> i64 { }) }) .collect(); - give(serde_json::json!({ "version": mul.version, "units": units }).to_string()) + give( + serde_json::json!({ + "version": mul.version, + "units": units, + "forces": forces_json(&mul.force_tree()), + "loose": mul.loose_units(), + }) + .to_string(), + ) }) } +/// The force tree, as the page draws it. +/// +/// A `.mul` stores no tree: every entity repeats its own chain and +/// `force_tree` rebuilds one. `units` here are indices into the summary's own +/// `units` array — the same `at` each unit carries — so a caller joins the two +/// without a second lookup, and `loose` beside it is everything in no force at +/// all. Between them every unit is drawn exactly once. +fn forces_json(forces: &[helm_unitfile::ForceNode]) -> Vec { + forces + .iter() + .map(|force| { + serde_json::json!({ + "id": force.id, + "name": force.name, + "camo": force.camo.as_ref().map(|(category, file)| { + serde_json::json!({ "category": category, "file": file }) + }), + "units": force.units, + "children": forces_json(&force.children), + }) + }) + .collect() +} + /// Write the force back out, as an edit of the file that arrived. #[unsafe(no_mangle)] pub extern "C" fn helm_mul_write(handle: i64) -> i64 { @@ -428,9 +460,14 @@ fn with_force_mut(handle: i64, f: impl FnOnce(&mut helm_unitfile::Mul) -> i64) - /// so a page that has cached an older `.wasm` than its wrapper - or a newer /// one - fails at load with something readable instead of returning numbers /// that are quietly wrong. +/// +/// 2 adds `forces` and `loose` to `helm_mul_summary`. Additive, so an older +/// caller is unaffected - but a page that draws a force tree cannot otherwise +/// tell a file with no forces from a module too old to report them, and one +/// of those is a bug worth failing on. #[unsafe(no_mangle)] pub extern "C" fn helm_abi_version() -> i64 { - 1 + 2 } /// How many equipment entries are loaded, so a caller can tell an empty