diff --git a/README.md b/README.md index 580d2d6..8293a6d 100644 --- a/README.md +++ b/README.md @@ -275,9 +275,11 @@ equipment list, the quirks, the critical slots. **Battle value, C-bill cost and the Alpha Strike conversion are not in those files.** MegaMek computes them when it loads a design. Reproducing them means -reimplementing the construction rules, so until that happens `bridge/` asks -MegaMek instead: two small Java programs that read `MekSummaryCache` and the -equipment catalogue and write JSON Lines. +reimplementing the construction rules, which `helm-bv` and `helm-cost` now +do for most of the library; `bridge/` is what they are measured against, and +what still answers for the chassis they do not cover. Seven small Java +programs read `MekSummaryCache`, the equipment catalogue and MegaMek's own +line-by-line working, and write JSON Lines. The bridge is optional and meant to be temporary. Build without `--bridge-dir` and you get every declared column and null in the computed ones. Every computed diff --git a/crates/helm-bv/src/defensive.rs b/crates/helm-bv/src/defensive.rs index d3bc300..5f1329c 100644 --- a/crates/helm-bv/src/defensive.rs +++ b/crates/helm-bv/src/defensive.rs @@ -299,7 +299,7 @@ fn explosive_mountings<'a>(machine: &'a Machine<'a>) -> Vec> = items .iter() .map(|i| Part { diff --git a/crates/helm-bv/src/lib.rs b/crates/helm-bv/src/lib.rs index 0184295..0cf7d04 100644 --- a/crates/helm-bv/src/lib.rs +++ b/crates/helm-bv/src/lib.rs @@ -262,36 +262,10 @@ impl Condition { } } -/// Why a design could not be scored. -/// -/// Kept as data rather than an error string so the conformance report can -/// bucket by reason and say what closing the gap would take. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Unsupported { - /// A unit type with no calculator here yet. Each has its own upstream. - UnitType(String), - /// A Mek, but some part of the rules is not written yet. The string names - /// the part, so the report groups by what is missing. - NotImplemented(&'static str), - /// The design carries something the catalogue does not know, so whatever - /// it contributes cannot be scored. Usually a stale or absent catalogue - /// rather than a real gap. - UnknownEquipment(String), - /// The file does not declare something the calculation needs. - Missing(&'static str), -} - -impl std::fmt::Display for Unsupported { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Unsupported::UnitType(t) => write!(f, "unit type: {t}"), - Unsupported::NotImplemented(what) => write!(f, "not implemented: {what}"), - Unsupported::UnknownEquipment(e) => write!(f, "unknown equipment: {e}"), - Unsupported::Missing(field) => write!(f, "design declares no {field}"), - } - } -} - +/// Why a design could not be scored. One type for both calculations that +/// answer for part of the library rather than all of it, so a report can +/// group battle value's gaps and cost's the same way. +pub use helm_core::Unsupported; /// A stock design's battle value, as flown by a regular 4/5 crew. /// /// That crew is the one whose skill multiplier is exactly 1, which is what diff --git a/crates/helm-bv/src/loadout.rs b/crates/helm-bv/src/loadout.rs index 9396cae..c753161 100644 --- a/crates/helm-bv/src/loadout.rs +++ b/crates/helm-bv/src/loadout.rs @@ -236,7 +236,7 @@ impl<'a> Loadout<'a> { // BNC-11X with its arm blown off was coming out 36 points light // because helm stopped. for line in slots { - for slot in line.split('|') { + for slot in helm_core::slot_parts(line) { let marked = clean(slot); if !marked.armored || marked.name.is_empty() @@ -255,7 +255,7 @@ impl<'a> Loadout<'a> { // Modular armour the same way: MegaMek pays for the pack whether // or not the location holding it is still attached. for line in slots { - for slot in line.split('|') { + for slot in helm_core::slot_parts(line) { let name = clean(slot).name; if catalogue .resolve(&name) @@ -289,7 +289,7 @@ impl<'a> Loadout<'a> { // `.mul` addresses: reading a superheavy's shared slot // whole resolves nothing, and an Orca OC-1X's sniper // rounds were never swapped because of it. - let held = line.split('|').next().unwrap_or(line); + let held = helm_core::slot_parts(line).next().unwrap_or(line); catalogue .resolve(&clean(held).name) .is_some_and(|held| held.is_ammo()) @@ -300,7 +300,7 @@ impl<'a> Loadout<'a> { // longer has, and the `heat sinks:` line still counts it. if let Some(entry) = catalogue.resolve(&clean(line).name) { if let Some(shed) = sink_dissipation(entry) { - let slots = entry.criticals.unwrap_or(1).max(1) as f64; + let slots = entry.footprint() as f64; lost_cooling += shed as f64 / slots; } had_jump_jets |= entry.has_flag("F_JUMP_JET"); @@ -324,7 +324,7 @@ impl<'a> Loadout<'a> { booster_mp = booster_mp.max(size.round() as i64); continue; } - for (at, slot) in line.split('|').enumerate() { + for (at, slot) in helm_core::slot_parts(line).enumerate() { // A `.mul` addresses one mounting per slot, and MULParser // only ever reaches `CriticalSlot.getMount()` - the first // of a pair. So the second item in a superheavy's shared @@ -381,11 +381,8 @@ impl<'a> Loadout<'a> { .iter() .filter(|(_, slots)| { slots.iter().any(|slot| { - slot.split('|').any(|part| { - catalogue - .resolve(&clean(part).name) - .is_some_and(|e| e.has_flag("F_ACTUATOR_ENHANCEMENT_SYSTEM")) - }) + helm_core::slot_line(slot, catalogue) + .any(|e| e.has_flag("F_ACTUATOR_ENHANCEMENT_SYSTEM")) }) }) .map(|(location, _)| location.clone()) @@ -427,8 +424,7 @@ impl<'a> Loadout<'a> { for (location, slots) in &unit.criticals { let explodes = slots .iter() - .flat_map(|line| line.split('|')) - .filter_map(|slot| catalogue.resolve(&clean(slot).name)) + .flat_map(|line| helm_core::slot_line(line, catalogue)) .any(|entry| equipment::explosive_penalty_per_slot(entry, true).is_some()); if explodes { clan_case_locations.insert(location.to_ascii_lowercase()); @@ -444,7 +440,7 @@ impl<'a> Loadout<'a> { .iter() .filter_map(|(name, slots)| { let entry = catalogue.resolve(name)?; - let per = entry.criticals.filter(|c| *c > 0).unwrap_or(1).max(1) as usize; + let per = entry.footprint() as usize; Some(entry.damage_capacity? * (*slots / per).max(1) as i64) }) .sum(), @@ -507,7 +503,7 @@ impl<'a> Loadout<'a> { if entry.has_flag("F_MODULAR_ARMOR") { modular += entry.damage_capacity.unwrap_or(0); } - let footprint = entry.criticals.filter(|c| *c > 0).unwrap_or(1).max(1) as usize; + let footprint = entry.footprint() as usize; let index = location_index(&location, superheavy); let counts = per_location .entry(Placement { @@ -726,7 +722,7 @@ impl<'a> Loadout<'a> { seen.into_iter() .map(|((_, rear, turret), s)| Mounting { entry: s.entry, - count: match s.entry.criticals.filter(|c| *c > 0) { + count: match s.entry.declared_criticals() { // Fixed size: how many there are is how many slots it // fills divided by its size, over the whole design - a // King Crab's LB 20-X fills eleven and does not fit in @@ -741,7 +737,7 @@ impl<'a> Loadout<'a> { // Rounded up: a Gauss rifle is seven slots and takes // four here, not three. Rounding down finds four // rifles on an Omega SHP-4X, which carries three. - let footprint = (per_item as usize).div_ceil(self.per_slot).max(1); + let footprint = helm_core::footprint_in(per_item, self.per_slot); (s.slots / footprint).max(1) } // Variable size: a hatchet takes a slot per fifteen tons, @@ -758,9 +754,9 @@ impl<'a> Loadout<'a> { // Only *wholly* in arms counts: a weapon half in the torso is // stuck facing the way it was built. arm: s.arm_slots == s.slots, - aes_count: match s.entry.criticals.filter(|c| *c > 0) { + aes_count: match s.entry.declared_criticals() { Some(per_item) => { - let footprint = (per_item as usize).div_ceil(self.per_slot).max(1); + let footprint = helm_core::footprint_in(per_item, self.per_slot); s.aes_slots / footprint } None => s.aes_locations, @@ -893,7 +889,7 @@ impl<'a> Loadout<'a> { .entry .criticals .filter(|c| *c > 0) - .map(|c| (c as usize).div_ceil(self.per_slot).max(1)) + .map(|c| helm_core::footprint_in(c, self.per_slot)) .unwrap_or(1); *out.entry(item.location.as_str()).or_default() += (item.slots / per_item).max(1); } @@ -1134,9 +1130,9 @@ fn is_arm(location: &str) -> bool { fn clean(slot: &str) -> Marked { Marked { name: helm_core::strip_slot_markers(slot), - rear: slot.contains("(R)"), - armored: slot.contains("(ARMORED)"), - turret: slot.contains("(T)"), + rear: helm_core::slot_has_marker(slot, "(R)"), + armored: helm_core::slot_has_marker(slot, "(ARMORED)"), + turret: helm_core::slot_has_marker(slot, "(T)"), } } diff --git a/crates/helm-core/src/catalogue.rs b/crates/helm-core/src/catalogue.rs index 0053911..031ba85 100644 --- a/crates/helm-core/src/catalogue.rs +++ b/crates/helm-core/src/catalogue.rs @@ -170,6 +170,25 @@ impl EquipmentEntry { } } +impl EquipmentEntry { + /// How many critical slots one of these fills. + /// + /// One for anything the catalogue gives no size for, which is what a + /// magazine is and what a system slot resolves to. + pub fn footprint(&self) -> i64 { + self.criticals.unwrap_or(1).max(1) + } + + /// The size it declares, where it declares one. + /// + /// Not the same question as [`EquipmentEntry::footprint`]: `None` here + /// means the catalogue does not know, and a caller that has to divide + /// slots by size counts per location instead rather than dividing by one. + pub fn declared_criticals(&self) -> Option { + self.criticals.filter(|c| *c > 0) + } +} + impl EquipmentEntry { /// A weapon rather than ammunition, armour or gear. /// diff --git a/crates/helm-core/src/lib.rs b/crates/helm-core/src/lib.rs index a3c1ddd..d175ada 100644 --- a/crates/helm-core/src/lib.rs +++ b/crates/helm-core/src/lib.rs @@ -91,18 +91,105 @@ pub fn strip_slot_markers(slot: &str) -> String { name.truncate(name.len() - ":OMNI".len()); } for marker in SLOT_MARKERS { - while let Some(at) = name.find(marker) { + while let Some(at) = find_marker(&name, marker) { name.replace_range(at..at + marker.len(), " "); } } name.split_whitespace().collect::>().join(" ") } +/// Whether a critical slot carries one of the markers it can be written with. +/// +/// The same rule the stripping uses, so that a slot cannot be armoured to one +/// crate and bare to another. Asked without regard to case: MegaMek writes +/// them in capitals everywhere in the shipped library bar one header line, and +/// two readers disagreeing about the exception is worse than either answer. +pub fn slot_has_marker(slot: &str, marker: &str) -> bool { + find_marker(slot, marker).is_some() +} + +/// Where a marker starts in a slot, ignoring case. +fn find_marker(slot: &str, marker: &str) -> Option { + if slot.is_ascii() && marker.is_ascii() { + return slot + .to_ascii_uppercase() + .find(&marker.to_ascii_uppercase()) + .filter(|at| slot.is_char_boundary(*at)); + } + slot.find(marker) +} + +/// The parts of one critical-slot line. +/// +/// A `.mtf` writes one item to a line, except on a superheavy: those pack two +/// into a single critical slot and write them either side of a pipe. Reading +/// such a line whole resolves nothing and drops both, which is what left an +/// Ares missing a third of its guns and an Orca's magazines unswappable. +/// +/// The first part is the one a `.mul` addresses - `MULParser` reaches +/// `CriticalSlot.getMount()` and never the second - so the order matters and +/// this yields them in it. +pub fn slot_parts(line: &str) -> impl Iterator { + line.split('|') +} + +/// What one critical-slot line holds, looked up in the catalogue. +/// +/// [`slot_parts`] and [`strip_slot_markers`] and a lookup, which is the same +/// three steps everything that reads a design takes. A part naming nothing - +/// an empty slot, a system slot, a spelling this catalogue does not have - is +/// left out rather than guessed at. +pub fn slot_line<'c>( + line: &str, + catalogue: &'c Catalogue, +) -> impl Iterator { + slot_line_parts(line, catalogue).map(|(_, entry)| entry) +} + +/// The same, keeping the slot's own text beside what it resolved to. +/// +/// The text still says things the catalogue entry does not: which way the +/// mounting faces, whether it is armoured, and what size a variable-size item +/// was built at. +pub fn slot_line_parts<'a, 'c>( + line: &'a str, + catalogue: &'c Catalogue, +) -> impl Iterator { + slot_parts(line).filter_map(|part| Some((part, catalogue.resolve(&strip_slot_markers(part))?))) +} + +/// Everything a design mounts, with the location it is mounted in. +/// +/// The whole-design form of [`slot_line`]: every location, every slot, every +/// part of a shared slot. Locations come in the order the file wrote them. +pub fn slot_equipment<'a, 'c>( + unit: &'a Unit, + catalogue: &'c Catalogue, +) -> impl Iterator { + unit.criticals.iter().flat_map(move |(location, slots)| { + slots + .iter() + .flat_map(move |line| slot_line(line, catalogue)) + .map(move |entry| (location.as_str(), entry)) + }) +} + /// The markers [`strip_slot_markers`] knows: rear-facing, pod-mounted, /// turret-mounted, armoured, and the two small turrets a vehicle can hang a /// weapon off - a sponson and a pintle. pub const SLOT_MARKERS: [&str; 6] = ["(R)", "(OMNIPOD)", "(T)", "(ARMORED)", "(ST)", "(PT)"]; +/// How many critical slots an item fills on a design that packs `per_slot` +/// items into each of them. +/// +/// A superheavy fits two components into one slot, so a two-slot weapon +/// occupies one. Halving the footprint rather than doubling the slot count is +/// what keeps a lone ammunition bin from counting as two: the file says +/// outright when a slot is doubled, by writing both items into it. +pub fn footprint_in(criticals: i64, per_slot: usize) -> usize { + (criticals.max(0) as usize).div_ceil(per_slot.max(1)).max(1) +} + /// Reduce a name to lowercase alphanumerics. /// /// MegaMek names the same weapon three ways depending on where you read it — @@ -117,6 +204,41 @@ pub fn normalize(name: &str) -> String { .collect() } +/// Why a design could not be scored. +/// +/// Shared by every calculation that answers for part of the library rather +/// than all of it - battle value and C-bill cost both - so that a report can +/// group two crates' gaps the same way and a caller can handle one type. +/// +/// The point of it is that a gap is *named*. A calculator that cannot answer +/// says so and says why; it never returns a plausible number it does not +/// stand behind. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Unsupported { + /// A unit type with no calculator here yet. Each has its own upstream. + UnitType(String), + /// The right kind of unit, but some part of the rules is not written yet. + /// The string names the part, so a report groups by what is missing. + NotImplemented(&'static str), + /// The design carries something the catalogue does not know, so whatever + /// it contributes cannot be counted. Usually a stale or absent catalogue + /// rather than a real gap. + UnknownEquipment(String), + /// The file does not declare something the calculation needs. + Missing(&'static str), +} + +impl std::fmt::Display for Unsupported { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Unsupported::UnitType(t) => write!(f, "unit type: {t}"), + Unsupported::NotImplemented(what) => write!(f, "not implemented: {what}"), + Unsupported::UnknownEquipment(e) => write!(f, "unknown equipment: {e}"), + Unsupported::Missing(field) => write!(f, "design declares no {field}"), + } + } +} + /// Where a database's numbers came from. /// /// Without this, a conformance failure is indistinguishable from a database diff --git a/crates/helm-core/src/metrics.rs b/crates/helm-core/src/metrics.rs index 2f11e5f..c403b71 100644 --- a/crates/helm-core/src/metrics.rs +++ b/crates/helm-core/src/metrics.rs @@ -269,21 +269,15 @@ pub fn mounted_items<'a>(unit: &Unit, catalogue: &'a Catalogue) -> Vec<(&'a Equi for (location, lines) in &unit.criticals { let mut here: BTreeMap<&str, ()> = BTreeMap::new(); for line in lines { - for part in line.split('|') { - let name = crate::strip_slot_markers(part); - if name.is_empty() || name == "-Empty-" { - continue; - } - if let Some(entry) = catalogue.resolve(&name) { - let key = entry.internal_name.as_str(); - slots.entry(key).or_default().0 += 1; - if let Some(size) = slot_size(part) { - let counted = sized.entry(key).or_insert((0, size)); - counted.0 += 1; - counted.1 = counted.1.max(size); - } - here.insert(key, ()); + for (part, entry) in crate::slot_line_parts(line, catalogue) { + let key = entry.internal_name.as_str(); + slots.entry(key).or_default().0 += 1; + if let Some(size) = slot_size(part) { + let counted = sized.entry(key).or_insert((0, size)); + counted.0 += 1; + counted.1 = counted.1.max(size); } + here.insert(key, ()); } } let _ = location; @@ -336,11 +330,11 @@ pub fn mounted_items<'a>(unit: &Unit, catalogue: &'a Catalogue) -> Vec<(&'a Equi let per_item = (size.ceil() as i64).max(1); return Some((entry, (lines / per_item).max(1))); } - let count = match entry.criticals.filter(|c| *c > 0) { + let count = match entry.declared_criticals() { // Rounded up, the way `helm_bv::loadout` rounds it: a Gauss // rifle is seven slots and takes four on a superheavy. Some(per_item) => { - let footprint = (per_item as usize).div_ceil(per_slot).max(1) as i64; + let footprint = crate::footprint_in(per_item, per_slot) as i64; (filled / footprint).max(1) } // Nothing to divide by, so one per location - which is right @@ -475,11 +469,10 @@ pub fn equipment_list<'a>(unit: &Unit, catalogue: &'a Catalogue) -> Vec<(&'a Equ // A design that mounts its own CASE anywhere is given none: MegaMek // checks that first and leaves the design alone. let cases_itself = |flag: &str| { - unit.criticals.values().flatten().any(|line| { - line.split('|') - .filter_map(|slot| catalogue.resolve(&crate::strip_slot_markers(slot))) - .any(|e| e.has_flag(flag)) - }) + unit.criticals + .values() + .flatten() + .any(|line| crate::slot_line(line, catalogue).any(|e| e.has_flag(flag))) }; if unit.is_clan() && !cases_itself("F_CASE") { let cased = unit @@ -489,8 +482,7 @@ pub fn equipment_list<'a>(unit: &Unit, catalogue: &'a Catalogue) -> Vec<(&'a Equ let resolved = || { slots .iter() - .flat_map(|line| line.split('|')) - .filter_map(|slot| catalogue.resolve(&crate::strip_slot_markers(slot))) + .flat_map(|line| crate::slot_line(line, catalogue)) }; // Explosive by MegaMek's own answer, which is not "is it // ammunition": Gauss rounds are inert and the rifle firing @@ -744,8 +736,7 @@ pub fn walking_mp(unit: &Unit, catalogue: &Catalogue) -> Option { let has = |location: &Vec, flag: &str| { location .iter() - .flat_map(|slot| slot.split('|')) - .filter_map(|slot| catalogue.resolve(&crate::strip_slot_markers(slot))) + .flat_map(|slot| crate::slot_line(slot, catalogue)) .any(|e| e.has_flag(flag)) }; // One shield per location that has one, rather than one per slot: a diff --git a/crates/helm-cost/src/items.rs b/crates/helm-cost/src/items.rs index 6e0ea10..22e04bf 100644 --- a/crates/helm-cost/src/items.rs +++ b/crates/helm-cost/src/items.rs @@ -64,11 +64,7 @@ pub(crate) fn clan_case(unit: &Unit, catalogue: &Catalogue) -> f64 { if !unit.is_clan() { return 0.0; } - let resolved = |line: &str| { - line.split('|') - .filter_map(|slot| catalogue.resolve(&helm_core::strip_slot_markers(slot))) - .collect::>() - }; + let resolved = |line: &str| helm_core::slot_line(line, catalogue).collect::>(); // One per location holding something that actually explodes, less the // CASE the design already mounts - which is bought and paid for on its // own line, so counting it here as well would charge for it twice. @@ -128,149 +124,235 @@ pub fn item_prices(unit: &Unit, catalogue: &Catalogue) -> Vec<(String, i64, f64) out } -/// What an item priced from its design costs on this one. +/// A design, and the item on it being priced. /// -/// Every figure here was read off MegaMek's own report for the designs that -/// carry the item. Anything not listed is still nought, which is a known -/// shortfall rather than a claim - see TODO.md. -pub(crate) fn variable( - entry: &helm_core::EquipmentEntry, - unit: &Unit, - catalogue: &Catalogue, - tons: f64, - rating: i64, -) -> f64 { - let rating = rating as f64; - // Some items are sized by the design's builder rather than by the design, - // and the slot line says how big: `Communications Equipment:SIZE:2.0` is - // two tons of it at ten thousand a ton. - if entry.has_flag("F_COMMUNICATIONS") { - return declared_size(unit, entry) * 10_000.0; +/// Every rule in [`PRICED_BY_THE_DESIGN`] needs some of these and none needs +/// all of them, so they travel together rather than as six arguments repeated +/// two dozen times. +pub(crate) struct Fitted<'a> { + pub entry: &'a helm_core::EquipmentEntry, + pub unit: &'a Unit, + pub catalogue: &'a Catalogue, + /// What the design weighs, which is what most of these are sized by. + pub tons: f64, + /// The engine's rating, which is what the rest of them are sized by. + pub rating: f64, +} + +impl Fitted<'_> { + /// Whether the item itself is Clan-built, which is not the same question + /// as whether the design is: a mixed design carries whichever it was sold. + fn item_is_clan(&self) -> bool { + self.entry + .internal_name + .to_ascii_uppercase() + .starts_with("CL") } - // A supercharger is strapped to the engine and priced by it; MASC is - // wound through the frame, so it is priced by the engine *and* by how - // much of it there is. - if entry.has_flag("S_SUPERCHARGER") { - return rating * 10_000.0; + + /// The size the design's own slots state for a variable-size item. + fn size(&self) -> f64 { + declared_size(self.unit, self.entry) } +} + +/// One item that is priced by the design carrying it rather than by a +/// catalogue figure. +struct Priced { + /// The flag MegaMek tells it by. + flag: &'static str, + /// What it costs on this design. + price: fn(&Fitted<'_>) -> f64, +} + +/// Everything priced from its design, in the order the flags are asked about. +/// +/// **The order is a rule rather than a layout.** MegaMek's flags overlap: a +/// supercharger and a VTOL's jet booster both carry `F_MASC`, and asking +/// about the myomer sort first prices a jet booster as myomer - which is what +/// helm did, and what left a Kestrel VTOL (Scout) 74% light. Anything whose +/// flags overlap something else has to come before what it would otherwise be +/// mistaken for, and `the_order_tells_overlapping_flags_apart` holds it there. +/// +/// Every figure was read off MegaMek's own report for the designs that carry +/// the item. Anything not listed is nought, which is a known shortfall rather +/// than a claim - see TODO.md. +const PRICED_BY_THE_DESIGN: &[Priced] = &[ + // Sized by the design's builder rather than by the design, and the slot + // line says how big: `Communications Equipment:SIZE:2.0` is two tons of + // it at ten thousand a ton. + Priced { + flag: "F_COMMUNICATIONS", + price: |f| f.size() * 10_000.0, + }, // A field hospital is two and a half tons of theatre plus one per bed, // and a drone carrier's control system two tons plus a half per drone. - // Both are ten thousand a ton, which is the commonest price there is. - if entry.has_flag("F_MASH") { - return (2.5 + declared_size(unit, entry)) * 10_000.0; - } - if entry.has_flag("F_DRONE_CARRIER_CONTROL") { - return (2.0 + declared_size(unit, entry) * 0.5) * 10_000.0; - } + // Both at ten thousand a ton, which is the commonest price there is. + Priced { + flag: "F_MASH", + price: |f| (2.5 + f.size()) * 10_000.0, + }, + Priced { + flag: "F_DRONE_CARRIER_CONTROL", + price: |f| (2.0 + f.size() * 0.5) * 10_000.0, + }, // A small turret is a tenth of what it swings, shared between however // many of them the design mounts: four light machine guns on one sponson // is half a ton of ring, which is two thousand C-bills. - if entry.has_flag("F_SPONSON_TURRET") { - return small_turret(unit, catalogue, "(ST)", "F_SPONSON_TURRET") * 4_000.0; - } - if entry.has_flag("F_PINTLE_TURRET") { - return small_turret(unit, catalogue, "(PT)", "F_PINTLE_TURRET") * 1_000.0; - } - // A VTOL's jet booster is also MASC, and is caught here first because it - // is priced like a supercharger rather than like the myomer sort. - if entry.has_flag("F_JET_BOOSTER") { - return rating * 10_000.0; - } - if entry.has_flag("F_MASC") { - // Priced by the engine it drives *and* by how much myomer there is: - // a ton per twenty of the design, or per twenty-five where the myomer - // is Clan. - let per = if unit.is_clan() { 25.0 } else { 20.0 }; - let weight = (tons / per).round().max(1.0); - return rating * 1_000.0 * weight; - } + Priced { + flag: "F_SPONSON_TURRET", + price: |f| small_turret(f.unit, f.catalogue, "(ST)", "F_SPONSON_TURRET") * 4_000.0, + }, + Priced { + flag: "F_PINTLE_TURRET", + price: |f| small_turret(f.unit, f.catalogue, "(PT)", "F_PINTLE_TURRET") * 1_000.0, + }, + // Before `F_MASC`, both of them: a supercharger is strapped to the engine + // and a jet booster is bolted to it, and both carry the myomer flag. + Priced { + flag: "S_SUPERCHARGER", + price: |f| f.rating * 10_000.0, + }, + Priced { + flag: "F_JET_BOOSTER", + price: |f| f.rating * 10_000.0, + }, + // MASC proper is wound through the frame, so it is priced by the engine + // it drives *and* by how much myomer there is: a ton per twenty of the + // design, or per twenty-five where the myomer is Clan. + Priced { + flag: "F_MASC", + price: |f| { + let per = if f.unit.is_clan() { 25.0 } else { 20.0 }; + f.rating * 1_000.0 * (f.tons / per).round().max(1.0) + }, + }, // A club is sized by the Mek swinging it. - if entry.has_flag("S_HATCHET") { - return (tons / 15.0).ceil() * 5_000.0; - } - if entry.has_flag("S_CLAW") { - return tons * 200.0; - } + Priced { + flag: "S_HATCHET", + price: |f| (f.tons / 15.0).ceil() * 5_000.0, + }, + Priced { + flag: "S_CLAW", + price: |f| f.tons * 200.0, + }, // A sword is forged to the Mek's weight and sold by the ton, rounded up // to the half ton it is actually made in. - if entry.has_flag("S_SWORD") { - return crate::next_half_ton(tons / 20.0) * 10_000.0; - } - if entry.has_flag("S_MACE") { - return 130_000.0; - } + Priced { + flag: "S_SWORD", + price: |f| crate::next_half_ton(f.tons / 20.0) * 10_000.0, + }, + Priced { + flag: "S_MACE", + price: |_| 130_000.0, + }, // A blade folds into the arm, and the housing is a ton of machinery // before any of the blade. - if entry.has_flag("S_RETRACTABLE_BLADE") { - return ((tons / 20.0).ceil() + 1.0) * 10_000.0; - } - // Tracks are laid over the whole machine and driven by the whole engine. - // Tracks are five hundred a point of engine, by the ton, and sold whole. - if entry.has_flag("F_TRACKS") { - let per = if entry.has_flag("S_QUADVEE_WHEELS") { - 750.0 - } else { - 500.0 - }; - return (per * rating * tons / 75.0).ceil(); - } + Priced { + flag: "S_RETRACTABLE_BLADE", + price: |f| ((f.tons / 20.0).ceil() + 1.0) * 10_000.0, + }, + // Tracks are laid over the whole machine and driven by the whole engine: + // five hundred a point of engine, by the ton, and sold whole. + Priced { + flag: "F_TRACKS", + price: |f| { + let per = if f.entry.has_flag("S_QUADVEE_WHEELS") { + 750.0 + } else { + 500.0 + }; + (per * f.rating * f.tons / 75.0).ceil() + }, + }, // A wing is built to a fraction of the design's weight and sold by the - // ton. Clan construction gets away with less of it - and which it is - // belongs to the wing rather than to the Mek, because a mixed design - // carries whichever it was sold. - if entry.has_flag("F_PARTIAL_WING") { - let rate = if entry.internal_name.to_ascii_uppercase().starts_with("CL") { - 0.05 - } else { - 0.07 - }; - return (crate::next_half_ton(tons * rate) * 50_000.0).ceil(); - } - if entry.has_flag("F_TALON") { - return (tons / 15.0).ceil() * 300.0; - } + // ton. Clan construction gets away with less of it. + Priced { + flag: "F_PARTIAL_WING", + price: |f| { + let rate = if f.item_is_clan() { 0.05 } else { 0.07 }; + (crate::next_half_ton(f.tons * rate) * 50_000.0).ceil() + }, + }, + Priced { + flag: "F_TALON", + price: |f| (f.tons / 15.0).ceil() * 300.0, + }, // A remote operating system is a flat fee and a rate: somebody has to fly // the thing from somewhere. - if entry.has_flag("F_DRONE_OPERATING_SYSTEM") { - return 10_000.0 + tons * 1_000.0; - } - if entry.has_flag("S_LANCE") { - return tons * 150.0; - } - if entry.has_flag("F_SPIKES") { - return tons * 50.0; - } + Priced { + flag: "F_DRONE_OPERATING_SYSTEM", + price: |f| 10_000.0 + f.tons * 1_000.0, + }, + Priced { + flag: "S_LANCE", + price: |f| f.tons * 150.0, + }, + Priced { + flag: "F_SPIKES", + price: |f| f.tons * 50.0, + }, // An enhanced actuator in a leg is dearer than one in an arm, and the // slot line is what says which limb it is in. - if entry.has_flag("F_ACTUATOR_ENHANCEMENT_SYSTEM") { - let per = if in_a_leg(unit, entry) { 700.0 } else { 500.0 }; - return (tons * per).ceil(); - } + Priced { + flag: "F_ACTUATOR_ENHANCEMENT_SYSTEM", + price: |f| { + let per = if in_a_leg(f.unit, f.entry) { + 700.0 + } else { + 500.0 + }; + (f.tons * per).ceil() + }, + }, // Sealing a Mek against vacuum is 225 a ton. Sealing a vehicle is not // charged for here at all: it is a quarter on the finished hull instead. - if entry.has_flag("F_ENVIRONMENTAL_SEALING") { - return if unit.is_mek() { tons * 225.0 } else { 0.0 }; - } + Priced { + flag: "F_ENVIRONMENTAL_SEALING", + price: |f| { + if f.unit.is_mek() { f.tons * 225.0 } else { 0.0 } + }, + }, // A hull that swims costs a thousand a ton; one that only wades costs a // twelfth of that in five-thousand-C-bill steps. - if entry.has_flag("F_FULLY_AMPHIBIOUS") { - return tons * 1_000.0; - } - if entry.has_flag("F_LIMITED_AMPHIBIOUS") { - return (tons * 0.08).ceil() * 5_000.0; - } + Priced { + flag: "F_FULLY_AMPHIBIOUS", + price: |f| f.tons * 1_000.0, + }, + Priced { + flag: "F_LIMITED_AMPHIBIOUS", + price: |f| (f.tons * 0.08).ceil() * 5_000.0, + }, // Armouring what makes the vehicle move is a tenth of the vehicle where - // the plate is Clan and a seventh where it is not, and it is welded in - // half tons like everything else. - if entry.has_flag("F_ARMORED_MOTIVE_SYSTEM") { - let per = if entry.internal_name.starts_with("CL") { - 0.1 - } else { - 0.15 - }; - return crate::next_half_ton(tons * per) * 100_000.0; - } - 0.0 + // the plate is Clan and a seventh where it is not, welded in half tons + // like everything else. + Priced { + flag: "F_ARMORED_MOTIVE_SYSTEM", + price: |f| { + let per = if f.item_is_clan() { 0.1 } else { 0.15 }; + crate::next_half_ton(f.tons * per) * 100_000.0 + }, + }, +]; + +/// What an item priced from its design costs on this one. +pub(crate) fn variable( + entry: &helm_core::EquipmentEntry, + unit: &Unit, + catalogue: &Catalogue, + tons: f64, + rating: i64, +) -> f64 { + let fitted = Fitted { + entry, + unit, + catalogue, + tons, + rating: rating as f64, + }; + PRICED_BY_THE_DESIGN + .iter() + .find(|priced| entry.has_flag(priced.flag)) + .map_or(0.0, |priced| (priced.price)(&fitted)) } /// How much ring one of a design's small turrets is, in tons. @@ -283,17 +365,11 @@ fn small_turret(unit: &Unit, catalogue: &Catalogue, marker: &str, flag: &str) -> .criticals .values() .flatten() - .flat_map(|line| line.split('|')) - .filter(|slot| slot.contains(marker)) - .filter_map(|slot| catalogue.resolve(&helm_core::strip_slot_markers(slot))) - .fold(0.0, |tons, entry| tons + entry.tonnage.unwrap_or(0.0)); - let rings = unit - .criticals - .values() - .flatten() - .flat_map(|line| line.split('|')) - .filter_map(|slot| catalogue.resolve(&helm_core::strip_slot_markers(slot))) - .filter(|entry| entry.has_flag(flag)) + .flat_map(|line| helm_core::slot_line_parts(line, catalogue)) + .filter(|(slot, _)| slot.contains(marker)) + .fold(0.0, |tons, (_, entry)| tons + entry.tonnage.unwrap_or(0.0)); + let rings = helm_core::slot_equipment(unit, catalogue) + .filter(|(_, entry)| entry.has_flag(flag)) .count() .max(1) as f64; crate::next_half_ton(mounted / 10.0) / rings @@ -305,7 +381,7 @@ fn in_a_leg(unit: &Unit, entry: &helm_core::EquipmentEntry) -> bool { let leg = location.to_ascii_lowercase(); (leg.contains("leg") || leg.contains("rl") || leg.contains("ll")) && slots.iter().any(|line| { - line.split('|').any(|slot| { + helm_core::slot_parts(line).any(|slot| { helm_core::normalize(&helm_core::strip_slot_markers(slot)) == helm_core::normalize(&entry.internal_name) }) @@ -318,7 +394,7 @@ pub(crate) fn declared_size(unit: &Unit, entry: &helm_core::EquipmentEntry) -> f unit.criticals .values() .flatten() - .flat_map(|line| line.split('|')) + .flat_map(|line| helm_core::slot_parts(line)) .filter(|part| { helm_core::strip_slot_markers(part).eq_ignore_ascii_case(&entry.internal_name) || helm_core::strip_slot_markers(part).eq_ignore_ascii_case(&entry.name) @@ -327,3 +403,41 @@ pub(crate) fn declared_size(unit: &Unit, entry: &helm_core::EquipmentEntry) -> f .next() .unwrap_or(0.0) } + +#[cfg(test)] +mod tests { + use super::*; + + /// The order of [`PRICED_BY_THE_DESIGN`] is load-bearing, so it is held + /// here rather than in a comment. + /// + /// MegaMek's flags overlap. A supercharger and a VTOL's jet booster are + /// both `F_MASC`, and the myomer price is not theirs: pricing a Kestrel + /// VTOL (Scout)'s booster as myomer left it 74% light. Each pair below is + /// one item that would be read as another if the list were reordered. + #[test] + fn the_order_tells_overlapping_flags_apart() { + let at = |flag: &str| { + PRICED_BY_THE_DESIGN + .iter() + .position(|priced| priced.flag == flag) + .unwrap_or_else(|| panic!("{flag} is not priced")) + }; + for (first, then) in [("S_SUPERCHARGER", "F_MASC"), ("F_JET_BOOSTER", "F_MASC")] { + assert!( + at(first) < at(then), + "{first} carries {then} and has to be asked about first" + ); + } + } + + /// Two rules answering to one flag would make the second unreachable and + /// the first look like the whole rule. + #[test] + fn no_flag_is_priced_twice() { + let mut seen = std::collections::BTreeSet::new(); + for priced in PRICED_BY_THE_DESIGN { + assert!(seen.insert(priced.flag), "{} is priced twice", priced.flag); + } + } +} diff --git a/crates/helm-cost/src/lib.rs b/crates/helm-cost/src/lib.rs index d52d79d..6c15bcb 100644 --- a/crates/helm-cost/src/lib.rs +++ b/crates/helm-cost/src/lib.rs @@ -52,32 +52,10 @@ pub const RULES_VERSION: &str = "0.1.0"; /// Which MegaMek these rules were written against and checked with. pub const CONFORMED_TO_MEGAMEK: &str = "0.51.0"; -/// Why a design could not be priced. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Unsupported { - /// A unit type with no cost model here yet. Each has its own upstream. - UnitType(String), - /// Some part of the rules is not written yet. The string names the part, - /// so a report can group by what is missing. - NotImplemented(&'static str), - /// The design carries something the catalogue does not know, so whatever - /// it costs cannot be counted. - UnknownEquipment(String), - /// The file does not declare something the calculation needs. - Missing(&'static str), -} - -impl std::fmt::Display for Unsupported { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Unsupported::UnitType(t) => write!(f, "unit type: {t}"), - Unsupported::NotImplemented(what) => write!(f, "not implemented: {what}"), - Unsupported::UnknownEquipment(e) => write!(f, "unknown equipment: {e}"), - Unsupported::Missing(field) => write!(f, "design declares no {field}"), - } - } -} - +/// Why a design could not be priced. One type for both calculations that +/// answer for part of the library rather than all of it, so a report can +/// group cost's gaps and battle value's the same way. +pub use helm_core::Unsupported; /// What the design costs, in C-bills. /// /// Rounded the way MegaMek rounds it, which is not at all: the figure is a diff --git a/crates/helm-cost/src/mek.rs b/crates/helm-cost/src/mek.rs index 00e613d..d12c3cc 100644 --- a/crates/helm-cost/src/mek.rs +++ b/crates/helm-cost/src/mek.rs @@ -565,18 +565,14 @@ fn armored_components(unit: &Unit, catalogue: &Catalogue) -> f64 { let mut marked: std::collections::BTreeMap = std::collections::BTreeMap::new(); for line in slots { - for part in line.split('|') { + for part in helm_core::slot_parts(line) { if !part.to_ascii_uppercase().contains("(ARMORED)") { continue; } let name = helm_core::strip_slot_markers(part); // A system - an actuator, a shoulder - is in no catalogue and // fills one slot. - let footprint = catalogue - .resolve(&name) - .and_then(|e| e.criticals) - .filter(|c| *c > 0) - .unwrap_or(1); + let footprint = catalogue.resolve(&name).map_or(1, |e| e.footprint()); let entry = marked.entry(name).or_insert((0, footprint)); entry.0 += 1; }