diff --git a/crates/helm-cli/src/main.rs b/crates/helm-cli/src/main.rs index 64bcbd9..ff07a44 100644 --- a/crates/helm-cli/src/main.rs +++ b/crates/helm-cli/src/main.rs @@ -886,13 +886,11 @@ fn force(args: &[String]) -> Result<(), String> { let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?; let mul = helm_unitfile::parse_mul(&text).map_err(|e| format!("{path}: {e}"))?; - // Chassis and model, because that is the pair a `.mul` records and a - // display name is not a key - a Clan design folds a second name into it. - let by_name: std::collections::HashMap<(&str, &str), &helm_core::Unit> = library - .units - .iter() - .map(|u| ((u.chassis.as_str(), u.model.as_str()), u)) - .collect(); + // Looked up by asking each design whether it answers to the name, because + // two tools spell a Clan design two ways: MegaMek keeps the second name + // apart and MekBay folds it into the chassis. + let find = + |chassis: &str, model: &str| library.units.iter().find(|u| u.answers_to(chassis, model)); println!( "{:<34} {:>8} {:>7} {:>8}", @@ -901,7 +899,7 @@ fn force(args: &[String]) -> Result<(), String> { let mut total = 0i64; let mut missing = 0; for entity in mul.machines() { - let Some(unit) = by_name.get(&(entity.chassis.as_str(), entity.model.as_str())) else { + let Some(unit) = find(&entity.chassis, &entity.model) else { println!("{:<34} not in the library", entity.display_name()); missing += 1; continue; diff --git a/crates/helm-core/src/unit.rs b/crates/helm-core/src/unit.rs index 43ac921..7375ffe 100644 --- a/crates/helm-core/src/unit.rs +++ b/crates/helm-core/src/unit.rs @@ -108,6 +108,33 @@ impl Unit { /// 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, + } + } + pub fn display_name(&self) -> String { let chassis = self.chassis.trim(); let model = self.model.trim(); @@ -154,6 +181,37 @@ mod tests { } } + // 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!(