From 9c672f888da0ff499127dc4da6a725ed79572cd4 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 19 Aug 2026 19:21:38 -0400 Subject: [PATCH] refactor(unit-rules)!: the structure table belongs to the domain model Moved from helm-bv to helm-core, where anything asking what a design could have been built with can reach it, and given the rule for the other half of that question: two points of armour a location for every point of structure, except a head, which takes nine whatever it is bolted to and twelve on a superheavy - so it is not twice the structure total. --- crates/helm-bv/src/check.rs | 10 +-- crates/helm-bv/src/defensive.rs | 2 +- crates/helm-bv/src/lib.rs | 2 - crates/helm-bv/src/mek.rs | 13 ++-- crates/helm-bv/src/offensive.rs | 2 +- crates/helm-bv/src/repair.rs | 6 +- crates/helm-bv/tests/conformance.rs | 61 ++++++++++++++- crates/helm-core/src/lib.rs | 2 + .../{helm-bv => helm-core}/src/structure.rs | 77 +++++++++++++++---- crates/helm-core/src/unit.rs | 22 ++++++ 10 files changed, 161 insertions(+), 36 deletions(-) rename crates/{helm-bv => helm-core}/src/structure.rs (71%) diff --git a/crates/helm-bv/src/check.rs b/crates/helm-bv/src/check.rs index e4612c0..2dbe9c9 100644 --- a/crates/helm-bv/src/check.rs +++ b/crates/helm-bv/src/check.rs @@ -19,7 +19,7 @@ use helm_core::{Catalogue, Unit}; use helm_unitfile::MulUnit; -use crate::{Condition, structure}; +use crate::Condition; /// Whether a complaint is about the file or about this crate. /// @@ -171,8 +171,8 @@ pub fn check(design: Option<&Unit>, catalogue: &Catalogue, entity: &MulUnit) -> }; if let Some(file) = entity.unit_type.as_deref() { - let ours = structure::Shape::from_config(design.config.as_deref()); - let theirs = structure::Shape::from_config(Some(file)); + let ours = helm_core::Shape::from_config(design.config.as_deref()); + let theirs = helm_core::Shape::from_config(Some(file)); // Only where the file says something this understands: `Biped` against // a biped is agreement, and a `Tank` against a Mek is a different // complaint that scoring already makes. @@ -197,8 +197,8 @@ pub fn check(design: Option<&Unit>, catalogue: &Catalogue, entity: &MulUnit) -> } if let Some(tons) = design.mass { - let shape = structure::Shape::from_config(design.config.as_deref()); - if let Some(built) = structure::per_location(tons, shape) { + let shape = helm_core::Shape::from_config(design.config.as_deref()); + if let Some(built) = helm_core::structure_per_location(tons, shape) { for (location, claimed) in &entity.structure { let Some((_, built)) = built.iter().find(|(l, _)| l == location) else { continue; diff --git a/crates/helm-bv/src/defensive.rs b/crates/helm-bv/src/defensive.rs index bdcfeb4..d00248c 100644 --- a/crates/helm-bv/src/defensive.rs +++ b/crates/helm-bv/src/defensive.rs @@ -355,7 +355,7 @@ fn location_is_exposed(mek: &Mek<'_>, location: &str) -> bool { } let has_case = |l: &str| mek.is_clan || mek.loadout.location_has_case(l); - let quad = mek.shape == crate::structure::Shape::Quad; + let quad = mek.shape == helm_core::Shape::Quad; match loc.as_str() { "left arm" | "right arm" if !quad => { diff --git a/crates/helm-bv/src/lib.rs b/crates/helm-bv/src/lib.rs index 6b1ad0e..c7abb9b 100644 --- a/crates/helm-bv/src/lib.rs +++ b/crates/helm-bv/src/lib.rs @@ -43,7 +43,6 @@ mod mek; mod offensive; mod repair; mod rules; -mod structure; pub use attribution::{Attribution, Change, Term, Terms, attribute, between, terms_in}; pub use check::{Severity, Trouble, check}; @@ -55,7 +54,6 @@ pub use mek::Mek; pub use offensive::{Offensive, offensive, speed_factor}; pub use repair::{Repair, Slot, Total, repairs}; pub use rules::{Adjustments, RULES, Rule, adjustments, fired}; -pub use structure::{Shape, per_location as structure_per_location}; use helm_core::{BvBreakdown, Catalogue, ComputedStats, Unit}; diff --git a/crates/helm-bv/src/mek.rs b/crates/helm-bv/src/mek.rs index 45b1e88..9955588 100644 --- a/crates/helm-bv/src/mek.rs +++ b/crates/helm-bv/src/mek.rs @@ -14,12 +14,12 @@ use helm_core::{ArmorKind, Catalogue, CockpitKind, EngineKind, GyroKind, StructureKind, Unit}; use crate::loadout::Loadout; -use crate::{Unsupported, defensive, structure}; +use crate::{Unsupported, defensive}; /// Everything the two ratings are computed from. pub struct Mek<'a> { pub tons: f64, - pub shape: structure::Shape, + pub shape: helm_core::Shape, /// Clan-built. Its CASE is structural and appears in no critical slot, so /// the explosive rules have to ask this rather than read the slot list. pub is_clan: bool, @@ -90,10 +90,11 @@ impl<'a> Mek<'a> { let loadout = Loadout::read_in(unit, catalogue, condition); let myomer = Myomer::read(unit.myomer.as_deref()); - let shape = structure::Shape::from_config(unit.config.as_deref()); - let structure_points = structure::total_in(tons, shape, &condition.structure).ok_or( - Unsupported::NotImplemented("internal structure at this tonnage"), - )?; + let shape = helm_core::Shape::from_config(unit.config.as_deref()); + let structure_points = helm_core::structure_total_in(tons, shape, &condition.structure) + .ok_or(Unsupported::NotImplemented( + "internal structure at this tonnage", + ))?; let armor_kind = unit.armor_kind(); // A torso-mounted cockpit puts the pilot in the centre torso, and that diff --git a/crates/helm-bv/src/offensive.rs b/crates/helm-bv/src/offensive.rs index 8640382..3c8ec5b 100644 --- a/crates/helm-bv/src/offensive.rs +++ b/crates/helm-bv/src/offensive.rs @@ -400,7 +400,7 @@ fn weight(mek: &Mek<'_>) -> f64 { let tons = mek.tons * mek .loadout - .aes_weight_multiplier(mek.shape == crate::Shape::Quad); + .aes_weight_multiplier(mek.shape == helm_core::Shape::Quad); if mek.has_industrial_tsm { tons * 1.15 } else if mek.has_tsm { diff --git a/crates/helm-bv/src/repair.rs b/crates/helm-bv/src/repair.rs index 13db3f9..dcca60a 100644 --- a/crates/helm-bv/src/repair.rs +++ b/crates/helm-bv/src/repair.rs @@ -12,7 +12,7 @@ use helm_core::Unit; -use crate::{Condition, structure}; +use crate::Condition; /// One thing in a critical slot that needs attention. #[derive(Debug, Clone, PartialEq, Eq)] @@ -65,10 +65,10 @@ impl Repair { /// /// An empty list is a machine that came back the way it left. pub fn repairs(design: &Unit, condition: &Condition) -> Vec { - let shape = structure::Shape::from_config(design.config.as_deref()); + let shape = helm_core::Shape::from_config(design.config.as_deref()); let frame = design .mass - .and_then(|tons| structure::per_location(tons, shape)); + .and_then(|tons| helm_core::structure_per_location(tons, shape)); let mut out = Vec::new(); for (code, name) in helm_unitfile::locations_for_config(design.config.as_deref()) { diff --git a/crates/helm-bv/tests/conformance.rs b/crates/helm-bv/tests/conformance.rs index 9f704ed..2005872 100644 --- a/crates/helm-bv/tests/conformance.rs +++ b/crates/helm-bv/tests/conformance.rs @@ -617,8 +617,8 @@ fn a_stripped_design_needs_everything_it_was_built_with() { let Some(tons) = unit.mass else { continue; }; - let shape = helm_bv::Shape::from_config(unit.config.as_deref()); - let Some(frame) = helm_bv::structure_per_location(tons, shape) else { + let shape = helm_core::Shape::from_config(unit.config.as_deref()); + let Some(frame) = helm_core::structure_per_location(tons, shape) else { continue; }; // Nothing left anywhere: every location the design has, front, rear @@ -656,6 +656,63 @@ fn a_stripped_design_needs_everything_it_was_built_with() { ); } +/// No design carries more plate than the rules let it. +/// +/// The check on `max_armor`, and the only one available: the figure is not in +/// any file, so it cannot be conformed against MegaMek the way a battle value +/// can. What it can be held to is that no design in the library exceeds it - +/// 4,294 designs built by people following the same table, and one of them +/// coming out at 101% would mean the rule here is wrong rather than that the +/// design is. +/// +/// The head is what makes this worth checking. It takes nine points whatever +/// it is bolted to, so the maximum is not twice the structure, and getting +/// that wrong shows every design as carrying slightly less than it does. +#[test] +#[ignore = "needs a MegaMek install and a bridge dump; set HELM_MEGAMEK and HELM_BRIDGE"] +fn nothing_is_armoured_past_what_the_rules_allow() { + 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 full = 0; + let mut over: Vec = Vec::new(); + for unit in &inputs.library.units { + let Some(percent) = unit.armor_percent() else { + continue; + }; + checked += 1; + if percent == 100 { + full += 1; + } + if percent > 100 { + over.push(format!( + "{}: {percent}% - {} points of {:?}", + unit.display_name(), + unit.total_armor(), + unit.mass.and_then(|t| helm_core::max_armor( + t, + helm_core::Shape::from_config(unit.config.as_deref()) + )) + )); + } + } + + println!("{checked} designs measured, {full} armoured to the maximum"); + for line in over.iter().take(20) { + println!(" {line}"); + } + assert!( + over.is_empty(), + "{} designs carry too much plate", + over.len() + ); + // A library where nothing reaches the maximum would mean the figure is + // too high and the percentage is meaningless rather than merely generous. + assert!(full > 0, "no design in the library is fully armoured"); +} + /// Every `.mul` the MegaMek install ships, read, written back, and read again. /// /// The structural round trip is checked in helm-unitfile against files written diff --git a/crates/helm-core/src/lib.rs b/crates/helm-core/src/lib.rs index f2a5dfa..f19e6be 100644 --- a/crates/helm-core/src/lib.rs +++ b/crates/helm-core/src/lib.rs @@ -29,6 +29,7 @@ mod computed; mod design; mod metrics; mod rng; +mod structure; mod unit; pub use bv::{BvBreakdown, RATING_TOLERANCE}; @@ -42,6 +43,7 @@ pub use metrics::{ max_single_damage_at_range, }; pub use rng::Rng; +pub use structure::{Shape, max_armor, structure_per_location, structure_total_in}; pub use unit::{Format, Mount, Unit}; /// Reduce a name to lowercase alphanumerics. diff --git a/crates/helm-bv/src/structure.rs b/crates/helm-core/src/structure.rs similarity index 71% rename from crates/helm-bv/src/structure.rs rename to crates/helm-core/src/structure.rs index c37ff2f..413e70e 100644 --- a/crates/helm-bv/src/structure.rs +++ b/crates/helm-core/src/structure.rs @@ -1,10 +1,15 @@ -//! How much internal structure a Mek has. +//! How much internal structure a Mek has, and how much plate it can carry. //! //! Battle value needs the total, and no unit file declares it: internal //! structure follows from tonnage and from how many limbs the design has, by //! the table in TechManual. Endo Steel and its relatives change what the //! structure *weighs*, never how many points it is. //! +//! It lives here rather than beside the battle value rules because it is a +//! fact about a design rather than a rule about scoring one: armour as a +//! fraction of the maximum needs the same table, and so does anything else +//! that asks what a design could have been built with. +//! //! The table below was written from the published one and then checked against //! MegaMek for all 4,294 Meks in 0.51.0. That found two things worth writing //! down: a QuadVee's structure is a quad's, not a biped's, and a LAM's is a @@ -92,7 +97,7 @@ const TABLE: &[Row] = &[ /// /// `None` for a tonnage the table does not have a row for, which is anything /// that is not a multiple of five between 10 and 200 tons. -pub fn per_location(tons: f64, shape: Shape) -> Option> { +pub fn structure_per_location(tons: f64, shape: Shape) -> Option> { let tons = tons.round() as i64; let (_, head, ct, st, arm, leg) = TABLE.iter().find(|row| row.0 == tons)?; let mut out = vec![("HD", *head), ("CT", *ct), ("LT", *st), ("RT", *st)]; @@ -111,6 +116,28 @@ pub fn per_location(tons: f64, shape: Shape) -> Option> Some(out) } +/// The most armour a design of this tonnage and shape could carry. +/// +/// Two points a location for every point of internal structure, which is the +/// rule for every location but one: a head takes nine whatever it is bolted +/// to, and twelve on a superheavy. The head is why this cannot be written as +/// twice the structure total. +/// +/// Front and rear plate share a torso's allowance rather than each getting +/// one, so a design that armours its back is not carrying more than this - it +/// has moved some of the same points around. +/// +/// `None` for a tonnage the table has no row for. +pub fn max_armor(tons: f64, shape: Shape) -> Option { + let per = structure_per_location(tons, shape)?; + let head = if tons > 100.0 { 12 } else { 9 }; + Some( + per.iter() + .map(|(location, points)| if *location == "HD" { head } else { points * 2 }) + .sum(), + ) +} + /// Total internal structure points, counting the frame the design still has. /// /// MegaMek reads `getTotalInternal()` rather than the figure on the sheet, so @@ -125,13 +152,13 @@ pub fn per_location(tons: f64, shape: Shape) -> Option> /// `getTotalInternal()`, not the figure the design was built with - so a /// cracked torso is worth less. A location the condition does not name is /// whole, and none can be worth more than it was built with. -pub fn total_in( +pub fn structure_total_in( tons: f64, shape: Shape, left: &std::collections::BTreeMap, ) -> Option { Some( - per_location(tons, shape)? + structure_per_location(tons, shape)? .iter() .map(|(loc, built)| left.get(*loc).copied().unwrap_or(*built).clamp(0, *built)) .sum(), @@ -147,14 +174,23 @@ mod tests { #[test] fn totals_match_the_published_table() { assert_eq!( - total_in(100.0, Shape::Biped, &Default::default()), + structure_total_in(100.0, Shape::Biped, &Default::default()), Some(152) ); - assert_eq!(total_in(20.0, Shape::Biped, &Default::default()), Some(33)); - assert_eq!(total_in(70.0, Shape::Biped, &Default::default()), Some(107)); - assert_eq!(total_in(70.0, Shape::Quad, &Default::default()), Some(115)); assert_eq!( - total_in(60.0, Shape::Tripod, &Default::default()), + structure_total_in(20.0, Shape::Biped, &Default::default()), + Some(33) + ); + assert_eq!( + structure_total_in(70.0, Shape::Biped, &Default::default()), + Some(107) + ); + assert_eq!( + structure_total_in(70.0, Shape::Quad, &Default::default()), + Some(115) + ); + assert_eq!( + structure_total_in(60.0, Shape::Tripod, &Default::default()), Some(113) ); } @@ -165,7 +201,10 @@ mod tests { #[test] fn a_quadvee_is_structured_as_a_quad() { assert_eq!(Shape::from_config(Some("QuadVee OmniMek")), Shape::Quad); - assert_eq!(total_in(60.0, Shape::Quad, &Default::default()), Some(107)); + assert_eq!( + structure_total_in(60.0, Shape::Quad, &Default::default()), + Some(107) + ); } // A LAM is a biped that also flies, and an OmniMek is a biped that swaps @@ -184,26 +223,32 @@ mod tests { #[test] fn a_superheavy_carries_a_heavier_head() { assert_eq!( - total_in(105.0, Shape::Biped, &Default::default()), + structure_total_in(105.0, Shape::Biped, &Default::default()), Some(158) ); assert_eq!( - total_in(125.0, Shape::Tripod, &Default::default()), + structure_total_in(125.0, Shape::Tripod, &Default::default()), Some(214) ); assert_eq!( - total_in(135.0, Shape::Tripod, &Default::default()), + structure_total_in(135.0, Shape::Tripod, &Default::default()), Some(229) ); assert_eq!( - total_in(200.0, Shape::Biped, &Default::default()), + structure_total_in(200.0, Shape::Biped, &Default::default()), Some(298) ); } #[test] fn a_tonnage_with_no_row_has_no_answer_rather_than_a_guessed_one() { - assert_eq!(total_in(37.0, Shape::Biped, &Default::default()), None); - assert_eq!(total_in(205.0, Shape::Biped, &Default::default()), None); + assert_eq!( + structure_total_in(37.0, Shape::Biped, &Default::default()), + None + ); + assert_eq!( + structure_total_in(205.0, Shape::Biped, &Default::default()), + None + ); } } diff --git a/crates/helm-core/src/unit.rs b/crates/helm-core/src/unit.rs index 7375ffe..3301048 100644 --- a/crates/helm-core/src/unit.rs +++ b/crates/helm-core/src/unit.rs @@ -154,6 +154,28 @@ impl Unit { self.armor_locations.values().sum() } + /// Armour as a percentage of the most this design could have carried. + /// + /// What makes "well armoured" comparable across weight classes: a Locust + /// with 64 points is close to full and an Atlas with 200 is not, and the + /// raw figures say the opposite. Rounded to the nearest point, and never + /// above 100 - front and rear plate share a torso's allowance, so a design + /// that armours its back has moved points around rather than added any. + /// + /// `None` for anything this cannot answer for: a design with no tonnage, + /// a tonnage the structure table has no row for, and every unit that is + /// not a Mek, since the table is a Mek's. A `.blk` declares no `Config`, + /// which is what that is read from. + pub fn armor_percent(&self) -> Option { + let tons = self.mass?; + let shape = crate::Shape::from_config(Some(self.config.as_deref()?)); + let most = crate::max_armor(tons, shape)?; + if most <= 0 { + return None; + } + Some(((self.total_armor() as f64 / most as f64) * 100.0).round() as i64) + } + /// Store a key, joining repeats rather than letting the last one win. pub fn push_field(&mut self, key: &str, value: &str) { match self.fields.get_mut(key) { -- 2.51.2