From 951e29635c8d9230e31b9c8f7280e55afea9f6dc Mon Sep 17 00:00:00 2001 From: @permadeath.com Date: Wed, 19 Aug 2026 01:16:21 +0000 Subject: [PATCH] feat(unit-rules): equipment whose value depends on the Mek carrying it Battle value from 3,206 to 3,410 of 4,294 exact. All of it came from the cluster report rather than from reading the worst-offender list. MegaMek stores Integer.MIN_VALUE where a value depends on the design - a hatchet is worth a fifth of the Mek swinging it, doubled with triple-strength myomer - and helm was discarding that sentinel as a bad number instead of reading it as "work this out". The same sentinel marks variable *size*, which is why a Berserker came out holding seven hatchets: seven slots divided by a size of one. Also: a supercharger carries the MASC flag, so "has MASC" was true for designs with only a supercharger and every one of them got the pair's run multiplier; hardened plate costs a point of movement; and an IndustrialMek with an industrial cockpit has no advanced fire control and is worth nine tenths of its offensive rating. The equipment dump now reads the subtype enums as well as the F_ constants, which is the only thing separating a hatchet from a shield. Change-Id: Id9e5f026c8dfe3bafd777b073648438ab1f5915b --- TODO.md | 6 +++--- bridge/DumpEquipment.java | 56 ++++++++++++++++++++++++++++++++++++++++++++++---------- crates/helm-bv/conformance.txt | 42 +++++++++++++++++++++--------------------- crates/helm-bridge/src/lib.rs | 7 ++++--- crates/helm-bv/src/defensive.rs | 3 ++- crates/helm-bv/src/equipment.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- crates/helm-bv/src/loadout.rs | 49 +++++++++++++++++++++++++++++++++++++++---------- crates/helm-bv/src/mek.rs | 33 ++++++++++++++++++++++++++++----- crates/helm-bv/src/offensive.rs | 8 +++++++- crates/helm-core/src/catalogue.rs | 6 ++++++ 10 file(s) changed, 217 insertion(s)(+), 59 deletion(s)(-) diff --git a/TODO.md b/TODO.md --- a/TODO.md +++ b/TODO.md @@ -174,7 +174,7 @@ 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.** 3,835 of 4,294 Meks match MegaMek exactly. +- [~] **Defensive battle rating.** 3,882 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 @@ 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.** 3,467 of 4,294 match. Weapons at catalogue +- [~] **Offensive battle rating.** 3,714 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 @@ 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.** 3,206 of 4,294 designs match MegaMek +- [~] **Battle value end to end.** 3,410 of 4,294 designs match MegaMek exactly. The two ratings are summed and the total rounded once, which is the last place a half point can change an answer. diff --git a/bridge/DumpEquipment.java b/bridge/DumpEquipment.java --- a/bridge/DumpEquipment.java +++ b/bridge/DumpEquipment.java @@ -160,7 +160,18 @@ */ private static List flagNames(EquipmentType et) { List out = new ArrayList<>(); - for (Class holder : new Class[] { MiscType.class, WeaponType.class }) { + // MiscType and WeaponType hold the F_ constants; the subtypes that tell + // a hatchet from a shield live on their own enums, and a club carries + // both kinds. Missing the enums makes every club look identical. + for (Class holder : flagHolders()) { + if (holder.isEnum()) { + for (Object flag : holder.getEnumConstants()) { + if (asks(et, flag)) { + out.add(holder.getSimpleName() + "." + flag); + } + } + continue; + } for (Field f : holder.getDeclaredFields()) { // S_ constants as well as F_: MegaMek stores an item's subtype // in the same bit set, and that is the only thing telling a @@ -175,15 +186,7 @@ if (flag == null) { continue; } - Method hasFlag = null; - for (Method m : EquipmentType.class.getMethods()) { - if (m.getName().equals("hasFlag") && m.getParameterCount() == 1 - && m.getParameterTypes()[0].isInstance(flag)) { - hasFlag = m; - break; - } - } - if (hasFlag != null && Boolean.TRUE.equals(hasFlag.invoke(et, flag))) { + if (asks(et, flag)) { out.add(holder.getSimpleName() + "." + f.getName()); } } catch (Throwable ignored) { @@ -193,6 +196,39 @@ } } return out; + } + + /** The classes carrying flag constants, skipping any a release has moved. */ + private static List> flagHolders() { + List> out = new ArrayList<>(); + out.add(MiscType.class); + out.add(WeaponType.class); + for (String name : new String[] { + "megamek.common.equipment.enums.MiscTypeFlag", + "megamek.common.equipment.WeaponTypeFlag" }) { + try { + out.add(Class.forName(name)); + } catch (Throwable ignored) { + // A release that moves or drops a flag holder drops its column + // rather than failing the run. + } + } + return out; + } + + /** Whether the equipment carries this flag, whatever type the flag is. */ + private static boolean asks(EquipmentType et, Object flag) { + for (Method m : EquipmentType.class.getMethods()) { + if (m.getName().equals("hasFlag") && m.getParameterCount() == 1 + && m.getParameterTypes()[0].isInstance(flag)) { + try { + return Boolean.TRUE.equals(m.invoke(et, flag)); + } catch (Throwable ignored) { + return false; + } + } + } + return false; } private static void val(StringBuilder sb, Object v, int depth) { diff --git a/crates/helm-bv/conformance.txt b/crates/helm-bv/conformance.txt --- a/crates/helm-bv/conformance.txt +++ b/crates/helm-bv/conformance.txt @@ -4,9 +4,9 @@ 10988 designs with a MegaMek answer, 4294 of a type this scores - battle value 3206 match 1068 differ 0 not computed 74.7% - defensive rating 3835 match 439 differ 0 not computed 89.3% - offensive rating 3467 match 807 differ 0 not computed 80.7% + battle value 3410 match 864 differ 0 not computed 79.4% + defensive rating 3882 match 392 differ 0 not computed 90.4% + offensive rating 3714 match 560 differ 0 not computed 86.5% out of scope, by unit type (6694 designs) 502 AeroSpaceFighter @@ -35,19 +35,19 @@ 4 not implemented: this structure type worst disagreements: battle value - 9 off by 1 or less - 485 within 5% - 523 within 20% - 51 further out + 2 off by 1 or less + 353 within 5% + 468 within 20% + 41 further out -39.3% Waneta S-WN-2LAM ours 1675.0 megamek 2760.0 -36.3% Ryoken III-XP (Skinwalker) A ours 2495.0 megamek 3915.0 -35.2% Pwwka S-PW-1LAM ours 1081.0 megamek 1668.0 -35.1% Stinger LAM STG-A5 ours 696.0 megamek 1073.0 - -34.6% Ryoken III-XP (Skinwalker) D ours 1657.0 megamek 2533.0 -34.0% Wasp LAM WSP-105M ours 599.0 megamek 907.0 -33.8% Ryoken III-XP (Skinwalker) C ours 2992.0 megamek 4519.0 -33.6% Ryoken III-XP (Skinwalker) Prime ours 2034.0 megamek 3063.0 -33.4% Waneta S-WN-1LAM ours 1464.0 megamek 2198.0 + -33.3% Ryoken III-XP (Skinwalker) D ours 1690.0 megamek 2533.0 -32.9% Stinger LAM STG-A10 ours 591.0 megamek 881.0 -32.7% Wasp LAM WSP-105 ours 548.0 megamek 814.0 -32.6% Phoenix Hawk LAM Mk I PHX-HK1 ours 1309.0 megamek 1942.0 @@ -58,13 +58,13 @@ -31.5% Phoenix Hawk LAM PHX-HK1RB ours 1233.0 megamek 1800.0 -30.7% Phoenix Hawk LAM PHX-HK2 ours 1077.0 megamek 1555.0 -30.7% Yurei S-YR-1LAM ours 1460.0 megamek 2106.0 - -30.5% Phoenix Hawk LAM PHX-HK2M ours 1018.0 megamek 1465.0 - ... and 1048 more + -30.5% Ryoken III-XP (Skinwalker) B ours 2545.0 megamek 3663.0 + ... and 844 more worst disagreements: defensive rating 105 within 5% - 318 within 20% - 16 further out + 270 within 20% + 17 further out -38.0% Hercules HRC-LS-9004 ours 579.3 megamek 934.9 -31.1% Quasimodo QSM-3D ours 586.0 megamek 850.7 -25.6% Spatha SP2-X 'Warlord' ours 1036.3 megamek 1392.4 @@ -79,19 +79,19 @@ -23.5% Waneta S-WN-2LAM ours 642.2 megamek 839.8 -21.9% Jenner JR10-X ours 607.9 megamek 778.8 -21.7% Great Turtle GTR-2 ours 1312.2 megamek 1676.8 + -20.2% Volkh VKH-68 ours 793.8 megamek 994.9 -20.0% UrbanMech LAM S-UM-1XLA ours 557.4 megamek 696.8 -20.0% UrbanMech LAM UM-A07 ours 395.4 megamek 494.2 -18.8% Wasp LAM WSP-110 ours 309.7 megamek 381.2 -18.8% Yurei S-YR-1LAM ours 572.6 megamek 704.8 -18.8% Phoenix Hawk LAM Mk I PHX-HK1 ours 610.4 megamek 751.2 - -18.8% Phoenix Hawk LAM Mk I PHX-HK1R ours 649.4 megamek 799.2 - ... and 419 more + ... and 372 more worst disagreements: offensive rating - 5 off by 1 or less - 178 within 5% - 444 within 20% - 180 further out + 3 off by 1 or less + 100 within 5% + 359 within 20% + 98 further out -50.0% Waneta S-WN-2LAM ours 1032.8 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 @@ -109,7 +109,7 @@ -41.3% Wasp LAM Mk I WSP-100b ours 197.1 megamek 336.0 -41.3% Yurei S-YR-1LAM ours 887.0 megamek 1512.0 -41.3% Wasp LAM WSP-110 ours 151.4 megamek 258.0 - +41.1% Slagmaiden SLG-X ours 908.5 megamek 643.8 - -38.8% Neanderthal NTL-AG ours 616.5 megamek 1008.0 -36.8% Wasp LAM Mk I WSP-100 ours 130.4 megamek 206.4 - ... and 787 more + -36.8% Screamer LAM SCR-1X-LAM ours 462.9 megamek 732.7 + +35.0% Exterminator EXT-4C ours 670.0 megamek 496.1 + ... and 540 more diff --git a/crates/helm-bridge/src/lib.rs b/crates/helm-bridge/src/lib.rs --- a/crates/helm-bridge/src/lib.rs +++ b/crates/helm-bridge/src/lib.rs @@ -170,10 +170,11 @@ // EquipmentType calls this criticalSlots, not criticals. Reading the // wrong key silently left every entry's slot count null. criticals: i(v, "criticalSlots"), - // MegaMek uses Integer.MIN_VALUE as "this has no battle value of its - // own", and taking it at face value makes an item worth minus two - // billion points. + // MegaMek uses Integer.MIN_VALUE to mean "this item's value depends on + // the design carrying it, so work it out there". Taking it at face + // value makes a hatchet worth minus two billion points. battle_value: f(v, "bv").filter(|b| *b > i32::MIN as f64), + variable_battle_value: f(v, "bv").is_some_and(|b| b <= i32::MIN as f64), cost: f(v, "cost"), damage_capacity: i(v, "baseDamageCapacity").filter(|c| *c > 0), flags: v diff --git a/crates/helm-bv/src/defensive.rs b/crates/helm-bv/src/defensive.rs --- a/crates/helm-bv/src/defensive.rs +++ b/crates/helm-bv/src/defensive.rs @@ -103,7 +103,8 @@ if !equipment::counts_as_defensive(mounting.entry) { continue; } - total += mounting.entry.battle_value.unwrap_or(0.0) * mounting.count as f64; + total += + equipment::battle_value(mounting.entry, mek.tons, mek.has_tsm) * mounting.count as f64; } // Anti-missile ammunition is worth having, but only as much as the diff --git a/crates/helm-bv/src/equipment.rs b/crates/helm-bv/src/equipment.rs --- a/crates/helm-bv/src/equipment.rs +++ b/crates/helm-bv/src/equipment.rs @@ -48,10 +48,46 @@ /// A shield is a club that is carried to be hit rather than to hit. /// -/// Upstream tells them apart by a subtype stored in the same bit set as the -/// flags but not reachable as one, so the name is the discriminator available. +/// Both are `F_CLUB`; only the subtype separates them. pub fn is_shield(entry: &EquipmentEntry) -> bool { - entry.has_flag("F_CLUB") && helm_core::normalize(&entry.internal_name).contains("shield") + ["S_SHIELD_SMALL", "S_SHIELD_MEDIUM", "S_SHIELD_LARGE"] + .iter() + .any(|f| entry.has_flag(f)) +} + +/// What a piece of equipment is worth to *this* design. +/// +/// Most equipment is worth what the catalogue says. A melee weapon is not: a +/// hatchet is worth a fifth of the Mek swinging it, so MegaMek stores a +/// sentinel instead of a number and works the value out per unit. Triple +/// strength myomer doubles all of them, which is most of what it is for. +pub fn battle_value(entry: &EquipmentEntry, tons: f64, tsm: bool) -> f64 { + if !entry.variable_battle_value { + return entry.battle_value.unwrap_or(0.0); + } + let tsm_mod = if tsm { 2.0 } else { 1.0 }; + let value = if entry.has_flag("S_HATCHET") { + (tons / 5.0).ceil() * 1.5 + } else if entry.has_flag("S_SWORD") { + (tons / 10.0 + 1.0).ceil() * 1.725 + } else if entry.has_flag("S_RETRACTABLE_BLADE") { + (tons / 10.0).ceil() * 1.725 + } else if entry.has_flag("S_MACE") { + (tons / 4.0).ceil() + } else if entry.has_flag("S_LANCE") { + (tons / 5.0).ceil() + } else if entry.has_flag("S_CLAW") { + (tons / 7.0).ceil() * 1.275 + } else if entry.has_flag("F_TALON") { + // Talons are worth the extra damage they add to a kick, which is half + // of a normal one. + ((tons / 5.0).floor() * 0.5).round() + } else { + 0.0 + }; + // Rounded the way MegaMek rounds it, to keep floating point out of the + // comparison. + (value * tsm_mod * 1000.0).round() / 1000.0 } /// Whether this shoots at somebody. @@ -201,8 +237,13 @@ // Both are F_CLUB and only one of them is swung at people. #[test] fn a_hatchet_is_a_weapon_and_a_shield_is_not() { - let hatchet = entry("Hatchet", &["MiscType"], &["F_CLUB"]); - let shield = entry("ISLargeShield", &["MiscType"], &["F_CLUB"]); + // Both carry F_CLUB; only the subtype separates them. + let hatchet = entry("Hatchet", &["MiscType"], &["F_CLUB", "S_HATCHET"]); + let shield = entry( + "ISLargeShield", + &["MiscType"], + &["F_CLUB", "S_SHIELD_LARGE"], + ); assert!(counts_as_offensive(&hatchet)); assert!(!counts_as_offensive(&shield)); assert!(counts_as_defensive(&shield)); @@ -255,6 +296,21 @@ ..Default::default() }; assert_eq!(explosive_penalty_per_slot(&bin, false), Some(-15.0)); + } + + // A hatchet is worth a fifth of the Mek swinging it, and twice that with + // triple-strength myomer. The catalogue cannot hold either number. + #[test] + fn a_melee_weapon_is_worth_a_share_of_the_mek_carrying_it() { + let mut hatchet = entry("Hatchet", &["MiscType"], &["F_CLUB", "S_HATCHET"]); + hatchet.variable_battle_value = true; + hatchet.battle_value = None; + assert_eq!(battle_value(&hatchet, 100.0, false), 30.0); + assert_eq!(battle_value(&hatchet, 100.0, true), 60.0); + + // Fixed-value equipment ignores all of that. + let ecm = entry("ISGuardianECMSuite", &["MiscType"], &["F_ECM"]); + assert_eq!(battle_value(&ecm, 100.0, true), 10.0); } #[test] diff --git a/crates/helm-bv/src/loadout.rs b/crates/helm-bv/src/loadout.rs --- a/crates/helm-bv/src/loadout.rs +++ b/crates/helm-bv/src/loadout.rs @@ -118,20 +118,30 @@ pub fn counts(&self) -> Vec> { // Keyed by name and facing: a rear-mounted laser is worth half what a // forward one is, so the two cannot be pooled. - let mut slots: BTreeMap<(&str, bool), (&'a EquipmentEntry, usize)> = BTreeMap::new(); + let mut slots: BTreeMap<(&str, bool), (&'a EquipmentEntry, usize, usize)> = BTreeMap::new(); for item in &self.items { let key = (item.entry.internal_name.as_str(), item.rear); - slots.entry(key).or_insert((item.entry, 0)).1 += item.slots; + let e = slots.entry(key).or_insert((item.entry, 0, 0)); + e.1 += item.slots; + e.2 += 1; } slots .into_iter() - .map(|((_, rear), (entry, slots))| { - let per_item = entry.criticals.filter(|c| *c > 0).unwrap_or(1) as usize; - Mounting { - entry, - count: (slots / per_item).max(1), - rear, - } + .map(|((_, rear), (entry, slots, locations))| { + let count = match entry.criticals.filter(|c| *c > 0) { + // Fixed size: how many of it there are is how many slots + // it fills, divided by its size. Counted over the whole + // design, because a King Crab's LB 20-X fills eleven slots + // and does not fit in one arm. + Some(per_item) => (slots / per_item as usize).max(1), + // Variable size: a hatchet takes a slot per fifteen tons, + // so there is no size to divide by. One per location is + // right for everything built this way - clubs, targeting + // computers, MASC - and dividing by one instead armed a + // Berserker with seven hatchets. + None => locations, + }; + Mounting { entry, count, rear } }) .collect() } @@ -183,7 +193,16 @@ internal_name: "CLTargeting Computer".into(), classes: vec!["MiscType".into()], flags: vec!["MiscType.F_TARGETING_COMPUTER".into()], - criticals: Some(4), + // Variable size, as MegaMek records it. + criticals: Some(i32::MIN as i64), + ..Default::default() + }, + EquipmentEntry { + internal_name: "Hatchet".into(), + classes: vec!["MiscType".into()], + flags: vec!["MiscType.F_CLUB".into()], + criticals: Some(i32::MIN as i64), + variable_battle_value: true, ..Default::default() }, EquipmentEntry { @@ -245,6 +264,16 @@ ("Left Arm", &["ISLBXAC20"; 6]), ("Left Torso", &["ISLBXAC20"; 5]), ]); + let counts = Loadout::read(&u, &cat).counts(); + assert_eq!(counts.iter().map(|m| m.count).collect::>(), [1]); + } + + // A hatchet takes a slot per fifteen tons, so there is no size to divide + // its slots by. Seven slots in one arm is one hatchet. + #[test] + fn variable_sized_equipment_is_counted_once_per_location() { + let cat = catalogue(); + let u = unit(&[("Left Arm", &["Hatchet"; 7])]); let counts = Loadout::read(&u, &cat).counts(); assert_eq!(counts.iter().map(|m| m.count).collect::>(), [1]); } diff --git a/crates/helm-bv/src/mek.rs b/crates/helm-bv/src/mek.rs --- a/crates/helm-bv/src/mek.rs +++ b/crates/helm-bv/src/mek.rs @@ -11,7 +11,7 @@ //! By the time [`crate::defensive`] or [`crate::offensive`] runs, everything //! it needs is a number. -use helm_core::{ArmorKind, Catalogue, EngineKind, GyroKind, StructureKind, Unit}; +use helm_core::{ArmorKind, Catalogue, CockpitKind, EngineKind, GyroKind, StructureKind, Unit}; use crate::loadout::Loadout; use crate::{Unsupported, defensive, structure}; @@ -43,6 +43,10 @@ /// Triple-strength myomer, which makes the Mek itself a better club. pub has_tsm: bool, pub has_industrial_tsm: bool, + /// Whether the design can aim properly. An IndustrialMek with an + /// industrial cockpit has no advanced fire control and is worth nine + /// tenths of its offensive rating. + pub has_advanced_fire_control: bool, pub loadout: Loadout<'a>, } @@ -72,14 +76,25 @@ // Sprint gear is part of what a Mek can do when it is trying, and // battle value is worked out on that: a Celerity runs 24 without MASC // and 32 with, which is two steps of movement modifier. - let masc = loadout.has_flag("F_MASC"); + // + // A supercharger carries the MASC flag as well, so "has F_MASC" is + // true for a design with only a supercharger. One or the other doubles + // the walk; both together is two and a half times, and reading the + // flag alone gives every supercharged Mek the pair's figure. let supercharger = loadout.has_named("supercharger"); + let masc = loadout.has_flag("F_MASC") && !supercharger; let sprint = match (masc, supercharger) { (true, true) => 2.5, (true, false) | (false, true) => 2.0, (false, false) => 1.5, }; - let run_mp = (walk as f64 * sprint).ceil() as i64; + // Hardened plate is heavy enough to cost a point of movement. + let hardened = if armor_kind == ArmorKind::Hardened { + 1 + } else { + 0 + }; + let run_mp = ((walk as f64 * sprint).ceil() as i64 - hardened).max(0); let myomer = unit.myomer.as_deref().unwrap_or("").to_ascii_lowercase(); @@ -105,8 +120,16 @@ jump_mp, heat_capacity, move_heat: move_heat(unit, &engine.kind, jump_mp), - has_tsm: myomer.contains("triple"), - has_industrial_tsm: myomer.contains("industrial triple"), + // Read from the equipment rather than the `myomer:` line: the + // line names the myomer a design is built with, and MegaMek asks + // whether a working TSM is fitted, which is what doubles a club. + has_tsm: loadout.has_flag("F_TSM") || myomer.contains("triple"), + has_industrial_tsm: loadout.has_flag("F_INDUSTRIAL_TSM") + || myomer.contains("industrial triple"), + has_advanced_fire_control: !matches!( + unit.cockpit_kind(), + CockpitKind::Industrial | CockpitKind::PrimitiveIndustrial + ), loadout, }) } diff --git a/crates/helm-bv/src/offensive.rs b/crates/helm-bv/src/offensive.rs --- a/crates/helm-bv/src/offensive.rs +++ b/crates/helm-bv/src/offensive.rs @@ -51,6 +51,12 @@ let weight = weight(mek); let subtotal = weapons + ammo + weight; + // A Mek that cannot aim properly is worth nine tenths of what it carries. + let subtotal = if mek.has_advanced_fire_control { + subtotal + } else { + subtotal * 0.9 + }; let factor = speed_factor(speed_factor_mp(mek.run_mp, mek.jump_mp)); Ok(Offensive { weapons, @@ -91,7 +97,7 @@ let mut out = Vec::new(); for mounting in mountings { for _ in 0..mounting.count { - let mut bv = mounting.entry.battle_value.unwrap_or(0.0); + let mut bv = equipment::battle_value(mounting.entry, mek.tons, mek.has_tsm); if mounting.rear { bv /= 2.0; } diff --git a/crates/helm-core/src/catalogue.rs b/crates/helm-core/src/catalogue.rs --- a/crates/helm-core/src/catalogue.rs +++ b/crates/helm-core/src/catalogue.rs @@ -84,6 +84,12 @@ /// Armour points this item carries in its own right. Modular armour is /// the case that matters: it is armour bolted on, and it counts as armour. pub damage_capacity: Option, + /// Whether this item's battle value depends on the design carrying it. + /// + /// A hatchet is worth a fifth of the Mek swinging it, so the catalogue + /// cannot hold a number - MegaMek stores a sentinel and computes the value + /// per unit. Anything with this set has no useful `battle_value`. + pub variable_battle_value: bool, } impl EquipmentEntry { -- tangled.sh