Something went wrong. Try again.
Data management tools for playing BattleTech
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470//! Battle value, computed rather than borrowed.//!//! MegaMek works a design's battle value out when it loads it, and helm has//! been taking that number from `bridge/` ever since. That is enough while a//! force is a list of stock units, and stops being enough the moment anything//! about one changes - a different pilot, a damaged leg, a swapped ammunition//! bin, a C3 link. Those are deltas from a known design, and there is nothing//! to take a delta from unless we own the formula.//!//! # What it scores//!//! BattleMeks and combat vehicles: tanks, hovercraft, VTOLs and naval//! vessels. MegaMek has one calculator with a subclass per unit type, and the//! subclasses differ by very little - a vehicle is worth less for being a//! vehicle, its tonnage counts for half, and it has no heat budget to run out//! of. So this crate has one calculation and a [`Machine`] that says which of//! those apply, rather than two calculations that agree by hand.//!//! Everything else - aerospace, battle armour, infantry, support vehicles,//! anything that flies between planets - is declined by type rather than//! guessed at.//!//! # What this crate is written against//!//! The published BV 2.0 rules, checked against MegaMek's own answers for every//! unit in the library. MegaMek is GPL-2.0-or-later and helm is not, so it is//! used here the way [`crate::conformance`] uses it - as an oracle that says//! whether we agree, never as a source to copy from. Same bargain the repo//! already struck with MekBay: the vocabulary and the shape of the problem,//! never the code.//!//! # Why it does no I/O//!//! Two callers have to get the same answer: a browser, while a player edits a//! force, and the server that believes what the browser sends back. One crate//! compiled twice is the only way to be sure of that, so this depends on//! `helm-core` and nothing else, and `scripts/check-boundaries.sh` fails the//! build if that stops being true.//!//! # Scope//!//! Meks first. Every other unit type has its own calculator upstream, and each//! is a smaller job than this one once the shared parts exist. Anything not//! yet covered comes back as [`Unsupported`] rather than as a wrong number: a//! battle value that is quietly approximate is worse than no battle value,//! because a force list built on it looks fine.
mod attribution;mod battle;mod check;mod clusters;mod conformance;mod defensive;mod equipment;mod loadout;mod machine;mod offensive;mod repair;mod rules;mod vehicle;mod wear;
pub use attribution::{Attribution, Change, Term, Terms, attribute, between, terms_in};pub use battle::{Battle, wear_in_battle, wear_in_battles};pub use check::{Severity, Trouble, check};pub use clusters::{Cluster, clusters, labels, oddity};pub use conformance::{Conformance, KNOWN_DEVIATIONS, Mismatch, Tally};pub use defensive::{Defensive, defensive, tmm};pub use equipment::RULE_FLAGS;pub use machine::Machine;pub use offensive::{Offensive, offensive, speed_factor};pub use repair::{Repair, Slot, Total, repairs};pub use rules::{Adjustments, RULES, Rule, adjustments, fired};pub use wear::{Worn, wear_to};
use helm_core::{BvBreakdown, Catalogue, ComputedStats, Unit};
/// How one piece of equipment was classified, for the `--unit` view.////// The question a disagreement nearly always comes down to is "what did you do/// with this item", and the answer is otherwise invisible.#[derive(Debug, Clone, PartialEq)]pub struct Classified { pub name: String, /// How many of it the design mounts. pub slots: usize, pub battle_value: f64, pub role: &'static str,}
/// Every distinct item in a design's critical slots, and what it was counted/// as. Items worth nothing either way are left out.pub fn classify(unit: &Unit, catalogue: &Catalogue) -> Vec<Classified> { let loadout = loadout::Loadout::read(unit, catalogue); let mut out = Vec::new(); for mounting in loadout.counts() { let entry = mounting.entry; let bv = entry.battle_value.unwrap_or(0.0); let role = if equipment::is_ammo(entry) { "ammo" } else if equipment::counts_as_offensive(entry) { "weapon" } else if equipment::counts_as_defensive(entry) { "defensive" } else if equipment::counts_as_offensive_equipment(entry) { "offensive equipment" } else if bv != 0.0 { "UNCOUNTED" } else { continue; }; out.push(Classified { name: entry.internal_name.clone(), slots: mounting.count, battle_value: bv, role, }); } out.sort_by(|a, b| a.name.cmp(&b.name)); out}
/// What a design carries of a C3 network.////// The set is equipment like any other, so this is a read of the loadout - and/// it is here rather than in `helm-force` because only this crate knows what a/// design is carrying. What the network is then *worth* is `helm-force`'s job,/// since it depends on the other units on it.////// A boosted set beats a plain one where a design carries both, because that/// is the share MegaMek pays it.pub fn c3_kind(unit: &Unit, catalogue: &Catalogue) -> helm_core::C3 { let loadout = loadout::Loadout::read(unit, catalogue); for (flags, kind) in [ (&["F_C3SBS", "F_C3MBS"][..], helm_core::C3::Boosted), (&["F_C3I"][..], helm_core::C3::Improved), (&["F_NOVA"][..], helm_core::C3::Nova), (&["F_C3S", "F_C3M", "F_C3EM"][..], helm_core::C3::Standard), ] { if flags.iter().any(|flag| loadout.has_flag(flag)) { return kind; } } helm_core::C3::None}
/// This crate's own version, recorded beside every number it produces.////// It exists so that a client and a server disagreeing can be pinned to a/// version skew rather than investigated as a rules bug. Bump it whenever an/// answer changes, including when a bug is fixed - "we now round this the way/// MegaMek does" changes answers, and a cached browser that does not know/// that is exactly the case this guards.pub const RULES_VERSION: &str = "0.1.0";
/// Which MegaMek these rules were written against and checked with.////// Battle value is defined by what MegaMek computes rather than by a document,/// so an answer is only meaningful beside the version it agrees with./// `conformance.txt` records the same figure, and the two are checked against/// each other by the conformance test.////// Worth storing beside any figure kept for later: a battle value recorded/// without it cannot be told from one computed against a different MegaMek,/// and the difference is silent.pub const CONFORMED_TO_MEGAMEK: &str = "0.51.0";
/// What has happened to a machine since it left the factory.////// Battle value is worked out in four scopes and this is the second of them./// They are not interchangeable and the order they combine in is not free:////// 1. **The design.** A `Unit` and the equipment catalogue - the sheet./// 2. **The condition it is in.** This: what the machine has lost. It changes/// terms *inside* the rating, because armour still on the Mek is what the/// defensive half counts./// 3. **The force it is attached to.** A C3 network raises every member's/// figure, and by an amount that depends on the other members - so it/// cannot be worked out from one design at all. Added to the rounded base./// 4. **The crew flying it.** A multiplier on everything above.////// MegaMek settles 3 and 4 in that order and it matters: `adjustBV` adds the/// C3 bonus to the rounded base and *then* multiplies by the pilot factor, so/// `(base + c3) x pilot` and never `base x pilot + c3`.////// Everything here is an override: an empty condition is a factory-fresh/// machine, which is what a figure quoted without qualification means.#[derive(Debug, Clone, Default, PartialEq, Eq)]pub struct Condition { /// Armour points still on each location, by the file's own code - `LT`, /// `RTC`. A location not named here is undamaged. pub armor: std::collections::BTreeMap<String, i64>, /// Internal structure still in each location, keyed the same way. Battle /// value counts the frame a Mek still has, not the one it was built with. pub structure: std::collections::BTreeMap<String, i64>, /// Critical slots that have been shot out, by the location's own name - /// `Right Arm` - and the slot's number down the list. A critical hit takes /// out whatever is in the slot, so a weapon filling three of them is gone /// when any one of the three is hit. pub destroyed: std::collections::BTreeMap<String, std::collections::BTreeSet<usize>>, /// Ammunition bins with nothing left in them, keyed the same way. /// /// An empty bin is worth nothing and, just as usefully, no longer explodes /// - which is why running a Locust dry *raises* its battle value. pub empty_ammo: std::collections::BTreeMap<String, std::collections::BTreeSet<usize>>, /// What a slot holds now, where it is not what the design put there. /// /// Damage is not the only thing that happens to a machine between the /// factory and the field. A player loads inferno rounds instead of /// standard, or swaps an omni pod, and MegaMek scores the machine that /// results - a `.mul` records the type in every slot for exactly that /// reason. A name here that the catalogue does not know is ignored rather /// than treated as an empty slot: the design's own answer is the better /// guess. pub loaded: std::collections::BTreeMap<String, std::collections::BTreeMap<usize, String>>,}
impl From<&helm_unitfile::MulUnit> for Condition { /// What a `.mul` says has happened to a machine. /// /// The two agree field for field, because `MulUnit` keys its locations the /// way a `.mtf` does rather than the way MegaMek numbers them - that /// mapping is done once, where the file is read. fn from(unit: &helm_unitfile::MulUnit) -> Self { Condition { armor: unit.armor.clone(), structure: unit.structure.clone(), destroyed: unit.destroyed.clone(), empty_ammo: unit.empty_ammo.clone(), loaded: unit.loaded.clone(), } }}
impl Condition { /// A machine as it left the factory. pub fn undamaged() -> Self { Self::default() }
/// Whether anything is overridden at all, which is worth knowing because /// the undamaged case can reuse a figure computed once. pub fn is_undamaged(&self) -> bool { self.armor.is_empty() && self.structure.is_empty() && self.destroyed.is_empty() && self.empty_ammo.is_empty() }}
/// 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 { /// Not a Mek. Each other unit type has its own calculator, and none of /// them is written yet. 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}"), } }}
/// 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/// makes every battle value quoted without a pilot mean this. Applying a/// crew's skill is `helm-force`'s job and is deliberately not folded in here.pub fn battle_value(unit: &Unit, catalogue: &Catalogue) -> Result<i64, Unsupported> { battle_value_in(unit, catalogue, &Condition::undamaged())}
/// The battle value of a machine in the condition it is in.////// This is the base figure: the force and the crew are the two scopes above/// it and belong to `helm_force`.pub fn battle_value_in( unit: &Unit, catalogue: &Catalogue, condition: &Condition,) -> Result<i64, Unsupported> { breakdown_in(unit, catalogue, condition)? .battle_value .ok_or(Unsupported::NotImplemented("battle value"))}
/// The same figure, with the parts it was built from.////// A part this crate cannot yet produce comes back as `None` rather than as a/// zero. That is what lets the conformance report say a finished half is right/// while the other half is still unwritten, instead of reporting every design/// as wrong until all of it exists.pub fn breakdown(unit: &Unit, catalogue: &Catalogue) -> Result<BvBreakdown, Unsupported> { breakdown_in(unit, catalogue, &Condition::undamaged())}
/// The same, for a machine in the condition it is in.pub fn breakdown_in( unit: &Unit, catalogue: &Catalogue, condition: &Condition,) -> Result<BvBreakdown, Unsupported> { let machine = readable(unit, catalogue, condition)?; let terms = attribution::from_mek(&machine)?; let mut out = BvBreakdown::new(unit.display_name()); out.defensive = Some(terms.defensive.total); out.offensive = Some(terms.offensive.total); out.battle_value = Some(terms.battle_value); Ok(out)}
/// Read a design, or say why it cannot be scored.////// The gate every entry point goes through, so that a design declined here is/// declined by all of them. Both refusals are deliberate rather than a/// shortfall: a unit type with no calculator, and a LAM, whose battle value is/// worked out over three movement modes with a heat budget of its own. None of/// that is written, and a LAM scored as a Mek is wrong rather than/// approximately right.fn readable<'a>( unit: &Unit, catalogue: &'a Catalogue, condition: &Condition,) -> Result<Machine<'a>, Unsupported> { if is_combat_vehicle(unit) { return crate::vehicle::read_in(unit, catalogue, condition); } if !is_mek(unit) { return Err(Unsupported::UnitType( unit.unit_type.clone().unwrap_or_else(|| "unknown".into()), )); } if unit .config .as_deref() .is_some_and(|c| c.to_ascii_lowercase().contains("lam")) { return Err(Unsupported::NotImplemented("LAM movement modes")); } machine::Machine::read_in(unit, catalogue, condition)}
/// Fill the shared computed type, so that comparing this producer against the/// bridge is [`ComputedStats::differences`] and nothing bespoke.////// Only the fields this crate computes are filled. A producer that scores/// battle value and nothing else must not read as wrong about cost, and/// `differences` already treats a field only one side populated as agreement.pub fn computed_stats(unit: &Unit, catalogue: &Catalogue) -> ComputedStats { let mut stats = ComputedStats::new(unit.display_name()); stats.battle_value = battle_value(unit, catalogue).ok(); stats}
/// Whether this design is one we score.////// A `.mtf` is always a Mek and declares no unit type; a `.blk` declares one./// Reading it the other way round - trusting the field - silently drops every/// Mek in the library, which is the whole population this crate is aimed at./// Whether this design is one MegaMek scores with its combat vehicle/// calculator.////// `Tank` only for now. A VTOL, a support tank and a large support tank are/// each their own calculator upstream, and reading them as a tank would be/// wrong rather than approximately right.fn is_combat_vehicle(unit: &Unit) -> bool { unit.unit_type .as_deref() .is_some_and(|t| t.eq_ignore_ascii_case("tank") || t.eq_ignore_ascii_case("vtol"))}
fn is_mek(unit: &Unit) -> bool { match unit.unit_type.as_deref() { None => unit.format_str == "mtf", Some(t) => t.eq_ignore_ascii_case("mek") || t.eq_ignore_ascii_case("mech"), }}
#[cfg(test)]mod tests { use super::*;
fn machine() -> Unit { Unit { chassis: "Atlas".into(), model: "AS7-D".into(), format_str: "mtf".into(), mass: Some(100.0), ..Default::default() } }
#[test] fn an_mtf_with_no_declared_type_is_a_mek() { assert!(is_mek(&machine())); }
#[test] fn a_unit_type_with_no_calculator_is_declined_by_type() { let dropship = Unit { format_str: "blk".into(), unit_type: Some("Dropship".into()), ..Default::default() }; assert_eq!( breakdown(&dropship, &Catalogue::default()).unwrap_err(), Unsupported::UnitType("Dropship".into()) ); }
#[test] fn a_tank_and_a_vtol_are_both_read_as_combat_vehicles() { for kind in ["Tank", "VTOL"] { let unit = Unit { format_str: "blk".into(), unit_type: Some(kind.into()), ..Default::default() }; assert!(is_combat_vehicle(&unit), "{kind} should be a vehicle"); // Read far enough to want a tonnage, rather than declined for // being the wrong kind of thing. assert_eq!( breakdown(&unit, &Catalogue::default()).unwrap_err(), Unsupported::Missing("mass") ); } }
#[test] fn a_support_vehicle_is_not_a_combat_vehicle() { let support = Unit { format_str: "blk".into(), unit_type: Some("SupportTank".into()), ..Default::default() }; assert!(!is_combat_vehicle(&support)); }
// The point of the shared type: a producer that fills one field is not // wrong about the ones it left alone. #[test] fn computed_stats_disagrees_with_the_bridge_about_nothing_it_did_not_compute() { let mut bridge = ComputedStats::new("Atlas AS7-D"); bridge.cost = Some(9_626_000); bridge.battle_value = Some(1897);
let ours = computed_stats(&machine(), &Catalogue::default()); // Nothing computed yet, so nothing to disagree about. assert!(bridge.differences(&ours).is_empty()); }}