//! 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 metrics; mod rng; mod structure; mod unit; pub use bv::{BvBreakdown, RATING_TOLERANCE}; pub use catalogue::{Catalogue, EquipmentEntry}; pub use computed::{AlphaStrike, ComputedStats}; pub use design::{ ArmorKind, C3, CockpitKind, Engine, EngineKind, GyroKind, HeatSinkKind, HeatSinks, StructureKind, }; pub use metrics::{ CombatMetrics, FORMATION_RANGES, FORMATION_SINGLE_RANGES, can_make_anti_mek_attacks, can_ride_a_mek, damage_at_range, equipment_list, jumping_mp, max_single_damage_at_range, mounted_items, walking_mp, }; pub use rng::Rng; pub use structure::{Shape, max_armor, structure_per_location, structure_total_in}; pub use unit::{Format, Mount, 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 { let mut name = slot.to_string(); for marker in SLOT_MARKERS { while let Some(at) = name.find(marker) { name.replace_range(at..at + marker.len(), " "); } } name.split_whitespace().collect::>().join(" ") } /// The markers [`strip_slot_markers`] knows: rear-facing, pod-mounted, /// turret-mounted and armoured. pub const SLOT_MARKERS: [&str; 4] = ["(R)", "(OMNIPOD)", "(T)", "(ARMORED)"]; /// 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(|c| c.is_ascii_alphanumeric()) .map(|c| c.to_ascii_lowercase()) .collect() } /// 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 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); } }