From 775d7e5763b17397e7fb5750ef1a44ca9202efff Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 19 Aug 2026 00:08:49 -0400 Subject: [PATCH] feat(unit-rules): a capacitor is wired to the PPC beside it Capacitors are handed out per location rather than to whichever PPC is worth most, and only the PPC that gets one pays its five points of heat - where before every PPC on the design did, so a Mek with three PPCs and one capacitor ran ten points hotter than it does. The location rule changes no answer on its own: every design in the library that fits a capacitor already fits it beside its PPC. It is in because it is the rule, and because the heat half of it is not free. Change-Id: I7121a1403463170e532f14c78495ab3831f22c6e --- TODO.md | 4 ++-- crates/helm-bv/conformance.txt | 14 ++++++------- crates/helm-bv/src/loadout.rs | 28 +++++++++++++++++++++++++ crates/helm-bv/src/offensive.rs | 36 ++++++++++++++++++++++----------- 4 files changed, 61 insertions(+), 21 deletions(-) diff --git a/TODO.md b/TODO.md index 18e47a7..2c0fba6 100644 --- a/TODO.md +++ b/TODO.md @@ -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,152 of 4,294 match. Weapons at catalogue +- [~] **Offensive battle rating.** 4,154 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,127 of 4,294 designs match MegaMek +- [~] **Battle value end to end.** 4,128 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-bv/conformance.txt b/crates/helm-bv/conformance.txt index d5b29f8..dbf0ee0 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 4127 match 158 differ 0 not computed 96.1% + battle value 4128 match 157 differ 0 not computed 96.1% defensive rating 4232 match 53 differ 0 not computed 98.6% - offensive rating 4152 match 133 differ 0 not computed 96.7% + offensive rating 4154 match 131 differ 0 not computed 96.7% out of scope, by unit type (6694 designs) 502 AeroSpaceFighter @@ -34,7 +34,7 @@ not scored at all, by reason 4 not implemented: this structure type worst disagreements: battle value - 86 within 5% + 85 within 5% 50 within 20% 22 further out -41.6% Waneta S-WN-2LAM ours 1613.0 megamek 2760.0 @@ -57,7 +57,7 @@ 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 138 more + ... and 137 more worst disagreements: defensive rating 20 within 5% @@ -86,8 +86,8 @@ worst disagreements: defensive rating ... and 33 more worst disagreements: offensive rating - 2 off by 1 or less - 35 within 5% + 1 off by 1 or less + 34 within 5% 48 within 20% 48 further out -48.9% Waneta S-WN-2LAM ours 1056.0 megamek 2065.5 @@ -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 113 more + ... and 111 more diff --git a/crates/helm-bv/src/loadout.rs b/crates/helm-bv/src/loadout.rs index 2adbe62..74e7dad 100644 --- a/crates/helm-bv/src/loadout.rs +++ b/crates/helm-bv/src/loadout.rs @@ -57,6 +57,9 @@ pub struct Mounting<'a> { pub arm: bool, /// Wholly in locations carrying an actuator enhancement system. pub aes: bool, + /// Where it is mounted. A fire-control system or a capacitor only helps + /// the weapon it shares a location with, so the pairing needs this. + pub locations: Vec, } /// What one item adds up to across a design, while [`Loadout::counts`] is @@ -64,6 +67,7 @@ pub struct Mounting<'a> { /// fields and `e.3` stopped meaning anything. struct Seen<'a> { entry: &'a EquipmentEntry, + locations_named: Vec, slots: usize, /// Distinct critical slots, which is fewer than `slots` where a superheavy /// has doubled up. @@ -77,6 +81,7 @@ impl<'a> Seen<'a> { fn new(entry: &'a EquipmentEntry) -> Self { Seen { entry, + locations_named: Vec::new(), slots: 0, physical: 0, locations: 0, @@ -297,6 +302,9 @@ impl<'a> Loadout<'a> { e.slots += item.slots; e.physical += item.physical_slots; e.locations += 1; + if !e.locations_named.contains(&item.location) { + e.locations_named.push(item.location.clone()); + } if item.arm { e.arm_slots += item.slots; } @@ -337,6 +345,7 @@ impl<'a> Loadout<'a> { // stuck facing the way it was built. arm: s.arm_slots == s.slots, aes: s.aes_slots == s.slots, + locations: s.locations_named, }) .collect() } @@ -443,6 +452,25 @@ impl<'a> Loadout<'a> { .sum() } + /// How many items answering to a flag sit in each location. + /// + /// For the systems that only help what they share a location with: a PPC + /// capacitor is wired to the PPC beside it, not to the best PPC on the + /// design. + pub fn count_by_location(&self, flag: &str) -> BTreeMap<&str, usize> { + let mut out: BTreeMap<&str, usize> = BTreeMap::new(); + for item in self.items.iter().filter(|i| i.entry.has_flag(flag)) { + let per_item = item + .entry + .criticals + .filter(|c| *c > 0) + .map(|c| (c as usize).div_ceil(self.per_slot).max(1)) + .unwrap_or(1); + *out.entry(item.location.as_str()).or_default() += (item.slots / per_item).max(1); + } + out + } + /// How many bins of one ammunition family the design carries. pub fn ammo_count(&self, family: &str) -> i64 { self.ammo() diff --git a/crates/helm-bv/src/offensive.rs b/crates/helm-bv/src/offensive.rs index 2700dfb..6648697 100644 --- a/crates/helm-bv/src/offensive.rs +++ b/crates/helm-bv/src/offensive.rs @@ -77,10 +77,9 @@ pub fn offensive(mek: &Mek<'_>) -> Result { /// quarter more for anything a targeting computer can help. fn arm<'a>(mek: &Mek<'a>) -> Vec> { let targeting_computer = mek.loadout.has_flag("F_TARGETING_COMPUTER"); - let capacitor = mek.loadout.has_ppc_capacitor(); - // Capacitors are fitted one to a PPC, so only as many PPCs get the bonus - // as there are capacitors. - let mut capacitors = mek.loadout.count_of("F_PPC_CAPACITOR"); + // A capacitor is wired to the PPC it shares a location with, not to + // whichever PPC is worth most - so they are handed out per location. + let mut capacitors = mek.loadout.count_by_location("F_PPC_CAPACITOR"); // Machine gun arrays are worth two thirds of the guns wired into them, // and have no value of their own, so their figure is worked out from the @@ -126,13 +125,26 @@ fn arm<'a>(mek: &Mek<'a>) -> Vec> { if targeting_computer && mounting.entry.has_flag("F_DIRECT_FIRE") { bv *= 1.25; } - if capacitors > 0 - && let Some(bonus) = equipment::ppc_capacitor_bonus(mounting.entry) + // A capacitor is wired to the PPC beside it, and only that PPC + // gains its value or pays its heat. + let mut capacitor_fitted = false; + if let Some(bonus) = equipment::ppc_capacitor_bonus(mounting.entry) + && let Some(location) = mounting + .locations + .iter() + .find(|l| capacitors.get(l.as_str()).is_some_and(|n| *n > 0)) { + *capacitors.get_mut(location.as_str()).unwrap() -= 1; + capacitor_fitted = true; bv += bonus; - capacitors -= 1; } - armed.push((mounting.rear, mounting.arm, mounting.entry, bv)); + armed.push(( + mounting.rear, + mounting.arm, + mounting.entry, + bv, + capacitor_fitted, + )); } } @@ -145,22 +157,22 @@ fn arm<'a>(mek: &Mek<'a>) -> Vec> { let arc = |rear: bool| -> f64 { armed .iter() - .filter(|(r, arm, _, _)| !arm && *r == rear) - .map(|(_, _, _, bv)| bv) + .filter(|(r, arm, _, _, _)| !arm && *r == rear) + .map(|(_, _, _, bv, _)| bv) .sum() }; let switch_arcs = arc(false) < arc(true); armed .into_iter() - .map(|(rear, arm, entry, bv)| { + .map(|(rear, arm, entry, bv, capacitor_fitted)| { // Rear-facing for battle value is not the same as mounted // backwards: an arm never is, and the arcs may have been swapped. let nominal_rear = !arm && (switch_arcs != rear); Armed { entry, bv: if nominal_rear { bv / 2.0 } else { bv }, - heat: equipment::weapon_heat(entry, capacitor), + heat: equipment::weapon_heat(entry, capacitor_fitted), } }) .collect() -- 2.54.0