diff --git a/crates/helm-wasm/helm.mjs b/crates/helm-wasm/helm.mjs index 0a956b3..12dd3d8 100644 --- a/crates/helm-wasm/helm.mjs +++ b/crates/helm-wasm/helm.mjs @@ -28,6 +28,7 @@ const ERRORS = new Map([ [-3n, "the design could not be read"], [-4n, "this crate cannot score that design"], [-5n, "the condition is not the JSON this expects"], + [-6n, "that design has been let go of"], ]); export class HelmError extends Error { @@ -113,6 +114,20 @@ export class Helm { ); } + /** + * Read a design once and keep it, for asking about repeatedly. + * + * Reading the `.mtf` is most of what scoring costs, so an application that + * re-scores as somebody drags a slider should hold one of these rather than + * pass the text again each time. Call `free()` when done with it. + */ + design(mtf) { + 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)); + } + /** Copy text into the module, run `use`, and give the memory back. */ #withBytes(text, use) { const bytes = new TextEncoder().encode(text); @@ -131,6 +146,58 @@ export class Helm { } } +/** One design, read and held, ready to be asked about. */ +export class Design { + #api; + #handle; + #withBytes; + /** The last condition asked about, and what it came to. */ + #baseFor = null; + #base = 0; + + constructor(api, handle, withBytes) { + this.#api = api; + this.#handle = handle; + this.#withBytes = withBytes; + } + + /** + * What it is worth, in a condition and with a crew. See Helm#battleValue. + * + * The base is worked out once per condition and kept, because the crew and + * the force bonus do not change it - they scale and shift it. That is what + * makes re-crewing a whole force free, which is the interactive case that + * looked most expensive and is not. + */ + battleValue(condition = null, crew = {}) { + if (this.#handle === null) { + throw new HelmError(-6n); + } + const { gunnery = 4, piloting = 5, forceBonus = 0 } = crew; + const key = condition ? JSON.stringify(condition) : ""; + if (key !== this.#baseFor) { + this.#base = Number( + check( + this.#withBytes(key, (ptr, len) => + this.#api.helm_design_base_value(this.#handle, len === 0 ? 0 : ptr, len), + ), + ), + ); + this.#baseFor = key; + } + const multiplier = this.#api.helm_skill_multiplier(gunnery, piloting); + return Math.round((this.#base + forceBonus) * multiplier); + } + + /** Let the module forget it. Asking afterwards throws rather than lying. */ + free() { + if (this.#handle !== null) { + this.#api.helm_design_free(this.#handle); + this.#handle = null; + } + } +} + function check(value) { if (typeof value === "bigint" && value < 0n) { throw new HelmError(value); diff --git a/crates/helm-wasm/smoke.mjs b/crates/helm-wasm/smoke.mjs index b5d58c8..89b0318 100644 --- a/crates/helm-wasm/smoke.mjs +++ b/crates/helm-wasm/smoke.mjs @@ -88,6 +88,29 @@ check( // Bad input raises rather than returning a plausible number. throws("an unreadable design", () => helm.battleValue("this is not a mek"), -3); +// A held design must answer exactly as the one-shot call does, including the +// rounding: the crew multiplier is applied in JavaScript there and in Rust +// here, and the two have to agree to the point. +const held = helm.design(mtf); +let heldWrong = 0; +for (const crew of [ + { gunnery: 4, piloting: 5 }, + { gunnery: 3, piloting: 4 }, + { gunnery: 5, piloting: 6 }, + { gunnery: 0, piloting: 0 }, + { gunnery: 3, piloting: 4, forceBonus: 137 }, +]) { + for (const condition of [null, { armor: { CT: 3 } }, { structure: { LT: 2 } }]) { + if (held.battleValue(condition, crew) !== helm.battleValue(mtf, condition, crew)) { + heldWrong++; + } + } +} +check("a held design answers identically", heldWrong, 0); + +held.free(); +throws("a freed design", () => held.battleValue(), -6); + // Re-scoring the same design many times must not leak the memory it borrows. for (let i = 0; i < 5000; i++) { helm.battleValue(mtf, { armor: { CT: i % 40 } }); @@ -118,16 +141,23 @@ if (damaged && designs) { for (const [where, slot] of pairs) (out[where] ??= []).push(slot); return out; }; - const ours = helm.battleValue(text, { + const state = { armor: row.armor, structure: row.structure, destroyed: group(row.destroyed), emptyAmmo: group(row.emptyAmmo), - }); + }; + const ours = helm.battleValue(text, state); + // Both paths, since an application will use the held one. + const design = helm.design(text); + const heldValue = design.battleValue(state); + design.free(); checked++; - if (ours !== row.bv) { + if (ours !== row.bv || heldValue !== row.bv) { wrong++; - console.log(`FAIL ${row.name} ${row.scenario}: ours ${ours}, megamek ${row.bv}`); + console.log( + `FAIL ${row.name} ${row.scenario}: ours ${ours}, held ${heldValue}, megamek ${row.bv}`, + ); } } check(`${checked} damaged designs match MegaMek`, wrong, 0); diff --git a/crates/helm-wasm/src/lib.rs b/crates/helm-wasm/src/lib.rs index 17c9ad8..d75235f 100644 --- a/crates/helm-wasm/src/lib.rs +++ b/crates/helm-wasm/src/lib.rs @@ -43,9 +43,19 @@ pub const ERR_UNREADABLE: i64 = -3; pub const ERR_UNSUPPORTED: i64 = -4; /// The state argument is not the JSON this expects. pub const ERR_BAD_STATE: i64 = -5; +/// The handle names no design this is holding. +pub const ERR_NO_DESIGN: i64 = -6; +// The catalogue, and the designs a caller is holding on to. +// +// Reading a `.mtf` is most of what scoring one costs - the arithmetic is cheap +// and the parsing is not - and an application asks the same design a great +// many questions: what is it worth now, now that it has been shot, now with a +// veteran in it. Designs are kept in a slab because a handle crossing the +// boundary has to be a number. thread_local! { static CATALOGUE: RefCell> = const { RefCell::new(None) }; + static DESIGNS: RefCell>> = const { RefCell::new(Vec::new()) }; } /// Hand out `len` bytes for the caller to write into. @@ -132,6 +142,116 @@ pub extern "C" fn helm_catalogue_len() -> i64 { }) } +/// Read a design and keep it, returning a handle to ask about later. +/// +/// Negative on failure, and otherwise a handle that stays valid until it is +/// passed to `helm_design_free`. +/// +/// # Safety +/// `ptr` must point to `len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn helm_design_load(ptr: *const u8, len: usize) -> i64 { + let Some(mtf) = (unsafe { text(ptr, len) }) else { + return ERR_NOT_UTF8; + }; + let Ok(unit) = helm_unitfile::parse_mtf("", mtf) else { + return ERR_UNREADABLE; + }; + DESIGNS.with(|d| { + let mut designs = d.borrow_mut(); + // A freed slot is reused so that a page holding a lance and swapping + // designs all afternoon does not grow the slab without bound. + let handle = match designs.iter().position(|slot| slot.is_none()) { + Some(free) => { + designs[free] = Some(unit); + free + } + None => { + designs.push(Some(unit)); + designs.len() - 1 + } + }; + // Handles start at one so that zero is never a valid one. + handle as i64 + 1 + }) +} + +/// Let go of a design read by `helm_design_load`. +#[unsafe(no_mangle)] +pub extern "C" fn helm_design_free(handle: i64) { + DESIGNS.with(|d| { + let mut designs = d.borrow_mut(); + if let Some(slot) = usize::try_from(handle - 1) + .ok() + .and_then(|i| designs.get_mut(i)) + { + *slot = None; + } + }); +} + +/// What a design already read is worth, in a condition and with a crew. +/// +/// The same question as `helm_battle_value_crewed` without re-reading the +/// design, which is most of what that call costs. +/// +/// # Safety +/// `condition` must point to `condition_len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn helm_design_battle_value( + handle: i64, + condition: *const u8, + condition_len: usize, + force_bonus: i64, + gunnery: u8, + piloting: u8, +) -> i64 { + let condition = match unsafe { read_condition(condition, condition_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; + }; + score(unit, &condition, force_bonus, gunnery, piloting) + }) +} + +/// What a design already read is worth before the force and the crew have +/// their say. +/// +/// The base is the expensive scope and the only one that depends on the +/// design: a crew multiplies it and a network adds to it. An application that +/// re-scores as somebody changes a pilot should ask for this once per +/// condition and combine it with `helm_skill_multiplier`, which is what makes +/// re-crewing a force cost nothing at all. +/// +/// # Safety +/// `condition` must point to `condition_len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn helm_design_base_value( + handle: i64, + condition: *const u8, + condition_len: usize, +) -> i64 { + unsafe { helm_design_battle_value(handle, condition, condition_len, 0, 4, 5) } +} + +/// What a crew of this skill multiplies a battle value by. +/// +/// A regular 4/5 crew is exactly one. Combined as +/// `round((base + force_bonus) * multiplier)`, which is the order MegaMek +/// uses - the bonus is inside the multiplication, not added after it. +#[unsafe(no_mangle)] +pub extern "C" fn helm_skill_multiplier(gunnery: u8, piloting: u8) -> f64 { + helm_force::bv_skill_multiplier(gunnery, piloting) +} + /// The battle value of a design as it left the factory, from its `.mtf` text. /// /// # Safety @@ -194,9 +314,21 @@ pub unsafe extern "C" fn helm_battle_value_crewed( return ERR_UNREADABLE; }; + score(&unit, &condition, force_bonus, gunnery, piloting) +} + +/// The one place the four scopes are combined, so both entry points answer +/// the same question the same way. +fn score( + unit: &helm_core::Unit, + condition: &helm_bv::Condition, + force_bonus: i64, + gunnery: u8, + piloting: u8, +) -> i64 { CATALOGUE.with(|c| match c.borrow().as_ref() { None => ERR_NO_CATALOGUE, - Some(catalogue) => match helm_bv::battle_value_in(&unit, catalogue, &condition) { + Some(catalogue) => match helm_bv::battle_value_in(unit, catalogue, condition) { Ok(bv) => helm_force::adjusted_battle_value_in_force( bv, force_bonus,