//! 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, /// 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, /// `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, /// `.blk` declares this outright; `.mtf` is always a Mek. pub unit_type: Option, /// `.mtf` only: Biped, Quad, Tripod, LAM and so on. pub config: Option, pub tech_base: Option, pub rules_level: Option, pub role: Option, pub source: Option, /// Year of introduction. `.mtf` calls it `era`, `.blk` calls it `year`. pub year: Option, pub mass: Option, pub engine: Option, pub structure: Option, pub myomer: Option, pub heat_sinks: Option, pub armor: Option, pub motion_type: Option, pub walk_mp: Option, pub run_mp: Option, pub jump_mp: Option, pub quirks: Vec, pub equipment: Vec, /// Armour points by location, keyed by the file's own location name. pub armor_locations: BTreeMap, /// 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, /// 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>, /// Every key the typed fields above do not claim, verbatim. Repeated keys /// are joined with `\n`. pub fields: BTreeMap, } 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 ``, and it is not /// the weight class a `MekSummary` reports, which is the unit's. pub fn suit_weight_class(&self) -> Option { 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 { 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" ); } }