From 24cc2abe3936ae3b3310efdd6452b79f658c050e Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 19 Aug 2026 17:26:35 -0400 Subject: [PATCH] feat(unit-rules): attribution across the wasm boundary `Design#attribution` hands a page the same terms as JSON, so it can draw a bar each and have them sum to the drop beside them. The smoke test checks that they do, and now finds the module under CARGO_TARGET_DIR, which is where a worktree here builds it. --- CLAUDE.md | 4 +- crates/helm-wasm/helm.mjs | 39 ++++++++++++++++++- crates/helm-wasm/smoke.mjs | 33 ++++++++++++++-- crates/helm-wasm/src/lib.rs | 77 +++++++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c1afc2f..976e3d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,9 @@ Most of the suite is ordinary `cargo test`. Three things are not: - `crates/helm-wasm/smoke.mjs` drives the wasm boundary from Node, which is the only thing that exercises the interface a browser calls through. Build the module first: - `cargo build -p helm-wasm --target wasm32-unknown-unknown --release`. + `cargo build -p helm-wasm --target wasm32-unknown-unknown --release`. It + finds the module under `CARGO_TARGET_DIR` when that is set, which it is in + a worktree here - `../../target` exists in none of them. Never read test fixtures out of a MegaMek install's `logs/` directory. That is MegaMek's own scratch, rewritten by anything that starts it — including diff --git a/crates/helm-wasm/helm.mjs b/crates/helm-wasm/helm.mjs index 54b603e..aa86b7f 100644 --- a/crates/helm-wasm/helm.mjs +++ b/crates/helm-wasm/helm.mjs @@ -127,7 +127,9 @@ export class Helm { const handle = check(this.#withBytes(mtf, (ptr, len) => this.#api.helm_design_load(ptr, len), )); - return new Design(this.#api, handle, (text, use) => this.#withBytes(text, use)); + return new Design(this.#api, handle, (text, use) => this.#withBytes(text, use), (packed) => + this.#take(packed), + ); } /** @@ -254,14 +256,16 @@ export class Design { #api; #handle; #withBytes; + #take; /** The last condition asked about, and what it came to. */ #baseFor = null; #base = 0; - constructor(api, handle, withBytes) { + constructor(api, handle, withBytes, take) { this.#api = api; this.#handle = handle; this.#withBytes = withBytes; + this.#take = take; } /** @@ -292,6 +296,37 @@ export class Design { return Math.round((this.#base + forceBonus) * multiplier); } + /** + * Where its battle value went, term by term. + * + * `to` is the condition it is in now and `from` what to compare against, + * defaulting to the machine as it left the factory - so one argument + * answers "what did this match cost me" and two answer "what did that + * turn". Only the terms that moved come back, worst loss first, and the + * changes plus `residual` equal `after - before` exactly, so a bar per term + * adds up to the figure a player is looking at. + */ + attribution(to = null, from = null) { + if (this.#handle === null) { + throw new HelmError(-6n); + } + return JSON.parse( + this.#take( + this.#withBytes(from ? JSON.stringify(from) : "", (fPtr, fLen) => + this.#withBytes(to ? JSON.stringify(to) : "", (tPtr, tLen) => + this.#api.helm_design_attribution( + this.#handle, + fLen === 0 ? 0 : fPtr, + fLen, + tLen === 0 ? 0 : tPtr, + tLen, + ), + ), + ), + ), + ); + } + /** Let the module forget it. Asking afterwards throws rather than lying. */ free() { if (this.#handle !== null) { diff --git a/crates/helm-wasm/smoke.mjs b/crates/helm-wasm/smoke.mjs index 4e818a2..76735a3 100644 --- a/crates/helm-wasm/smoke.mjs +++ b/crates/helm-wasm/smoke.mjs @@ -35,10 +35,17 @@ const throws = (what, run, code) => { } }; -const wasm = new URL( - "../../target/wasm32-unknown-unknown/release/helm_wasm.wasm", - import.meta.url, -); +// Where cargo put the module. `CARGO_TARGET_DIR` is honoured because the +// worktrees here share one, so `../../target` is a directory that does not +// exist in any of them. +const wasm = process.env.CARGO_TARGET_DIR + ? new URL( + `file://${process.env.CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/helm_wasm.wasm`, + ) + : new URL( + "../../target/wasm32-unknown-unknown/release/helm_wasm.wasm", + import.meta.url, + ); // Before the catalogue is there, nothing is scoreable. const bare = await Helm.load(readFileSync(wasm)); @@ -108,8 +115,26 @@ for (const crew of [ } check("a held design answers identically", heldWrong, 0); +// Where the value went. A page draws a bar per term, so the bars have to add +// up to the figure beside them - which is the property worth checking here +// rather than any particular number. +const stripped = { armor: { CT: 0, LT: 0, RT: 0, LA: 0, RA: 0, LL: 0, RL: 0, HD: 0 } }; +const why = held.attribution(stripped); +check("the attribution names the design's own figure", why.before, fresh); +check("stripping plate costs it", why.points, (n) => n < 0); +check( + "armour is the biggest loss", + why.changes[0].label, + (label) => label === "armour", +); +const summed = why.changes.reduce((n, c) => n + c.points, 0) + why.residual; +check("the parts sum to the difference", Math.abs(summed - why.points) < 1e-6, true); +check("a factor is marked as one", why.changes.every((c) => typeof c.isFactor === "boolean"), true); +check("an untouched design attributes to nothing", held.attribution().changes.length, 0); + held.free(); throws("a freed design", () => held.battleValue(), -6); +throws("a freed design has no attribution", () => held.attribution(), -6); // Re-scoring the same design many times must not leak the memory it borrows. for (let i = 0; i < 5000; i++) { diff --git a/crates/helm-wasm/src/lib.rs b/crates/helm-wasm/src/lib.rs index a113af2..bbfdab0 100644 --- a/crates/helm-wasm/src/lib.rs +++ b/crates/helm-wasm/src/lib.rs @@ -531,6 +531,83 @@ pub unsafe extern "C" fn helm_design_base_value( unsafe { helm_design_battle_value(handle, condition, condition_len, 0, 4, 5) } } +/// Where a held design's battle value went, term by term, as JSON. +/// +/// Both conditions are given, so the same call answers "what did this match +/// cost me" against a factory-fresh machine and "what did that turn cost me" +/// against the one before it. An empty condition is a machine as it left the +/// factory, which is the first of those. +/// +/// The shape is `{before, after, points, residual, changes: [{term, label, +/// isFactor, before, after, points}]}`, with only the terms that moved, worst +/// loss first. `points` on a change is battle value points and is negative for +/// a loss; the changes plus `residual` equal `after - before` exactly, so a +/// page can show a bar per term and have the bars add up. +/// +/// Returns a pointer and a length packed into one `i64`, or a negative error. +/// The caller owns the bytes and must free them. +/// +/// # Safety +/// Each condition pointer must point to its own length in readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn helm_design_attribution( + handle: i64, + from: *const u8, + from_len: usize, + to: *const u8, + to_len: usize, +) -> i64 { + let from = match unsafe { read_condition(from, from_len) } { + Ok(condition) => condition, + Err(code) => return code, + }; + let to = match unsafe { read_condition(to, to_len) } { + Ok(condition) => condition, + Err(code) => return code, + }; + DESIGNS.with(|d| { + let designs = d.borrow(); + let Some(Some(unit)) = usize::try_from(handle - 1) + .ok() + .and_then(|i| designs.get(i)) + else { + return ERR_NO_DESIGN; + }; + CATALOGUE.with(|c| match c.borrow().as_ref() { + None => ERR_NO_CATALOGUE, + Some(catalogue) => match helm_bv::attribute(unit, catalogue, &from, &to) { + Err(_) => ERR_UNSUPPORTED, + Ok(a) => { + let changes: Vec = a + .changes + .iter() + .map(|c| { + serde_json::json!({ + "term": format!("{:?}", c.term), + "label": c.term.label(), + "isFactor": c.term.is_factor(), + "before": c.before, + "after": c.after, + "points": c.points, + }) + }) + .collect(); + give( + serde_json::json!({ + "before": a.before, + "after": a.after, + "points": a.points(), + "residual": a.residual, + "changes": changes, + }) + .to_string(), + ) + } + }, + }) + }) +} + /// What a crew of this skill multiplies a battle value by. /// /// A regular 4/5 crew is exactly one. Combined as -- 2.51.2