diff --git a/TODO.md b/TODO.md index 219bbcb..b25cdd1 100644 --- a/TODO.md +++ b/TODO.md @@ -149,10 +149,23 @@ The long pole, and the reason `helm-core` does no I/O. Tracked as committed, and the test fails when a run stops matching it. So an improvement shows up as its own diff and a regression cannot be merged quietly. It reads 0 / 4,294 today, which is the honest number. -- [ ] **The design model battle value reads.** Engine, gyro, cockpit, internal - structure and armour type - including patchwork - plus the equipment - MegaMek synthesises while loading rather than reading from the file. All - of it is in the file already; most is sitting untyped in `Unit::fields`. +- [x] **The design model battle value reads.** Engine (rating and type), heat + sinks, gyro, cockpit, internal structure and armour type, all typed in + `helm-core`'s `design` module against the vocabulary the 4,294 Meks + actually use rather than a remembered list. Every enum has an `Other` + arm, so a component a future MegaMek adds makes a design unscoreable + instead of quietly scoring as the wrong component. + + Two things fell out. The tech-base marker is spelled in three places - + `XL (Clan) Engine`, `XL Engine(IS)`, `IS Endo Steel` - and reading only + the first misses 116 designs. And patchwork armour writes its type in + front of the points, `LT armor:Clan Standard(Clan):26`, which the parser + rejected as non-numeric: all five patchwork designs were reading as + almost unarmoured, a Zeus-X at 28 points where MegaMek says 247. Total + armour now agrees with MegaMek on all 10,749 designs it reports one for. + +- [ ] **The equipment MegaMek synthesises while loading** rather than reading + from the file, which battle value counts. Recorded on `UnitFacets::loadout`. - [ ] **Defensive battle rating.** Armour, structure, gyro, defensive equipment, the explosive ammunition penalty, and the defensive movement factor. diff --git a/crates/helm-core/src/design.rs b/crates/helm-core/src/design.rs new file mode 100644 index 0000000..0b3ebc5 --- /dev/null +++ b/crates/helm-core/src/design.rs @@ -0,0 +1,498 @@ +//! What a design's declared strings actually say. +//! +//! A `.mtf` records its components as prose: `300 XL (Clan) Engine`, +//! `10 IS Double`, `Standard(Inner Sphere)`. [`crate::Unit`] keeps those +//! verbatim, because what the file says is the thing worth preserving. This +//! module is the reading of them, and it lives here rather than beside the +//! parser because interpretation is shared - battle value needs the engine +//! type, and the heat metrics already needed the heat sink type. +//! +//! Every enum has an `Other` arm carrying the original string. A MegaMek +//! release that adds a component should make a design score as unsupported, +//! never as one of the components it is not. +//! +//! The vocabulary is taken from the 4,294 Meks in MegaMek 0.51.0: 20 engine +//! spellings, 5 heat sink types, 22 armour types, 11 structure types, 6 gyros +//! and 12 cockpits. Where a line is absent the component is the standard one, +//! which is why `gyro:` appears in only 727 files. + +use crate::Unit; + +/// A Mek's engine: how big, and of what kind. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Engine { + /// The rating, so walk MP times tonnage is checkable and the engine's own + /// weight is derivable. + pub rating: i64, + pub kind: EngineKind, + pub clan: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EngineKind { + Fusion, + Xl, + Xxl, + LargeXxl, + Light, + Compact, + Ice, + FuelCell, + Fission, + Other(String), +} + +/// Heat sinks, as the `heat sinks:` line declares them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HeatSinks { + pub count: i64, + pub kind: HeatSinkKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HeatSinkKind { + Single, + Double, + Laser, + Compact, +} + +impl HeatSinkKind { + /// Heat shed per sink per turn. Doubles and laser sinks shed two. + pub fn dissipation(self) -> i64 { + match self { + HeatSinkKind::Single | HeatSinkKind::Compact => 1, + HeatSinkKind::Double | HeatSinkKind::Laser => 2, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ArmorKind { + Standard, + FerroFibrous, + LightFerroFibrous, + HeavyFerroFibrous, + FerroLamellor, + Stealth, + Hardened, + Reactive, + Reflective, + BallisticReinforced, + ImpactResistant, + HeatDissipating, + AntiPenetrativeAblation, + Industrial, + HeavyIndustrial, + Commercial, + Primitive, + /// Different armour in different locations. The type is then declared per + /// location and [`Unit::armor_kind_at`] is the one to ask. + Patchwork, + Other(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StructureKind { + Standard, + EndoSteel, + EndoComposite, + Composite, + Reinforced, + Industrial, + Other(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GyroKind { + Standard, + Xl, + Compact, + HeavyDuty, + Superheavy, + /// A design with no gyro at all - 14 of them, all of which have something + /// else going on. + None, + Other(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CockpitKind { + Standard, + Small, + Industrial, + TorsoMounted, + Primitive, + PrimitiveIndustrial, + CommandConsole, + Interface, + QuadVee, + Tripod, + Superheavy, + SuperheavyTripod, + Other(String), +} + +/// Strip the tech-base marker a component name may carry, and say whether it +/// was there. +/// +/// MegaMek spells it two ways in the same file format - `XL (Clan) Engine` +/// puts it in the middle and `XL Engine(IS)` puts it at the end - so a reader +/// that handles one silently misreads 116 designs written the other way. +fn strip_tech_base(s: &str) -> (String, bool) { + let mut clan = false; + let mut out = s.to_string(); + for marker in ["(Clan)", "(clan)", "(Inner Sphere)", "(IS)"] { + while let Some(at) = out.find(marker) { + clan |= marker.eq_ignore_ascii_case("(clan)"); + out.replace_range(at..at + marker.len(), " "); + } + } + // A leading `IS ` or `Clan ` is the third spelling, used by `structure:`. + let trimmed = out.trim().to_string(); + if let Some(rest) = trimmed.strip_prefix("Clan ") { + return (rest.trim().to_string(), true); + } + if let Some(rest) = trimmed.strip_prefix("IS ") { + return (rest.trim().to_string(), clan); + } + (collapse_spaces(&trimmed), clan) +} + +fn collapse_spaces(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Compare ignoring case, spaces and punctuation, so `Endo-Composite` and +/// `Endo Composite` are one component rather than two. +fn same(a: &str, b: &str) -> bool { + crate::normalize(a) == crate::normalize(b) +} + +impl Unit { + /// The engine, read from `engine:300 XL (Clan) Engine`. + pub fn engine_spec(&self) -> Option { + let raw = self.engine.as_deref()?.trim(); + let (rating, rest) = match raw.split_once(char::is_whitespace) { + Some((head, rest)) => (head.parse::().ok()?, rest), + None => return None, + }; + let (name, clan) = strip_tech_base(rest); + let name = name.trim_end_matches(" Engine").trim(); + let kind = if same(name, "Fusion") { + EngineKind::Fusion + } else if same(name, "XL") { + EngineKind::Xl + } else if same(name, "XXL") { + EngineKind::Xxl + } else if same(name, "Large XXL") { + EngineKind::LargeXxl + } else if same(name, "Light") { + EngineKind::Light + } else if same(name, "Compact") { + EngineKind::Compact + } else if same(name, "ICE") { + EngineKind::Ice + } else if same(name, "Fuel Cell") { + EngineKind::FuelCell + } else if same(name, "Fission") { + EngineKind::Fission + } else { + EngineKind::Other(name.to_string()) + }; + Some(Engine { rating, kind, clan }) + } + + /// Heat sinks, read from `heat sinks:10 IS Double`. + pub fn heat_sink_spec(&self) -> Option { + let raw = self.heat_sinks.as_deref()?.trim(); + // A `.blk` can give the count with no type beside it, so a line with + // no space in it is a count rather than nothing. + let (count, rest) = raw.split_once(char::is_whitespace).unwrap_or((raw, "")); + let count = count.parse::().ok()?; + let (name, _) = strip_tech_base(rest); + let kind = if same(&name, "Double") { + HeatSinkKind::Double + } else if same(&name, "Laser") { + HeatSinkKind::Laser + } else if same(&name, "Compact") { + HeatSinkKind::Compact + } else if same(&name, "Single") { + HeatSinkKind::Single + } else { + // Every spelling in 0.51.0 is one of the four. An unknown one is + // far likelier to be a single than to be nothing. + HeatSinkKind::Single + }; + Some(HeatSinks { count, kind }) + } + + /// The armour type, read from `armor:Ferro-Fibrous(Clan)`. + pub fn armor_kind(&self) -> ArmorKind { + self.armor + .as_deref() + .map(armor_kind_from) + .unwrap_or(ArmorKind::Standard) + } + + /// The armour type in one location, which differs from the design's only + /// when it is [`ArmorKind::Patchwork`]. + /// + /// The location key is the file's own - `LA`, `CT`, `RTL` - matching + /// [`Unit::armor_locations`]. + pub fn armor_kind_at(&self, location: &str) -> ArmorKind { + match self.armor_types.get(location) { + Some(declared) => armor_kind_from(declared), + None => self.armor_kind(), + } + } + + /// The internal structure type, read from `structure:IS Endo Steel`. + pub fn structure_kind(&self) -> StructureKind { + let Some(raw) = self.structure.as_deref() else { + return StructureKind::Standard; + }; + let (name, _) = strip_tech_base(raw); + let name = name.trim_end_matches(" Prototype").trim().to_string(); + if same(&name, "Endo Steel") { + StructureKind::EndoSteel + } else if same(&name, "Endo-Composite") { + StructureKind::EndoComposite + } else if same(&name, "Composite") { + StructureKind::Composite + } else if same(&name, "Reinforced") { + StructureKind::Reinforced + } else if same(&name, "Industrial") { + StructureKind::Industrial + } else if same(&name, "Standard") { + StructureKind::Standard + } else { + StructureKind::Other(name) + } + } + + /// The gyro. Absent means standard, which is 3,567 of 4,294 designs. + pub fn gyro_kind(&self) -> GyroKind { + let Some(raw) = self.fields.get("gyro") else { + return GyroKind::Standard; + }; + let name = raw.trim().trim_end_matches(" Gyro").trim(); + if same(name, "Standard") { + GyroKind::Standard + } else if same(name, "XL") { + GyroKind::Xl + } else if same(name, "Compact") { + GyroKind::Compact + } else if same(name, "Heavy Duty") { + GyroKind::HeavyDuty + } else if same(name, "Superheavy") { + GyroKind::Superheavy + } else if same(name, "None") { + GyroKind::None + } else { + GyroKind::Other(name.to_string()) + } + } + + /// The cockpit. Absent means standard. + pub fn cockpit_kind(&self) -> CockpitKind { + let Some(raw) = self.fields.get("cockpit") else { + return CockpitKind::Standard; + }; + let name = raw.trim().trim_end_matches(" Cockpit").trim(); + if same(name, "Standard") { + CockpitKind::Standard + } else if same(name, "Small") { + CockpitKind::Small + } else if same(name, "Industrial") { + CockpitKind::Industrial + } else if same(name, "Torso-Mounted") { + CockpitKind::TorsoMounted + } else if same(name, "Primitive") { + CockpitKind::Primitive + } else if same(name, "Primitive Industrial") { + CockpitKind::PrimitiveIndustrial + } else if same(name, "Command Console") { + CockpitKind::CommandConsole + } else if same(name, "Interface") { + CockpitKind::Interface + } else if same(name, "QuadVee") { + CockpitKind::QuadVee + } else if same(name, "Tripod") { + CockpitKind::Tripod + } else if same(name, "Superheavy") { + CockpitKind::Superheavy + } else if same(name, "Superheavy Tripod") { + CockpitKind::SuperheavyTripod + } else { + CockpitKind::Other(name.to_string()) + } + } +} + +fn armor_kind_from(raw: &str) -> ArmorKind { + let (name, _) = strip_tech_base(raw); + let name = name.trim_end_matches(" Prototype").trim().to_string(); + if same(&name, "Standard") { + ArmorKind::Standard + } else if same(&name, "Ferro-Fibrous") { + ArmorKind::FerroFibrous + } else if same(&name, "Light Ferro-Fibrous") { + ArmorKind::LightFerroFibrous + } else if same(&name, "Heavy Ferro-Fibrous") { + ArmorKind::HeavyFerroFibrous + } else if same(&name, "Ferro-Lamellor") { + ArmorKind::FerroLamellor + } else if same(&name, "Stealth") { + ArmorKind::Stealth + } else if same(&name, "Hardened") { + ArmorKind::Hardened + } else if same(&name, "Reactive") { + ArmorKind::Reactive + } else if same(&name, "Reflective") { + ArmorKind::Reflective + } else if same(&name, "Ballistic-Reinforced") { + ArmorKind::BallisticReinforced + } else if same(&name, "Impact-Resistant") { + ArmorKind::ImpactResistant + } else if same(&name, "Heat-Dissipating") { + ArmorKind::HeatDissipating + } else if same(&name, "Anti-Penetrative Ablation") { + ArmorKind::AntiPenetrativeAblation + } else if same(&name, "Heavy Industrial") { + ArmorKind::HeavyIndustrial + } else if same(&name, "Industrial") { + ArmorKind::Industrial + } else if same(&name, "Commercial") { + ArmorKind::Commercial + } else if same(&name, "Primitive") { + ArmorKind::Primitive + } else if same(&name, "Patchwork") { + ArmorKind::Patchwork + } else { + ArmorKind::Other(name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unit(field: &str, value: &str) -> Unit { + let mut u = Unit::default(); + match field { + "engine" => u.engine = Some(value.into()), + "heat sinks" => u.heat_sinks = Some(value.into()), + "armor" => u.armor = Some(value.into()), + "structure" => u.structure = Some(value.into()), + other => { + u.push_field(other, value); + } + } + u + } + + // Both spellings occur in one library, and reading only the first + // misreads 116 designs as an unknown engine. + #[test] + fn the_tech_base_marker_is_read_wherever_megamek_put_it() { + let mid = unit("engine", "300 XL (Clan) Engine") + .engine_spec() + .unwrap(); + assert_eq!(mid.kind, EngineKind::Xl); + assert_eq!(mid.rating, 300); + assert!(mid.clan); + + let end = unit("engine", "300 XL Engine(IS)").engine_spec().unwrap(); + assert_eq!(end.kind, EngineKind::Xl); + assert!(!end.clan); + + let plain = unit("engine", "300 Fusion Engine").engine_spec().unwrap(); + assert_eq!(plain.kind, EngineKind::Fusion); + } + + #[test] + fn heat_sinks_carry_a_count_and_a_rate() { + let hs = unit("heat sinks", "10 IS Double").heat_sink_spec().unwrap(); + assert_eq!(hs.count, 10); + assert_eq!(hs.kind, HeatSinkKind::Double); + assert_eq!(hs.kind.dissipation(), 2); + + let single = unit("heat sinks", "20 Single").heat_sink_spec().unwrap(); + assert_eq!(single.kind.dissipation(), 1); + // A laser sink is not a single one, and reading it as one loses 10 + // points of dissipation on a design that spent tonnage for it. + let laser = unit("heat sinks", "10 Laser").heat_sink_spec().unwrap(); + assert_eq!(laser.kind, HeatSinkKind::Laser); + assert_eq!(laser.kind.dissipation(), 2); + } + + #[test] + fn structure_and_armour_read_through_their_tech_base() { + assert_eq!( + unit("structure", "Clan Endo Steel").structure_kind(), + StructureKind::EndoSteel + ); + assert_eq!( + unit("structure", "IS Endo-Composite").structure_kind(), + StructureKind::EndoComposite + ); + assert_eq!( + unit("armor", "Ferro-Fibrous(Clan)").armor_kind(), + ArmorKind::FerroFibrous + ); + assert_eq!( + unit("armor", "Standard(Inner Sphere)").armor_kind(), + ArmorKind::Standard + ); + // Spelled with a space in one place and a hyphen in another. + assert_eq!( + unit("armor", "Industrial (Inner Sphere)").armor_kind(), + ArmorKind::Industrial + ); + } + + // A prototype is the component, built early. Reading it as an unknown one + // would decline to score eight designs that are perfectly ordinary. + #[test] + fn a_prototype_component_is_still_that_component() { + assert_eq!( + unit("structure", "IS Endo Steel Prototype").structure_kind(), + StructureKind::EndoSteel + ); + assert_eq!( + unit("armor", "Ferro-Fibrous Prototype(Inner Sphere)").armor_kind(), + ArmorKind::FerroFibrous + ); + } + + // Absent means standard, which is most of the library for both. + #[test] + fn an_undeclared_gyro_and_cockpit_are_the_standard_ones() { + let bare = Unit::default(); + assert_eq!(bare.gyro_kind(), GyroKind::Standard); + assert_eq!(bare.cockpit_kind(), CockpitKind::Standard); + assert_eq!(unit("gyro", "XL Gyro").gyro_kind(), GyroKind::Xl); + assert_eq!( + unit("cockpit", "Torso-Mounted Cockpit").cockpit_kind(), + CockpitKind::TorsoMounted + ); + } + + // An unrecognised component must not read as a standard one. A design + // scored as though it had ordinary armour is worse than one not scored. + #[test] + fn an_unknown_component_says_so_rather_than_defaulting() { + assert_eq!( + unit("armor", "Nanotube Whatsit(Clan)").armor_kind(), + ArmorKind::Other("Nanotube Whatsit".into()) + ); + assert!(matches!( + unit("engine", "300 Antimatter Engine") + .engine_spec() + .unwrap() + .kind, + EngineKind::Other(_) + )); + } +} diff --git a/crates/helm-core/src/lib.rs b/crates/helm-core/src/lib.rs index 65964ee..de132dc 100644 --- a/crates/helm-core/src/lib.rs +++ b/crates/helm-core/src/lib.rs @@ -25,12 +25,16 @@ mod catalogue; mod computed; +mod design; mod metrics; mod rng; mod unit; pub use catalogue::{Catalogue, EquipmentEntry}; pub use computed::{AlphaStrike, ComputedStats}; +pub use design::{ + ArmorKind, CockpitKind, Engine, EngineKind, GyroKind, HeatSinkKind, HeatSinks, StructureKind, +}; pub use metrics::{ CombatMetrics, FORMATION_RANGES, FORMATION_SINGLE_RANGES, damage_at_range, max_single_damage_at_range, diff --git a/crates/helm-core/src/metrics.rs b/crates/helm-core/src/metrics.rs index b4c8ddb..0f5fde4 100644 --- a/crates/helm-core/src/metrics.rs +++ b/crates/helm-core/src/metrics.rs @@ -16,19 +16,6 @@ use crate::{Catalogue, EquipmentEntry, Unit}; -/// Heat sinking per sink, by the type a `.mtf` names. -/// -/// The four spellings in the 0.51.0 library are `Single`, `IS Double`, -/// `Clan Double` and `Laser`. Doubles and laser sinks dissipate two each. -fn dissipation_per_sink(kind: &str) -> i64 { - let k = kind.to_ascii_lowercase(); - if k.contains("double") || k.contains("laser") { - 2 - } else { - 1 - } -} - /// How a design performs, derived rather than declared. #[derive(Debug, Clone, Default, PartialEq)] pub struct CombatMetrics { @@ -222,30 +209,17 @@ pub const FORMATION_RANGES: &[i64] = &[6, 7, 9, 18]; /// The bands asked about for a single weapon rather than a total. pub const FORMATION_SINGLE_RANGES: &[i64] = &[15, 18]; -/// Parse the `heat sinks:` line into a dissipation figure. +/// How much heat a design sheds in a turn. /// -/// A `.mtf` writes `20 Single` or `10 IS Double`; a `.blk` gives the count in -/// `` and the type in ``, which the parser records the -/// same way. A design with neither dissipates nothing rather than defaulting -/// to a number nobody chose. +/// The `heat sinks:` line is read by [`Unit::heat_sink_spec`], which battle +/// value needs too; this is only the arithmetic over it. A design that +/// declares none dissipates nothing rather than defaulting to a number nobody +/// chose. fn heat_sink_dissipation(unit: &Unit) -> i64 { - let Some(spec) = unit.heat_sinks.as_deref() else { - return 0; - }; - let spec = spec.trim(); - let count: i64 = spec - .split_whitespace() - .next() - .and_then(|w| w.parse().ok()) - .unwrap_or(0); - if count <= 0 { - return 0; + match unit.heat_sink_spec() { + Some(hs) if hs.count > 0 => hs.count * hs.kind.dissipation(), + _ => 0, } - let kind = spec - .split_once(char::is_whitespace) - .map(|(_, k)| k) - .unwrap_or(""); - count * dissipation_per_sink(kind) } #[cfg(test)] diff --git a/crates/helm-core/src/unit.rs b/crates/helm-core/src/unit.rs index c45ad6a..43ac921 100644 --- a/crates/helm-core/src/unit.rs +++ b/crates/helm-core/src/unit.rs @@ -85,6 +85,10 @@ pub struct Unit { 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 diff --git a/crates/helm-unitfile/src/mtf.rs b/crates/helm-unitfile/src/mtf.rs index cb69051..e5690d6 100644 --- a/crates/helm-unitfile/src/mtf.rs +++ b/crates/helm-unitfile/src/mtf.rs @@ -102,12 +102,28 @@ pub fn parse_mtf(path: &str, text: &str) -> Result { // "LA armor:34" and friends. Checked before the generic field store so // the armour map is typed, and `armor:Standard(Inner Sphere)` - the // armour *type* - is not mistaken for a location. - if let Some(loc) = key.strip_suffix(" armor") - && let Ok(points) = value.parse::() - { - unit.armor_locations - .insert(loc.to_ascii_uppercase(), points); - continue; + // + // A patchwork design writes the location's own armour type in front of + // the points: `LT armor:Clan Standard(Clan):26`. Insisting on a bare + // integer here dropped every location of all five of them, so they + // read as having no armour at all. + if let Some(loc) = key.strip_suffix(" armor") { + let loc = loc.to_ascii_uppercase(); + match value.rsplit_once(':') { + Some((kind, points)) => { + if let Ok(points) = points.trim().parse::() { + unit.armor_locations.insert(loc.clone(), points); + unit.armor_types.insert(loc, kind.trim().to_string()); + continue; + } + } + None => { + if let Ok(points) = value.parse::() { + unit.armor_locations.insert(loc, points); + continue; + } + } + } } assign(&mut unit, &key, key_raw, value); diff --git a/crates/helm-unitfile/tests/parse.rs b/crates/helm-unitfile/tests/parse.rs index 1daa001..7e362c6 100644 --- a/crates/helm-unitfile/tests/parse.rs +++ b/crates/helm-unitfile/tests/parse.rs @@ -246,3 +246,51 @@ fn rejects_a_file_with_no_unit_in_it() { assert!(parse("empty.mtf", b"# just a comment\n").is_err()); assert!(parse("notes.txt", b"chassis:Atlas\n").is_err()); } + +/// A patchwork design names an armour type per location, in front of the +/// points: `LT armor:Clan Standard(Clan):26`. +/// +/// Reading this wrongly is silent and total. Insisting on a bare integer drops +/// every location of every patchwork design, so the unit parses fine and comes +/// out with almost no armour - which was true of all five of them in MegaMek +/// 0.51.0 until this was fixed, and moved total armour from 28 to 247 on a +/// Zeus-X. +const PATCHWORK: &str = "\ +chassis:Zeus-X +model:ZEU-X +mass:80 +armor:Patchwork +LA armor:IS Reactive(Inner Sphere):26 +RA armor:IS Reactive(Inner Sphere):26 +CT armor:Clan Standard(Clan):38 +HD armor:9 +"; + +#[test] +fn patchwork_armor_keeps_both_its_points_and_its_type() { + let u = parse("x.mtf", PATCHWORK.as_bytes()).unwrap(); + assert_eq!(u.armor.as_deref(), Some("Patchwork")); + assert_eq!(u.armor_locations.get("LA"), Some(&26)); + assert_eq!(u.armor_locations.get("CT"), Some(&38)); + assert_eq!(u.total_armor(), 99); + + assert_eq!( + u.armor_types.get("LA").map(String::as_str), + Some("IS Reactive(Inner Sphere)") + ); + assert_eq!(u.armor_kind_at("LA"), helm_core::ArmorKind::Reactive); + assert_eq!(u.armor_kind_at("CT"), helm_core::ArmorKind::Standard); + + // A location that names no type falls back to the design's, which for a + // patchwork design is Patchwork itself rather than a real armour. + assert_eq!(u.armor_kind_at("HD"), helm_core::ArmorKind::Patchwork); + assert!(!u.armor_types.contains_key("HD")); +} + +/// The ordinary case must not have grown a type map. +#[test] +fn a_design_with_one_armour_type_records_no_per_location_types() { + let u = parse("x.mtf", ATLAS.as_bytes()).unwrap(); + assert!(u.armor_types.is_empty()); + assert_eq!(u.armor_kind_at("LA"), helm_core::ArmorKind::Standard); +}