Something went wrong. Try again.
Data management tools for playing BattleTech
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292//! A unit design as its file declares it.
use std::collections::BTreeMap;
/// Which of MegaMek's two formats a unit was read from.#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum Format { /// `.mtf` — Meks. Mtf, /// `.blk` — vehicles, infantry, aerospace, buildings, everything else. Blk,}
impl Format { pub fn as_str(self) -> &'static str { match self { Format::Mtf => "mtf", Format::Blk => "blk", } }}
/// One equipment entry as the file declares it.////// `.mtf` names a location per mount and repeats the line for each one; `.blk`/// groups mounts under a location block. Both are normalised to one row per/// mount so counting is a `GROUP BY`.#[derive(Debug, Clone, PartialEq, Eq)]pub struct Mount { pub name: String, pub location: Option<String>, /// True when the entry is rear-facing — MegaMek marks it with an `(R)` /// suffix, which is not part of the equipment's name. pub rear: bool,}
/// A unit design as declared in its file.////// Everything here is *declared*: it is what the designer chose, read back/// verbatim. Nothing on this struct is derived, which is why battle value and/// cost are not fields on it — see [`crate::ComputedStats`].#[derive(Debug, Clone, Default)]pub struct Unit { /// Path within the library, so a row can be traced back to its file. pub path: String, pub format_str: String,
pub chassis: String, pub model: String, /// The Clan name, where the design has one alongside its Inner Sphere /// reporting name — `Nova` to the `Black Hawk`, `Timber Wolf` to the /// `Mad Cat`. MegaMek puts it in the display name, so it is part of a /// unit's identity rather than fluff. pub clan_name: Option<String>, /// `chassis` and `model` joined the way MegaMek displays a unit. pub name: String, /// The unit's masterunitlist.info record id, where the file declares one. /// These survive locally even though that site does not. pub mul_id: Option<i64>,
/// `.blk` declares this outright; `.mtf` is always a Mek. pub unit_type: Option<String>, /// `.mtf` only: Biped, Quad, Tripod, LAM and so on. pub config: Option<String>, pub tech_base: Option<String>, pub rules_level: Option<String>, pub role: Option<String>, pub source: Option<String>, /// Year of introduction. `.mtf` calls it `era`, `.blk` calls it `year`. pub year: Option<i64>,
pub mass: Option<f64>, pub engine: Option<String>, pub structure: Option<String>, pub myomer: Option<String>, pub heat_sinks: Option<String>, pub armor: Option<String>, pub motion_type: Option<String>,
pub walk_mp: Option<i64>, pub run_mp: Option<i64>, pub jump_mp: Option<i64>,
pub quirks: Vec<String>, pub equipment: Vec<Mount>, /// Armour points by location, keyed by the file's own location name. pub armor_locations: BTreeMap<String, i64>, /// Armour *type* by location, for the designs that differ location to /// location. Empty for every design whose `armor:` line is not /// `Patchwork`, which is all but five of them. pub armor_types: BTreeMap<String, String>, /// Slot contents by location: a Mek's critical hit table, and the /// equipment blocks of a `.blk`. Empty slots are kept, because which slots /// are free is part of the design and a battle value calculation will want /// the placement, not just the inventory. pub criticals: BTreeMap<String, Vec<String>>,
/// Every key the typed fields above do not claim, verbatim. Repeated keys /// are joined with `\n`. pub fields: BTreeMap<String, String>,}
impl Unit { /// MegaMek's display name, and the key that joins a parsed unit to a /// computed record. /// /// The Clan name goes in parentheses between chassis and model, which is /// how MegaMek renders it: `Black Hawk (Nova) Prime`. Getting this wrong /// costs the join on every dual-named Clan design, and there are 434 of /// them. /// The name a `.mul` might record this design under. /// /// Two tools spell the same design two ways. MegaMek keeps a Clan design's /// second name apart - `chassis:Loki`, `clanname:Hellbringer` - and writes /// the bare chassis; MekBay folds them together and writes /// `chassis="Loki (Hellbringer)"`. A force from one tool then matches /// nothing in a library indexed the other way, and every Clan design in it /// comes back missing. /// /// Both spellings are accepted, because both are in the wild and neither /// is wrong. pub fn answers_to(&self, chassis: &str, model: &str) -> bool { if self.model.trim() != model.trim() { return false; } let chassis = chassis.trim(); if self.chassis.trim() == chassis { return true; } match self.clan_name.as_deref().map(str::trim) { Some(clan) if !clan.is_empty() => { chassis == format!("{} ({clan})", self.chassis.trim()) } _ => false, } }
/// A battle armour suit's own weight class: 0 exoskeleton or PA(L) /// through 4 assault. `.blk` writes it as `<weightclass>`, and it is not /// the weight class a `MekSummary` reports, which is the unit's. pub fn suit_weight_class(&self) -> Option<i64> { self.fields.get("weightclass")?.trim().parse().ok() }
/// Whether a battle armour suit walks on four legs, which decides several /// things it cannot do. pub fn is_quad(&self) -> bool { self.fields .get("chassis") .is_some_and(|c| c.trim().eq_ignore_ascii_case("quad")) }
/// Whether the design is Clan-built, by the `techbase:` line. /// /// Only the two Clan bases count. A mixed design says which chassis it is /// built on - `Mixed (IS Chassis)` - and is read as that side, because /// that is the side its unprefixed equipment names belong to. pub fn is_clan(&self) -> bool { match self.tech_base.as_deref().map(str::trim) { Some(base) => { let lower = base.to_ascii_lowercase(); lower.starts_with("clan") || lower.contains("clan chassis") } None => false, } }
pub fn display_name(&self) -> String { let chassis = self.chassis.trim(); let model = self.model.trim(); let head = match self.clan_name.as_deref().map(str::trim) { Some(clan) if !clan.is_empty() => format!("{chassis} ({clan})"), _ => chassis.to_string(), }; if model.is_empty() { head } else { format!("{head} {model}") } }
/// Sum of the declared armour across every location. pub fn total_armor(&self) -> i64 { self.armor_locations.values().sum() }
/// Armour as a percentage of the most this design could have carried. /// /// What makes "well armoured" comparable across weight classes: a Locust /// with 64 points is close to full and an Atlas with 200 is not, and the /// raw figures say the opposite. Rounded to the nearest point, and never /// above 100 - front and rear plate share a torso's allowance, so a design /// that armours its back has moved points around rather than added any. /// /// `None` for anything this cannot answer for: a design with no tonnage, /// a tonnage the structure table has no row for, and every unit that is /// not a Mek, since the table is a Mek's. A `.blk` declares no `Config`, /// which is what that is read from. pub fn armor_percent(&self) -> Option<i64> { let tons = self.mass?; let shape = crate::Shape::from_config(Some(self.config.as_deref()?)); let most = crate::max_armor(tons, shape)?; if most <= 0 { return None; } Some(((self.total_armor() as f64 / most as f64) * 100.0).round() as i64) }
/// Store a key, joining repeats rather than letting the last one win. pub fn push_field(&mut self, key: &str, value: &str) { match self.fields.get_mut(key) { Some(existing) => { existing.push('\n'); existing.push_str(value); } None => { self.fields.insert(key.to_string(), value.to_string()); } } }}
#[cfg(test)]mod tests { use super::*;
fn unit(chassis: &str, clan: Option<&str>, model: &str) -> Unit { Unit { chassis: chassis.into(), clan_name: clan.map(Into::into), model: model.into(), ..Default::default() } }
// MekBay writes `Loki (Hellbringer)` where MegaMek writes `Loki` with the // second name held apart, and a force from either has to find the design. #[test] fn a_design_answers_to_both_spellings_of_its_name() { let loki = Unit { chassis: "Loki".into(), clan_name: Some("Hellbringer".into()), model: "A".into(), ..Default::default() }; assert!(loki.answers_to("Loki", "A")); assert!(loki.answers_to("Loki (Hellbringer)", "A")); assert!( !loki.answers_to("Hellbringer", "A"), "the clan name alone is not the chassis" ); assert!( !loki.answers_to("Loki", "B"), "a different model is a different design" );
// A design with no second name has only the one spelling. let atlas = Unit { chassis: "Atlas".into(), model: "AS7-D".into(), ..Default::default() }; assert!(atlas.answers_to("Atlas", "AS7-D")); assert!(!atlas.answers_to("Atlas (Something)", "AS7-D")); }
#[test] fn display_name_puts_the_clan_name_in_parentheses() { assert_eq!( unit("Black Hawk", Some("Nova"), "Prime").display_name(), "Black Hawk (Nova) Prime" ); assert_eq!(unit("Atlas", None, "AS7-D").display_name(), "Atlas AS7-D"); // A .blk Model block is often present but empty. assert_eq!( unit("Condor HoverBall", None, "").display_name(), "Condor HoverBall" ); }
#[test] fn repeated_fields_join_rather_than_overwrite() { let mut u = unit("Atlas", None, "AS7-D"); u.push_field("systemmanufacturer", "CHASSIS:Foundation"); u.push_field("systemmanufacturer", "ENGINE:Vlar"); assert_eq!( u.fields.get("systemmanufacturer").unwrap(), "CHASSIS:Foundation\nENGINE:Vlar" ); }}