From d7e11043071f64abc10319402a58ed82d62f265e Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 19 Aug 2026 00:14:52 -0400 Subject: [PATCH] feat(unit-rules): a shield costs walking, and movement is checked on its own Battle value 4,136 of 4,294 exact. A shield is heavy and in the way: it takes a point of walking as well as the point of jumping already counted, and a large one stops the design jumping at all rather than merely slowing it. The test is the other half of this. Running and jumping feed the defensive movement factor and the offensive speed factor both, so a design whose movement is wrong is wrong twice over and neither rating says which. Checking it on its own puts 4,280 of 4,294 designs right and names the fourteen that are not - which is how the shields were found, five Black Knights running a point too fast with a medium shield each. It also pins the interaction worth pinning: triple-strength myomer adds its point of walking *before* MASC and a supercharger multiply, so the three compound rather than being taken in isolation. Change-Id: Ia64956d77eac92ce5a9384b2594095c06d6db55c --- TODO.md | 6 ++-- crates/helm-bridge/src/lib.rs | 2 ++ crates/helm-bv/conformance.txt | 22 ++++++------ crates/helm-bv/src/conformance.rs | 4 +++ crates/helm-bv/src/mek.rs | 23 ++++++++---- crates/helm-bv/tests/conformance.rs | 54 +++++++++++++++++++++++++++++ crates/helm-core/src/bv.rs | 5 +++ 7 files changed, 96 insertions(+), 20 deletions(-) diff --git a/TODO.md b/TODO.md index 2c0fba6..87b7c04 100644 --- a/TODO.md +++ b/TODO.md @@ -174,7 +174,7 @@ The long pole, and the reason `helm-core` does no I/O. Tracked as built to avoid. It costs a second pass over the library because the calculator needs a real Entity rather than the summary cache. -- [~] **Defensive battle rating.** 4,232 of 4,294 Meks match MegaMek exactly. +- [~] **Defensive battle rating.** 4,235 of 4,294 Meks match MegaMek exactly. Armour at 2.5 a point and internal structure at 1.5, each through its own type multiplier, plus the gyro at half a ton, defensive equipment at catalogue value, minus 15 a slot for ammunition that can cook off, all @@ -193,7 +193,7 @@ The long pole, and the reason `helm-core` does no I/O. Tracked as within 20%. Counting equipment per critical slot rather than per item was worth 200 designs on its own: a large shield fills seven slots and was being paid for seven times. -- [~] **Offensive battle rating.** 4,154 of 4,294 match. Weapons at catalogue +- [~] **Offensive battle rating.** 4,162 of 4,294 match. Weapons at catalogue value, halved once the design runs out of heat, plus ammunition capped by the weapons that eat it, plus the Mek's own mass, all through the speed factor. @@ -202,7 +202,7 @@ The long pole, and the reason `helm-core` does no I/O. Tracked as whatever they are worth, and only then by descending battle value. A machine gun costs nothing to fire, so it never competes for the heat budget, and sorting on value alone charges it as though it did. -- [~] **Battle value end to end.** 4,128 of 4,294 designs match MegaMek +- [~] **Battle value end to end.** 4,136 of 4,294 designs match MegaMek exactly. `helm bv-report --clusters` groups what is left by what the designs have in common, which is how each of the remaining rules was found - the ranked list of worst offenders only ever shows whatever is diff --git a/crates/helm-bridge/src/lib.rs b/crates/helm-bridge/src/lib.rs index 478fdc9..1767107 100644 --- a/crates/helm-bridge/src/lib.rs +++ b/crates/helm-bridge/src/lib.rs @@ -76,6 +76,8 @@ pub fn read_bv(path: &Path) -> Result, Error> { b.battle_value = v.get("bv").and_then(Value::as_i64); b.defensive = v.get("defensiveValue").and_then(Value::as_f64); b.offensive = v.get("offensiveValue").and_then(Value::as_f64); + b.run_mp = v.get("runMP").and_then(Value::as_i64); + b.jump_mp = v.get("jumpMP").and_then(Value::as_i64); out.push(b); } Ok(out) diff --git a/crates/helm-bv/conformance.txt b/crates/helm-bv/conformance.txt index dbf0ee0..94c9d6e 100644 --- a/crates/helm-bv/conformance.txt +++ b/crates/helm-bv/conformance.txt @@ -4,9 +4,9 @@ megamek 0.51.0 10988 designs with a MegaMek answer, 4294 of a type this scores - battle value 4128 match 157 differ 0 not computed 96.1% - defensive rating 4232 match 53 differ 0 not computed 98.6% - offensive rating 4154 match 131 differ 0 not computed 96.7% + battle value 4136 match 149 differ 0 not computed 96.3% + defensive rating 4235 match 50 differ 0 not computed 98.6% + offensive rating 4162 match 123 differ 0 not computed 96.9% out of scope, by unit type (6694 designs) 502 AeroSpaceFighter @@ -35,7 +35,7 @@ not scored at all, by reason worst disagreements: battle value 85 within 5% - 50 within 20% + 42 within 20% 22 further out -41.6% Waneta S-WN-2LAM ours 1613.0 megamek 2760.0 -38.5% Pwwka S-PW-1LAM ours 1026.0 megamek 1668.0 @@ -57,11 +57,11 @@ worst disagreements: battle value -27.7% Screamer LAM SCR-1X-LAM ours 1066.0 megamek 1475.0 -27.4% Wasp LAM Mk I WSP-100b ours 636.0 megamek 876.0 -26.4% Wasp LAM Mk I WSP-100 ours 360.0 megamek 489.0 - ... and 137 more + ... and 129 more worst disagreements: defensive rating 20 within 5% - 23 within 20% + 20 within 20% 10 further out -25.0% Waneta S-WN-1LAM ours 520.8 megamek 694.4 -23.5% Stinger LAM Mk I STG-A1 ours 378.9 megamek 495.6 @@ -83,13 +83,13 @@ worst disagreements: defensive rating -18.8% Screamer LAM SCR-1X-LAM ours 603.2 megamek 742.4 -18.8% Wasp LAM Mk I WSP-100 ours 229.5 megamek 282.4 -18.8% Wasp LAM Mk I WSP-100b ours 438.8 megamek 540.0 - ... and 33 more + ... and 30 more worst disagreements: offensive rating 1 off by 1 or less - 34 within 5% - 48 within 20% - 48 further out + 35 within 5% + 41 within 20% + 46 further out -48.9% Waneta S-WN-2LAM ours 1056.0 megamek 2065.5 -45.1% Pwwka S-PW-1LAM ours 669.1 megamek 1217.8 -45.1% Stinger LAM STG-A10 ours 211.7 megamek 385.3 @@ -110,4 +110,4 @@ worst disagreements: offensive rating -36.8% Wasp LAM Mk I WSP-100 ours 130.4 megamek 206.4 -36.8% Screamer LAM SCR-1X-LAM ours 462.9 megamek 732.7 -34.4% UrbanMech LAM S-UM-1XLA ours 854.0 megamek 1301.7 - ... and 111 more + ... and 103 more diff --git a/crates/helm-bv/src/conformance.rs b/crates/helm-bv/src/conformance.rs index d803934..47d5555 100644 --- a/crates/helm-bv/src/conformance.rs +++ b/crates/helm-bv/src/conformance.rs @@ -379,6 +379,7 @@ mod tests { battle_value: bv, defensive: def, offensive: off, + ..Default::default() }) } @@ -398,6 +399,7 @@ mod tests { battle_value: Some(1897), defensive: Some(1155.6), offensive: Some(741.5), + ..Default::default() }; c.observe( "Atlas AS7-D", @@ -425,6 +427,7 @@ mod tests { battle_value: Some(1897), defensive: Some(1155.6), offensive: Some(741.5), + ..Default::default() }; c.observe( "Atlas AS7-D", @@ -473,6 +476,7 @@ mod tests { battle_value: Some(1897), defensive: Some(1155.6), offensive: Some(741.5), + ..Default::default() }; c.observe( "Atlas AS7-D", diff --git a/crates/helm-bv/src/mek.rs b/crates/helm-bv/src/mek.rs index be82dd1..071c5fb 100644 --- a/crates/helm-bv/src/mek.rs +++ b/crates/helm-bv/src/mek.rs @@ -127,6 +127,12 @@ impl<'a> Mek<'a> { let has_industrial_tsm = loadout.has_flag("F_INDUSTRIAL_TSM") || myomer == Myomer::IndustrialTripleStrength; let walk = walk + i64::from(has_tsm && !has_industrial_tsm); + // A shield is heavy and in the way: it costs walking as well as + // jumping. + let walk = (walk + - loadout.count_of("S_SHIELD_MEDIUM") as i64 + - loadout.count_of("S_SHIELD_LARGE") as i64) + .max(0); // Hardened plate is heavy enough to cost a point of movement. let hardened = if armor_kind == ArmorKind::Hardened { @@ -157,12 +163,17 @@ impl<'a> Mek<'a> { }; // Jumping is hampered by what the design is carrying: a medium shield - // costs a point each, and modular armour costs one however much of it - // there is. - let jets_mp = (jump_mp - - loadout.count_of("S_SHIELD_MEDIUM") as i64 - - i64::from(loadout.modular_armor() > 0)) - .max(0); + // costs a point each, modular armour costs one however much of it + // there is, and a large shield stops the design jumping at all rather + // than merely slowing it. + let jets_mp = if loadout.has_flag("S_SHIELD_LARGE") { + 0 + } else { + (jump_mp + - loadout.count_of("S_SHIELD_MEDIUM") as i64 + - i64::from(loadout.modular_armor() > 0)) + .max(0) + }; let jump_mp = jets_mp + wing_jump_bonus; let heat_capacity = unit diff --git a/crates/helm-bv/tests/conformance.rs b/crates/helm-bv/tests/conformance.rs index 9b8461b..df756cb 100644 --- a/crates/helm-bv/tests/conformance.rs +++ b/crates/helm-bv/tests/conformance.rs @@ -144,3 +144,57 @@ fn every_flag_the_rules_use_exists_in_the_catalogue() { keyed on them never fire: {missing:?}" ); } + +/// Our movement must match MegaMek's, design by design. +/// +/// Running and jumping feed the defensive movement factor and the offensive +/// speed factor both, so a design whose movement is wrong is wrong twice over +/// and neither rating says which. Worth checking on its own, and cheap. +/// +/// It is also where the fiddliest interactions live: triple-strength myomer +/// adds a point of walking *before* MASC and a supercharger multiply it, so +/// the three compound; hardened plate costs a point; a partial wing adds jump +/// the file does not declare; and medium shields and modular armour take it +/// away. +#[test] +#[ignore = "needs a MegaMek install and a bridge dump; set HELM_MEGAMEK and HELM_BRIDGE"] +fn movement_matches_megamek() { + let Some(inputs) = inputs() else { + panic!("set HELM_MEGAMEK to a MegaMek install and HELM_BRIDGE to a bridge dump"); + }; + + let mut checked = 0; + let mut wrong: Vec = Vec::new(); + for unit in &inputs.library.units { + let name = unit.display_name(); + let Some(theirs) = inputs.megamek.get(&name) else { + continue; + }; + let (Some(their_run), Some(their_jump)) = (theirs.run_mp, theirs.jump_mp) else { + continue; + }; + let Ok(mek) = helm_bv::Mek::read(unit, &inputs.catalogue) else { + continue; + }; + checked += 1; + if mek.run_mp != their_run || mek.jump_mp != their_jump { + wrong.push(format!( + "{name}: run {} jump {}, megamek run {their_run} jump {their_jump}", + mek.run_mp, mek.jump_mp + )); + } + } + + println!( + "{} of {checked} designs move as MegaMek says", + checked - wrong.len() + ); + for line in wrong.iter().take(20) { + println!(" {line}"); + } + assert!( + wrong.len() * 100 <= checked, + "{} of {checked} designs disagree about movement, which is more than one in a hundred", + wrong.len() + ); +} diff --git a/crates/helm-core/src/bv.rs b/crates/helm-core/src/bv.rs index 0b2c2f4..d8725d1 100644 --- a/crates/helm-core/src/bv.rs +++ b/crates/helm-core/src/bv.rs @@ -24,6 +24,11 @@ pub struct BvBreakdown { pub battle_value: Option, pub defensive: Option, pub offensive: Option, + /// The movement the calculation was worked out on. Not part of the answer, + /// but it feeds both ratings - a design whose run is wrong is wrong twice + /// over, and checking it separately says which. + pub run_mp: Option, + pub jump_mp: Option, } /// How close two ratings have to be to count as the same. -- 2.54.0