//! The types every other helm crate agrees on, and no I/O whatsoever. //! //! This crate exists to be the thing the rules are written against. Battle //! value and construction validation have to run in two places — in a browser //! while somebody edits a design, and again on the server that believes the //! answer — so they cannot depend on a filesystem, a database or a network. //! Keeping those types here, with nothing under them, is what makes that //! possible; `scripts/check-boundaries.sh` fails the build if it stops being //! true. //! //! The split that matters most is between what a unit file *declares* and what //! MegaMek *computes*: //! //! * [`Unit`] is declared. It comes out of a `.mtf` or `.blk` and says what the //! designer chose. //! * [`ComputedStats`] is derived. Battle value, cost and the Alpha Strike //! conversion appear in no unit file; MegaMek works them out when it loads a //! design. //! //! [`ComputedStats`] is deliberately named for its shape rather than its //! source. Today the Java bridge fills it by asking MegaMek. Later `helm-bv` //! will fill it by computing it. Nothing downstream should be able to tell //! which, and having both fill one type is what makes conformance a diff //! rather than a project. mod bv; mod catalogue; mod computed; mod design; mod expected; mod metrics; mod rng; mod structure; mod unit; pub use bv::{BvBreakdown, RATING_TOLERANCE, bv_skill_multiplier}; pub use catalogue::{Catalogue, Category, EquipmentEntry, InfantryMount, Prosthetic}; pub use computed::{AlphaStrike, ComputedStats}; pub use design::{ ArmorKind, C3, CockpitKind, Engine, EngineKind, GyroKind, HeatSinkKind, HeatSinks, StructureKind, }; pub use expected::{ Firepower, GUNNERY, ammo_share, expected_damage_curve, firepower, firepower_flown_by, firepower_of, one_shot_share, }; pub use metrics::{ CombatMetrics, FORMATION_RANGES, FORMATION_SINGLE_RANGES, REACH, can_make_anti_mek_attacks, can_ride_a_mek, count_carrying, damage_at_range, damage_curve, equipment_list, infantry_armor_is_encumbering, infantry_can_make_anti_mek_attacks, infantry_can_swarm, infantry_is_mechanized, is_conventional_infantry, jumping_mp, max_single_damage_at_range, mounted_items, one_shot_ammo, platoon_weapons, slot_size, walking_mp, weapon_bays, }; pub use rng::Rng; pub use structure::{Shape, max_armor, structure_per_location, structure_total_in}; pub use unit::{Format, Mount, SuitMount, Unit}; /// Turn a battle value figure into the integer MegaMek would. /// /// Java's `Math.round` is `floor(x + 0.5)` - half goes *up*. Rust's /// `f64::round` is half *away from zero*. For a positive figure the two agree, /// and every figure battle value rounds today is positive, so this changes no /// answer as it stands. /// /// It exists for the one that will not be. A term that comes out negative - /// an explosive penalty on its own, a difference between two conditions - /// rounds one way here and the other way there, and -2.5 becoming -3 instead /// of -2 is a point nobody would find. Rounding in one named place means the /// rule is written down once rather than assumed at eleven call sites. pub fn round_half_up(value: f64) -> i64 { (value + 0.5).floor() as i64 } /// Strip the markers a critical slot puts after an equipment name. /// /// A `.mtf` writes how a slot is mounted into the slot itself - /// `CLTargeting Computer (OMNIPOD)`, `Medium Laser (R)` - so the text of a /// slot is not the name of the thing in it, and looking one up in the /// catalogue unstripped finds nothing. On an omni that costs the design its /// pod-mounted ammunition, which is most of what it shoots with. /// /// Only the known markers, never everything after the first bracket: a /// Rifleman RFL-3B carries a `Binary Laser (Blazer) Cannon`, and cutting at /// the bracket leaves `Binary Laser`, which matches nothing and disarms it. pub fn strip_slot_markers(slot: &str) -> String { // A variable-size item states its size on the slot line rather than in // the catalogue: `Cargo:SIZE:0.5`. The size is not part of the name, and // leaving it on is why 74 IndustrialMeks carried no cargo bay at all. let mut name = match slot.split_once(":SIZE:") { Some((before, _)) => before.to_string(), None => slot.to_string(), }; // A `.blk` writes `:OMNI` on everything in a pod, the way a `.mtf` // writes `(OMNIPOD)`. It is not part of the name either. if name.to_ascii_uppercase().ends_with(":OMNI") { name.truncate(name.len() - ":OMNI".len()); } for marker in SLOT_MARKERS { 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. /// /// Compared in place rather than by uppercasing either side: this runs for /// six markers on every slot of every design, and an allocation apiece is the /// difference between reading the library in a second and in two. fn find_marker(slot: &str, marker: &str) -> Option { let len = marker.len(); slot.char_indices().map(|(at, _)| at).find(|&at| { slot.get(at..at + len) .is_some_and(|found| found.eq_ignore_ascii_case(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) } /// `chassis model`, the way MegaMek displays a unit. /// /// For display only, never as a key: a design's chassis and model together /// are not unique across the library, and a Clan design has a second chassis /// name besides - see [`Unit::display_name`], which adds it. pub fn display_name(chassis: &str, model: &str) -> String { let chassis = chassis.trim(); let model = model.trim(); if model.is_empty() { chassis.to_string() } else { format!("{chassis} {model}") } } /// The MegaMek release a changelog names, from its first release line. /// /// The parsing rather than the reading, because helm-core touches no /// filesystem: a caller reads [`HISTORY_IN_INSTALL`] out of an install and /// hands the text here. Two crates were doing both halves for themselves. /// /// Worth reading rather than being told. Every computed figure helm holds is /// only meaningful against a stated MegaMek, and a `--megamek-version` flag /// that can be typed wrong is a provenance hole in exactly the situation the /// provenance is for. pub fn megamek_version_in(history: &str) -> Option { history .lines() .map(str::trim) .find(|line| line.starts_with(|c: char| c.is_ascii_digit())) .and_then(|line| line.split_whitespace().next()) .map(str::to_string) } /// Where a MegaMek install keeps the changelog [`megamek_version_in`] reads. pub const HISTORY_IN_INSTALL: &str = "docs/history.txt"; /// Reduce a name to lowercase alphanumerics. /// /// MegaMek names the same weapon three ways depending on where you read it — /// `ISGaussRifle` in a loadout, `Gauss Rifle` in the catalogue, `IS Gauss /// Ammo` for its ammunition — so matching on the raw string finds one and /// misses the others. Normalising both sides is what makes a search for /// "gauss rifle" return all of them. pub fn normalize(name: &str) -> String { name.chars() .filter(char::is_ascii_alphanumeric) .map(|c| c.to_ascii_lowercase()) .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 /// built against a different MegaMek — which is exactly the ambiguity you hit /// while chasing a deviation, and exactly when you can least afford it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Provenance { /// The MegaMek release the unit files and catalogue were read from. pub megamek_version: String, /// What filled [`ComputedStats`]: `megamek-bridge`, or `helm-bv` once /// that exists. pub stats_producer: String, /// The producer's own version, so a disagreement can be pinned to a /// change on our side rather than theirs. pub rules_version: Option, } impl Provenance { /// The provenance of a database nothing has computed for yet. /// /// A database built from the unit files alone: every declared column is /// there and every computed one is null. Recorded rather than left blank, /// because "nobody produced these" and "somebody produced these and we /// forgot to say who" are different states and only one of them is fine. pub fn none_yet(megamek_version: impl Into) -> Self { Provenance { megamek_version: megamek_version.into(), stats_producer: "none".to_string(), rules_version: None, } } /// The provenance of a database whose computed values came from MegaMek /// itself by way of the Java bridge. pub fn from_bridge(megamek_version: impl Into) -> Self { Provenance { megamek_version: megamek_version.into(), stats_producer: "megamek-bridge".to_string(), rules_version: None, } } } /// `weightClass` codes, from MegaMek's `EntityWeightClass`. /// /// 5 is both `WEIGHT_COLOSSAL` and `WEIGHT_SUPER_HEAVY` upstream, and which /// one it reads as depends on the unit type, so the label carries both rather /// than picking one and being wrong half the time. pub fn weight_class_name(code: i64) -> Option<&'static str> { Some(match code { 0 => "Ultra Light", 1 => "Light", 2 => "Medium", 3 => "Heavy", 4 => "Assault", 5 => "Colossal / Super Heavy", 6 => "Small Craft", 7 => "Small DropShip", 8 => "Medium DropShip", 9 => "Large DropShip", 10 => "Small WarShip", 11 => "Large WarShip", 12 => "Small Support", 13 => "Medium Support", 14 => "Large Support", _ => return None, }) } /// Unit types that are scenery or ordnance rather than something that takes /// the field under its own power. /// /// These are the names the *files* use. MegaMek's summary spells several of /// them differently — `Gun Emplacement` against `.blk`'s `GunEmplacement` — /// and filtering on its spelling here silently excludes nothing at all. pub const NOT_PLAYABLE: &[&str] = &["GunEmplacement", "BuildingEntity", "HandheldWeapon"]; #[cfg(test)] mod tests { use super::*; /// The rule, written down: half goes up, which is what `Math.round` does /// and what a battle value is rounded by. #[test] fn half_goes_up() { assert_eq!(round_half_up(1.5), 2); assert_eq!(round_half_up(2.5), 3); assert_eq!(round_half_up(1.49), 1); assert_eq!(round_half_up(1.0), 1); assert_eq!(round_half_up(0.0), 0); } /// And the case Rust and Java disagree about, which is the whole reason /// this is a function rather than a method call: `f64::round` takes -2.5 /// to -3 and `Math.round` takes it to -2. #[test] fn a_negative_half_goes_up_too_rather_than_away_from_zero() { assert_eq!(round_half_up(-2.5), -2); assert_eq!(round_half_up(-0.5), 0); assert_eq!((-2.5f64).round() as i64, -3, "which is what Rust does"); } #[test] fn normalize_collapses_megameks_three_spellings() { assert_eq!(normalize("ISGaussRifle"), "isgaussrifle"); assert_eq!(normalize("Gauss Rifle"), "gaussrifle"); assert_eq!(normalize("IS Gauss Ammo"), "isgaussammo"); // The point of the whole exercise: one substring reaches both weapons. assert!(normalize("ISGaussRifle").contains("gaussrifle")); assert!(normalize("CLGaussRifle").contains("gaussrifle")); } #[test] fn weight_class_5_names_both_meanings() { assert_eq!(weight_class_name(3), Some("Heavy")); assert_eq!(weight_class_name(5), Some("Colossal / Super Heavy")); assert_eq!(weight_class_name(99), None); } } #[cfg(test)] mod marker_tests { use super::*; /// A slot's markers decide how it is mounted, and the two questions asked /// about them - strip it, or ask whether it is there - have to agree. #[test] fn stripping_a_marker_and_asking_about_one_are_the_same_test() { for slot in [ "Medium Laser (R)", "Medium Laser (r)", "CLTargeting Computer (OMNIPOD)", "Fusion Engine (Armored)", "Light Machine Gun(ST)", ] { let stripped = strip_slot_markers(slot); for marker in SLOT_MARKERS { assert!( !slot_has_marker(&stripped, marker), "{marker} survived stripping {slot}" ); } } } /// Case is not the rule: MegaMek writes them in capitals, and a file that /// does not is a file to read rather than to refuse. #[test] fn a_marker_is_found_whatever_case_it_is_written_in() { assert!(slot_has_marker("Fusion Engine (ARMORED)", "(ARMORED)")); assert!(slot_has_marker("Fusion Engine (Armored)", "(ARMORED)")); assert!(slot_has_marker("Fusion Engine (armored)", "(ARMORED)")); assert!(!slot_has_marker("Fusion Engine", "(ARMORED)")); assert_eq!(strip_slot_markers("Medium Laser (r)"), "Medium Laser"); } /// A name that is not ASCII must not panic on a byte that is not a /// character boundary. #[test] fn a_name_outside_ascii_is_read_rather_than_split_through() { assert_eq!(strip_slot_markers("Zeus Ärger (R)"), "Zeus Ärger"); assert!(!slot_has_marker("Ärger", "(R)")); } }