Something went wrong. Try again.
Data management tools for playing BattleTech
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450//! 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::<Vec<_>>().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<usize> { 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<Item = &str> { 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<Item = &'c EquipmentEntry> { 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<Item = (&'a str, &'c EquipmentEntry)> { 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<Item = (&'a str, &'c EquipmentEntry)> { 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<String> { 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<String>,}
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<String>) -> 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<String>) -> 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)")); }}