diff --git a/crates/helm-bridge/src/lib.rs b/crates/helm-bridge/src/lib.rs index f352776..356e270 100644 --- a/crates/helm-bridge/src/lib.rs +++ b/crates/helm-bridge/src/lib.rs @@ -25,6 +25,11 @@ use helm_core::{ use helm_force::{Blueprint, Grouping, Requirement, Rule}; use serde_json::Value; +/// Why a bridge dump could not be read. +/// +/// A dump is machine-written, so a failure here is a version skew or a +/// truncated file rather than a typo - and the line number is what says +/// which. #[derive(Debug)] pub enum Error { Io(std::io::Error), @@ -307,6 +312,9 @@ fn grouping(v: &Value) -> Option { }) } +/// A JSON array of strings under a key, with anything that is not one left +/// out. An absent key is an empty list rather than an error: a dump from an +/// older MegaMek is missing fields rather than malformed. fn strings(v: &Value, key: &str) -> Vec { v.get(key) .and_then(Value::as_array) @@ -487,27 +495,9 @@ fn equipment_record(v: &Value) -> Option { // Only an infantry armour kit has one, and MegaMek writes 1.0 on the // rest of MiscType rather than leaving it out. damage_divisor: f(v, "damageDivisor"), - flags: v - .get("_flags") - .and_then(Value::as_array) - .map(|a| { - a.iter() - .filter_map(Value::as_str) - .map(str::to_string) - .collect() - }) - .unwrap_or_default(), + flags: strings(v, "_flags"), explosive: v.get("explosive").and_then(Value::as_bool).unwrap_or(false), - classes: v - .get("_classes") - .and_then(Value::as_array) - .map(|a| { - a.iter() - .filter_map(Value::as_str) - .map(str::to_string) - .collect() - }) - .unwrap_or_default(), + classes: strings(v, "_classes"), sub_type: v.get("subType").and_then(Value::as_i64), }) } diff --git a/crates/helm-bv/src/clusters.rs b/crates/helm-bv/src/clusters.rs index 80f1e16..0b9ba9c 100644 --- a/crates/helm-bv/src/clusters.rs +++ b/crates/helm-bv/src/clusters.rs @@ -27,6 +27,7 @@ pub struct Cluster { } impl Cluster { + /// How many designs carry this label. pub fn total(&self) -> usize { self.matched + self.differed + self.unscored } diff --git a/crates/helm-bv/src/conformance.rs b/crates/helm-bv/src/conformance.rs index d8ac75f..5b14706 100644 --- a/crates/helm-bv/src/conformance.rs +++ b/crates/helm-bv/src/conformance.rs @@ -97,10 +97,12 @@ pub struct Tally { } impl Tally { + /// Designs where this figure agreed with MegaMek's. pub fn matched(&self) -> usize { self.matched } + /// Designs where it did not, with both answers. pub fn mismatches(&self) -> &[Mismatch] { &self.mismatches } @@ -160,6 +162,8 @@ pub struct Conformance { } impl Conformance { + /// An empty report, stamped with what produced it and what it was measured + /// against. pub fn new(rules_version: impl Into, megamek_version: impl Into) -> Self { Conformance { rules_version: rules_version.into(), @@ -247,14 +251,17 @@ impl Conformance { + self.battle_value.uncomputed } + /// How the totals compare - the figure anybody quotes. pub fn battle_value(&self) -> &Tally { &self.battle_value } + /// How the defensive halves compare, which says *where* a total is wrong. pub fn defensive(&self) -> &Tally { &self.defensive } + /// How the offensive halves compare, the other half of that question. pub fn offensive(&self) -> &Tally { &self.offensive } diff --git a/crates/helm-bv/src/loadout.rs b/crates/helm-bv/src/loadout.rs index 370b318..e0eba0b 100644 --- a/crates/helm-bv/src/loadout.rs +++ b/crates/helm-bv/src/loadout.rs @@ -292,7 +292,7 @@ impl<'a> Loadout<'a> { let held = helm_core::slot_parts(line).next().unwrap_or(line); catalogue .resolve(&clean(held).name) - .is_some_and(|held| held.is_ammo()) + .is_some_and(helm_core::EquipmentEntry::is_ammo) }) .map(|(what, _)| what.as_str()); if gone.contains(&index) { @@ -390,32 +390,11 @@ impl<'a> Loadout<'a> { let items = per_location .into_iter() - .filter_map( - |( - Placement { - name, - location, - rear, - armored, - turret, - empty, - }, - (slots, physical_slots), - )| { - Some(Item { - entry: catalogue.get(&name)?, - arm: is_arm(&location), - aes: aes_locations.contains(&location), - location, - slots, - physical_slots, - rear, - armored, - turret, - empty, - }) - }, - ) + .filter_map(|(placement, counted)| { + let arm = is_arm(&placement.location); + let aes = aes_locations.contains(&placement.location); + placement.into_item(catalogue, counted, arm, aes) + }) .collect(); // Read from the design rather than from `items`, which is what the // machine is carrying now: a bin that went off in a location the @@ -451,13 +430,7 @@ impl<'a> Loadout<'a> { lines_are_items: false, mechanical_jump_booster_mp: booster_mp, }; - loadout.mountings = loadout.total_mountings(); - loadout.present = loadout - .items - .iter() - .flat_map(|item| item.entry.flags.iter()) - .map(|flag| flag.rsplit('.').next().unwrap_or(flag.as_str())) - .collect(); + loadout.index(); loadout } @@ -529,34 +502,10 @@ impl<'a> Loadout<'a> { let items = per_location .into_iter() - .filter_map( - |( - Placement { - name, - location, - rear, - armored, - turret, - empty, - }, - (slots, physical_slots), - )| { - Some(Item { - entry: catalogue.get(&name)?, - // A vehicle has no arms, and nothing on one turns - // except a turret. - arm: false, - aes: false, - location, - slots, - physical_slots, - rear, - armored, - turret, - empty, - }) - }, - ) + // A vehicle has no arms, and nothing on one turns except a turret. + .filter_map(|(placement, counted)| { + placement.into_item(catalogue, counted, false, false) + }) .collect(); let mut loadout = Loadout { @@ -578,16 +527,28 @@ impl<'a> Loadout<'a> { lines_are_items: true, mechanical_jump_booster_mp: 0, }; - loadout.mountings = loadout.total_mountings(); - loadout.present = loadout + loadout.index(); + loadout + } + + /// Work out what the items add up to, once the items are known. + /// + /// Both readers finish here. The mountings are the items totalled per name + /// and facing, and `present` is every flag anything carries: the rules ask + /// "does this design have X" two dozen times per scoring, and answering it + /// by walking the flag lists meant thousands of string comparisons for a + /// question with eight distinct answers. + fn index(&mut self) { + self.mountings = self.total_mountings(); + self.present = self .items .iter() .flat_map(|item| item.entry.flags.iter()) .map(|flag| flag.rsplit('.').next().unwrap_or(flag.as_str())) .collect(); - loadout } + /// Every mounting on the design, one entry per item per location. pub fn items(&self) -> &[Item<'a>] { &self.items } @@ -889,8 +850,7 @@ impl<'a> Loadout<'a> { .entry .criticals .filter(|c| *c > 0) - .map(|c| helm_core::footprint_in(c, self.per_slot)) - .unwrap_or(1); + .map_or(1, |c| helm_core::footprint_in(c, self.per_slot)); *out.entry(item.location.as_str()).or_default() += (item.slots / per_item).max(1); } out @@ -1170,6 +1130,37 @@ struct Placement { empty: bool, } +impl Placement { + /// The item this placement holds, once its slots have been totalled. + /// + /// `counted` is how many of the item are here and how many *physical* + /// slots that takes - fewer on a superheavy, which packs two into one. + /// Whether it is in an arm and whether it aims better are the caller's to + /// say: a vehicle has neither, and a Mek has to have read its actuator + /// enhancement systems first. + fn into_item<'a>( + self, + catalogue: &'a Catalogue, + counted: (usize, usize), + arm: bool, + aes: bool, + ) -> Option> { + let (slots, physical_slots) = counted; + Some(Item { + entry: catalogue.get(&self.name)?, + location: self.location, + slots, + physical_slots, + rear: self.rear, + arm, + aes, + armored: self.armored, + turret: self.turret, + empty: self.empty, + }) + } +} + /// A slot line read apart: what is in it, and how it is mounted. struct Marked { name: String, diff --git a/crates/helm-bv/src/machine.rs b/crates/helm-bv/src/machine.rs index d0f7ffa..07545e9 100644 --- a/crates/helm-bv/src/machine.rs +++ b/crates/helm-bv/src/machine.rs @@ -239,13 +239,8 @@ impl<'a> Machine<'a> { // rather than one, so the extra point is added back per sink. let prototype_doubles = loadout.prototype_double_heat_sinks(); let declared = unit.heat_sink_spec(); - let per_declared = declared - .as_ref() - .map(|hs| hs.kind.dissipation()) - .unwrap_or(1); - let heat_capacity = declared - .map(|hs| hs.count.max(0) * hs.kind.dissipation()) - .unwrap_or(0) + let per_declared = declared.as_ref().map_or(1, |hs| hs.kind.dissipation()); + let heat_capacity = declared.map_or(0, |hs| hs.count.max(0) * hs.kind.dissipation()) + prototype_doubles * (2 - per_declared) + moves.wing_heat_bonus - loadout.lost_cooling(); @@ -274,7 +269,7 @@ impl<'a> Machine<'a> { jump_mp: moves.jump_mp, umu_mp: moves.umu_mp, heat_capacity, - heat_sink_count: unit.heat_sink_spec().map(|hs| hs.count.max(0)).unwrap_or(0), + heat_sink_count: unit.heat_sink_spec().map_or(0, |hs| hs.count.max(0)), // Charged on the jets alone: the distance the wing adds costs // nothing, which is most of the point of fitting one. move_heat: move_heat(unit, &engine.kind, moves.jets_mp, &loadout), diff --git a/crates/helm-bv/src/repair.rs b/crates/helm-bv/src/repair.rs index dcca60a..0f44eb8 100644 --- a/crates/helm-bv/src/repair.rs +++ b/crates/helm-bv/src/repair.rs @@ -86,13 +86,12 @@ pub fn repairs(design: &Unit, condition: &Condition) -> Vec { "RT" => Some("RTR"), _ => None, }; - let rear = rear_code.map(built).unwrap_or(0); + let rear = rear_code.map_or(0, built); let built_frame = frame .as_ref() .and_then(|f| f.iter().find(|(l, _)| l == code)) - .map(|(_, points)| *points) - .unwrap_or(0); + .map_or(0, |(_, points)| *points); let frame_left = condition .structure .get(*code) @@ -148,6 +147,7 @@ pub struct Total { } impl Total { + /// Add up a repair list, for the line a post-match screen leads with. pub fn of(repairs: &[Repair]) -> Total { Total { armor: repairs.iter().map(|r| r.armor + r.rear_armor).sum(), @@ -158,6 +158,7 @@ impl Total { } } + /// Whether the machine came out of it needing nothing at all. pub fn is_nothing(&self) -> bool { *self == Total::default() } diff --git a/crates/helm-bv/src/wear.rs b/crates/helm-bv/src/wear.rs index bf31423..8c1e392 100644 --- a/crates/helm-bv/src/wear.rs +++ b/crates/helm-bv/src/wear.rs @@ -110,7 +110,7 @@ pub fn wear_to(unit: &Unit, catalogue: &Catalogue, target: i64) -> Result target { low = mid + 1; @@ -142,7 +142,7 @@ pub fn wear_to(unit: &Unit, catalogue: &Catalogue, target: i64) -> Result target { low = mid + 1; diff --git a/crates/helm-bv/tests/battlearmor.rs b/crates/helm-bv/tests/battlearmor.rs index 179f5ae..fe2272e 100644 --- a/crates/helm-bv/tests/battlearmor.rs +++ b/crates/helm-bv/tests/battlearmor.rs @@ -10,10 +10,13 @@ fn battle_armour_agrees_with_megamek() { panic!("set HELM_MEGAMEK and HELM_BRIDGE"); }; let path = PathBuf::from(&bridge).join("bv-ba.jsonl"); - if !path.is_file() { - println!("no bv-ba.jsonl in the bridge dump; run DumpBv BattleArmor --report"); - return; - } + // Naming a dump is claiming to have one. `bridge/dump.sh` writes every + // oracle file, so one missing means the dump is older than the test, and + // passing quietly would report a rule as checked when nothing checked it. + assert!( + path.is_file(), + "no bv-ba.jsonl in the bridge dump; re-run bridge/dump.sh" + ); let catalogue = helm_bridge::read_catalogue(&PathBuf::from(&bridge).join("equipment.jsonl")) .expect("equipment.jsonl"); let library = helm_unitfile::read_install(Path::new(&mm)).expect("library"); diff --git a/crates/helm-bv/tests/conformance.rs b/crates/helm-bv/tests/conformance.rs index ac955cc..9b7385c 100644 --- a/crates/helm-bv/tests/conformance.rs +++ b/crates/helm-bv/tests/conformance.rs @@ -466,10 +466,10 @@ fn condition_from(row: &helm_bridge::Damaged) -> helm_bv::Condition { /// The rows the bridge dumped, or nothing when there is no dump to read. fn damaged_rows() -> Option> { let path = PathBuf::from(std::env::var("HELM_BRIDGE").ok()?).join("damaged.jsonl"); - if !path.is_file() { - println!("no damaged.jsonl in the bridge dump; run bridge/dump.sh to make one"); - return None; - } + assert!( + path.is_file(), + "no damaged.jsonl in the bridge dump; re-run bridge/dump.sh" + ); Some(helm_bridge::read_damaged(&path).expect("damaged.jsonl")) } @@ -996,8 +996,7 @@ fn a_machine_out_of_a_battle_can_still_take_the_field() { .criticals .get(location) .and_then(|s| s.get(*at)) - .map(String::as_str) - .unwrap_or(""); + .map_or("", String::as_str); if what.contains("Engine") { engines += 1; } @@ -1163,8 +1162,7 @@ fn nothing_is_damaged_behind_armour_that_held() { let name = helm_unitfile::locations_for_config(unit.config.as_deref()) .iter() .find(|(c, _)| c == code) - .map(|(_, name)| *name) - .unwrap_or(""); + .map_or("", |(_, name)| *name); let wrecked = worn .condition .destroyed @@ -1579,10 +1577,10 @@ fn a_varied_unit_is_worth_what_megamek_says_it_is() { panic!("set HELM_MEGAMEK to a MegaMek install and HELM_BRIDGE to a bridge dump"); }; let path = PathBuf::from(std::env::var("HELM_BRIDGE").unwrap()).join("mul.jsonl"); - if !path.is_file() { - println!("no mul.jsonl in the bridge dump; run bridge/dump.sh to make one"); - return; - } + assert!( + path.is_file(), + "no mul.jsonl in the bridge dump; re-run bridge/dump.sh" + ); let oracle = std::fs::read_to_string(&path).expect("mul.jsonl"); // The rows come back in the order the entities appear in each file, which diff --git a/crates/helm-bv/tests/infantry.rs b/crates/helm-bv/tests/infantry.rs index 85b226c..63e1770 100644 --- a/crates/helm-bv/tests/infantry.rs +++ b/crates/helm-bv/tests/infantry.rs @@ -15,10 +15,13 @@ fn infantry_agrees_with_megamek() { panic!("set HELM_MEGAMEK and HELM_BRIDGE"); }; let path = PathBuf::from(&bridge).join("bv-infantry.jsonl"); - if !path.is_file() { - println!("no bv-infantry.jsonl in the bridge dump; run DumpBv Infantry --report"); - return; - } + // Naming a dump is claiming to have one. `bridge/dump.sh` writes every + // oracle file, so one missing means the dump is older than the test, and + // passing quietly would report a rule as checked when nothing checked it. + assert!( + path.is_file(), + "no bv-infantry.jsonl in the bridge dump; re-run bridge/dump.sh" + ); let catalogue = helm_bridge::read_catalogue(&PathBuf::from(&bridge).join("equipment.jsonl")) .expect("equipment.jsonl"); let library = helm_unitfile::read_install(Path::new(&mm)).expect("library"); diff --git a/crates/helm-cli/src/main.rs b/crates/helm-cli/src/main.rs index cadbbb8..b132c85 100644 --- a/crates/helm-cli/src/main.rs +++ b/crates/helm-cli/src/main.rs @@ -252,6 +252,24 @@ fn parse_opts(args: &[String]) -> Result { /// /// Prefers the zip MegaMek ships and falls back to loose files, which is what /// an extracted or partially unpacked install looks like. +/// What one location needs putting right, as a phrase. +/// +/// Only what is actually wanted: a location with its plate intact and its +/// frame bent reads "3 structure" rather than "0 armour, 3 structure". +fn plate_and_frame(repair: &helm_bv::Repair) -> String { + let mut parts = Vec::new(); + for (points, what) in [ + (repair.armor, "armour"), + (repair.rear_armor, "rear armour"), + (repair.structure, "structure"), + ] { + if points > 0 { + parts.push(format!("{points} {what}")); + } + } + parts.join(", ") +} + /// The two things every command that scores anything needs. /// /// Four subcommands were opening the library and the catalogue with the same @@ -575,8 +593,7 @@ fn one_unit( unit.fields .iter() .find(|(k, _)| k.eq_ignore_ascii_case(key)) - .map(|(_, v)| v.trim()) - .unwrap_or("?") + .map_or("?", |(_, v)| v.trim()) }; println!( " {} squads of {}, {}", @@ -822,17 +839,7 @@ fn wear(args: &[String]) -> Result<(), String> { ); let repairs = helm_bv::repairs(unit, &worn.condition); for repair in &repairs { - let mut parts = Vec::new(); - if repair.armor > 0 { - parts.push(format!("{} armour", repair.armor)); - } - if repair.rear_armor > 0 { - parts.push(format!("{} rear armour", repair.rear_armor)); - } - if repair.structure > 0 { - parts.push(format!("{} structure", repair.structure)); - } - println!(" {:<16} {}", repair.location, parts.join(", ")); + println!(" {:<16} {}", repair.location, plate_and_frame(repair)); // What a penetrating hit took with it, which is the part of a worn // machine that makes it that machine rather than a generic one. for slot in &repair.destroyed { @@ -1108,8 +1115,7 @@ fn print_clusters( .and_then(|b| b.battle_value) .unwrap_or_default(); let ours = helm_bv::battle_value(unit, catalogue) - .map(|v| v.to_string()) - .unwrap_or_else(|e| e.to_string()); + .map_or_else(|e| e.to_string(), |v| v.to_string()); let notable: Vec = helm_bv::labels(unit, catalogue) .into_iter() .filter(|l| !l.starts_with("config:") && !l.starts_with("tech:")) @@ -1297,9 +1303,7 @@ fn print_bench(library: &helm_unitfile::Library, catalogue: &Catalogue) -> Resul let start = Instant::now(); for _ in 0..rounds { for unit in &meks { - sink += helm_bv::Machine::read(unit, catalogue) - .map(|m| m.tons as i64) - .unwrap_or(0); + sink += helm_bv::Machine::read(unit, catalogue).map_or(0, |m| m.tons as i64); } } let reading = per(start.elapsed()); @@ -1312,12 +1316,8 @@ fn print_bench(library: &helm_unitfile::Library, catalogue: &Catalogue) -> Resul let start = Instant::now(); for _ in 0..rounds { for machine in &prepared { - sink += helm_bv::defensive(machine) - .map(|d| d.total as i64) - .unwrap_or(0) - + helm_bv::offensive(machine) - .map(|o| o.total as i64) - .unwrap_or(0); + sink += helm_bv::defensive(machine).map_or(0, |d| d.total as i64) + + helm_bv::offensive(machine).map_or(0, |o| o.total as i64); } } let scoring = per(start.elapsed()); diff --git a/crates/helm-core/src/bv.rs b/crates/helm-core/src/bv.rs index 9598b91..50eeeb0 100644 --- a/crates/helm-core/src/bv.rs +++ b/crates/helm-core/src/bv.rs @@ -40,6 +40,7 @@ pub struct BvBreakdown { pub const RATING_TOLERANCE: f64 = 0.05; impl BvBreakdown { + /// An empty breakdown for a design, to be filled in as figures arrive. pub fn new(name: impl Into) -> Self { BvBreakdown { name: name.into(), diff --git a/crates/helm-core/src/catalogue.rs b/crates/helm-core/src/catalogue.rs index 031ba85..1c45c25 100644 --- a/crates/helm-core/src/catalogue.rs +++ b/crates/helm-core/src/catalogue.rs @@ -310,6 +310,7 @@ pub struct Catalogue { } impl Catalogue { + /// Build a catalogue from the entries a bridge dump carried. pub fn new(entries: Vec) -> Self { Self::with_infantry(entries, Vec::new(), Vec::new()) } @@ -366,14 +367,17 @@ impl Catalogue { .find(|p| normalize(&p.key) == key || normalize(&p.name) == key) } + /// Whether nothing was loaded, which means every lookup will miss. pub fn is_empty(&self) -> bool { self.entries.is_empty() } + /// How many entries it holds. pub fn len(&self) -> usize { self.entries.len() } + /// Every entry, for a caller that has to sweep rather than look up. pub fn entries(&self) -> &[EquipmentEntry] { &self.entries } @@ -451,8 +455,7 @@ impl Catalogue { /// for so a caller never has to handle a missing name. pub fn display_name(&self, name: &str) -> String { self.resolve(name) - .map(|e| e.name.clone()) - .unwrap_or_else(|| name.to_string()) + .map_or_else(|| name.to_string(), |e| e.name.clone()) } /// The name MegaMek looks this equipment up by, whatever it was called @@ -469,8 +472,7 @@ impl Catalogue { /// keeps whatever the file called it rather than vanishing. pub fn canonical_name(&self, name: &str) -> String { self.resolve(name) - .map(|e| e.internal_name.clone()) - .unwrap_or_else(|| name.to_string()) + .map_or_else(|| name.to_string(), |e| e.internal_name.clone()) } } diff --git a/crates/helm-core/src/computed.rs b/crates/helm-core/src/computed.rs index a6fd4a7..e1038d7 100644 --- a/crates/helm-core/src/computed.rs +++ b/crates/helm-core/src/computed.rs @@ -34,6 +34,8 @@ pub struct AlphaStrike { } impl AlphaStrike { + /// Whether nothing was computed for it, which a build with no bridge dump + /// leaves behind. pub fn is_empty(&self) -> bool { self == &AlphaStrike::default() } @@ -69,6 +71,7 @@ pub struct ComputedStats { } impl ComputedStats { + /// An empty record for a design, to be filled in as figures arrive. pub fn new(name: impl Into) -> Self { ComputedStats { name: name.into(), diff --git a/crates/helm-core/src/design.rs b/crates/helm-core/src/design.rs index 1952fe8..77fabf7 100644 --- a/crates/helm-core/src/design.rs +++ b/crates/helm-core/src/design.rs @@ -28,6 +28,12 @@ pub struct Engine { pub clan: bool, } +/// The engine a design declares, by the family it belongs to. +/// +/// The kind decides three things at once: what the engine weighs, how many +/// heat sinks it carries for nothing, and how many of a torso's slots it +/// fills - which is why a design's engine is read before anything else. +/// `Other` keeps a spelling this does not know rather than guessing at it. #[derive(Debug, Clone, PartialEq, Eq)] pub enum EngineKind { Fusion, @@ -53,6 +59,11 @@ pub struct HeatSinks { pub clan: bool, } +/// What one heat sink sheds, by the family it belongs to. +/// +/// A double sheds two and a single one, and a compact sheds like a double +/// while filling less of the design - so the kind is not a label, it is the +/// dissipation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HeatSinkKind { Single, @@ -86,6 +97,11 @@ impl HeatSinkKind { } } +/// The plate a design wears, by the family it belongs to. +/// +/// What it buys to the ton and what it costs both follow from this, and so +/// does whether battle value scales it: reflective and reactive plate are +/// worth more against the damage they are made for. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ArmorKind { Standard, @@ -116,6 +132,11 @@ pub enum ArmorKind { Other(String), } +/// The frame under the plate, by the family it belongs to. +/// +/// Endo steel buys the same points for half the weight and takes slots for +/// it; composite is the other bargain, lighter and weaker. Battle value +/// counts the points and construction counts the slots. #[derive(Debug, Clone, PartialEq, Eq)] pub enum StructureKind { Standard, @@ -127,6 +148,12 @@ pub enum StructureKind { Other(String), } +/// The gyro, which decides how many slots it fills and what it is worth. +/// +/// A compact fills two of the centre torso's slots and an extra-light six, +/// and the defensive rating pays for the gyro by the ton of design - so a +/// design that armours its gyro is paying for a different share of it +/// depending on which one it has. #[derive(Debug, Clone, PartialEq, Eq)] pub enum GyroKind { Standard, @@ -140,6 +167,11 @@ pub enum GyroKind { Other(String), } +/// The cockpit, which decides what the pilot can do and what it costs. +/// +/// A small cockpit saves weight and costs the pilot a point of gunnery; an +/// interface cockpit stands in for the gyro, which is why a Ryoken III +/// (Skinwalker) declares none. #[derive(Debug, Clone, PartialEq, Eq)] pub enum CockpitKind { Standard, @@ -255,8 +287,7 @@ impl Unit { pub fn armor_kind(&self) -> ArmorKind { self.armor .as_deref() - .map(armor_kind_from) - .unwrap_or(ArmorKind::Standard) + .map_or(ArmorKind::Standard, armor_kind_from) } /// The armour type in one location, which differs from the design's only diff --git a/crates/helm-core/src/lib.rs b/crates/helm-core/src/lib.rs index d247c48..92bb908 100644 --- a/crates/helm-core/src/lib.rs +++ b/crates/helm-core/src/lib.rs @@ -109,14 +109,16 @@ pub fn slot_has_marker(slot: &str, marker: &str) -> bool { } /// Where a marker starts in a slot, ignoring case. +/// +/// Compared in place rather than by uppercasing either side: this runs for +/// six markers on every slot of every design, and an allocation apiece is the +/// difference between reading the library in a second and in two. fn find_marker(slot: &str, marker: &str) -> Option { - if slot.is_ascii() && marker.is_ascii() { - return slot - .to_ascii_uppercase() - .find(&marker.to_ascii_uppercase()) - .filter(|at| slot.is_char_boundary(*at)); - } - slot.find(marker) + let len = marker.len(); + slot.char_indices().map(|(at, _)| at).find(|&at| { + slot.get(at..at + len) + .is_some_and(|found| found.eq_ignore_ascii_case(marker)) + }) } /// The parts of one critical-slot line. @@ -236,7 +238,7 @@ pub const HISTORY_IN_INSTALL: &str = "docs/history.txt"; /// "gauss rifle" return all of them. pub fn normalize(name: &str) -> String { name.chars() - .filter(|c| c.is_ascii_alphanumeric()) + .filter(char::is_ascii_alphanumeric) .map(|c| c.to_ascii_lowercase()) .collect() } @@ -381,3 +383,48 @@ mod tests { assert_eq!(weight_class_name(99), None); } } + +#[cfg(test)] +mod marker_tests { + use super::*; + + /// A slot's markers decide how it is mounted, and the two questions asked + /// about them - strip it, or ask whether it is there - have to agree. + #[test] + fn stripping_a_marker_and_asking_about_one_are_the_same_test() { + for slot in [ + "Medium Laser (R)", + "Medium Laser (r)", + "CLTargeting Computer (OMNIPOD)", + "Fusion Engine (Armored)", + "Light Machine Gun(ST)", + ] { + let stripped = strip_slot_markers(slot); + for marker in SLOT_MARKERS { + assert!( + !slot_has_marker(&stripped, marker), + "{marker} survived stripping {slot}" + ); + } + } + } + + /// Case is not the rule: MegaMek writes them in capitals, and a file that + /// does not is a file to read rather than to refuse. + #[test] + fn a_marker_is_found_whatever_case_it_is_written_in() { + assert!(slot_has_marker("Fusion Engine (ARMORED)", "(ARMORED)")); + assert!(slot_has_marker("Fusion Engine (Armored)", "(ARMORED)")); + assert!(slot_has_marker("Fusion Engine (armored)", "(ARMORED)")); + assert!(!slot_has_marker("Fusion Engine", "(ARMORED)")); + assert_eq!(strip_slot_markers("Medium Laser (r)"), "Medium Laser"); + } + + /// A name that is not ASCII must not panic on a byte that is not a + /// character boundary. + #[test] + fn a_name_outside_ascii_is_read_rather_than_split_through() { + assert_eq!(strip_slot_markers("Zeus Ärger (R)"), "Zeus Ärger"); + assert!(!slot_has_marker("Ärger", "(R)")); + } +} diff --git a/crates/helm-core/src/metrics.rs b/crates/helm-core/src/metrics.rs index c403b71..7750cfc 100644 --- a/crates/helm-core/src/metrics.rs +++ b/crates/helm-core/src/metrics.rs @@ -200,8 +200,9 @@ fn per_weapon_damage_at_range(unit: &Unit, catalogue: &Catalogue, range: i64) -> let aboard = ammo_aboard.iter().copied().find(|e| matches(e)); aboard .or_else(|| one_shot_ammo(weapon, catalogue)) - .map(|a| a.damage_per_shot.unwrap_or(0) * a.rack_size.unwrap_or(1).max(1)) - .unwrap_or(0) + .map_or(0, |a| { + a.damage_per_shot.unwrap_or(0) * a.rack_size.unwrap_or(1).max(1) + }) } None => weapon.damage_at(range), }; diff --git a/crates/helm-core/src/rng.rs b/crates/helm-core/src/rng.rs index d306c33..a4ab01b 100644 --- a/crates/helm-core/src/rng.rs +++ b/crates/helm-core/src/rng.rs @@ -20,6 +20,7 @@ pub struct Rng { } impl Rng { + /// A generator that repeats for the same seed, on any machine. pub fn new(seed: u64) -> Self { Rng { state: seed } } diff --git a/crates/helm-core/src/unit.rs b/crates/helm-core/src/unit.rs index 08a9ae8..dbd10e8 100644 --- a/crates/helm-core/src/unit.rs +++ b/crates/helm-core/src/unit.rs @@ -12,6 +12,7 @@ pub enum Format { } impl Format { + /// The name the format goes by in a file path and in a report. pub fn as_str(self) -> &'static str { match self { Format::Mtf => "mtf", @@ -268,6 +269,11 @@ impl Unit { } } + /// What MegaMek calls this design, which is the key that joins a parsed + /// unit to a computed record. + /// + /// A Clan design carries the name the Inner Sphere gave it as well, in + /// brackets between the chassis and the model: `Black Hawk (Nova) R`. pub fn display_name(&self) -> String { let chassis = self.chassis.trim(); let head = match self.clan_name.as_deref().map(str::trim) { diff --git a/crates/helm-cost/src/items.rs b/crates/helm-cost/src/items.rs index 44cf1cd..dcda514 100644 --- a/crates/helm-cost/src/items.rs +++ b/crates/helm-cost/src/items.rs @@ -99,7 +99,7 @@ pub(crate) fn clan_case(unit: &Unit, catalogue: &Catalogue) -> f64 { /// priced from the design that answer is in no catalogue to look up. pub fn item_prices(unit: &Unit, catalogue: &Catalogue) -> Vec<(String, i64, f64)> { let tons = unit.mass.unwrap_or(0.0); - let rating = unit.engine_spec().map(|e| e.rating).unwrap_or(0); + let rating = unit.engine_spec().map_or(0, |e| e.rating); let mut out: Vec<(String, i64, f64)> = helm_core::mounted_items(unit, catalogue) .iter() // The plate has its own term and is not equipment here. diff --git a/crates/helm-cost/src/mek.rs b/crates/helm-cost/src/mek.rs index 8e590d1..5560fea 100644 --- a/crates/helm-cost/src/mek.rs +++ b/crates/helm-cost/src/mek.rs @@ -48,6 +48,10 @@ pub struct Breakdown { pub total: f64, } +/// What a BattleMek costs, term by term. +/// +/// The terms are the ones MegaMek's own report names, in the order it adds +/// them, so a total that is forty thousand C-bills light says which line. pub fn breakdown(unit: &Unit, catalogue: &Catalogue) -> Result { // A machine that turns into something else carries the gear to do it, and // that gear is priced by what it converts into rather than by the Mek. A @@ -378,11 +382,10 @@ fn jump_jets(unit: &Unit, catalogue: &Catalogue, tons: f64) -> f64 { let booster = items .iter() .find(|(e, _)| e.has_flag("F_JUMP_BOOSTER")) - .map(|(entry, _)| { + .map_or(0, |(entry, _)| { let size = declared_size(unit, entry) as i64; if size > 0 { size } else { declared } - }) - .unwrap_or(0); + }); // Modular armour costs a point of jump before anything is priced, and // MegaMek charges for what is left rather than for what is bolted on. let mut jets = if booster > 0 { 0 } else { declared }; @@ -562,7 +565,9 @@ fn armored_components(unit: &Unit, catalogue: &Catalogue) -> f64 { let name = helm_core::strip_slot_markers(part); // A system - an actuator, a shoulder - is in no catalogue and // fills one slot. - let footprint = catalogue.resolve(&name).map_or(1, |e| e.footprint()); + let footprint = catalogue + .resolve(&name) + .map_or(1, helm_core::EquipmentEntry::footprint); let entry = marked.entry(name).or_insert((0, footprint)); entry.0 += 1; } diff --git a/crates/helm-cost/src/vehicle.rs b/crates/helm-cost/src/vehicle.rs index c62cba2..5506fab 100644 --- a/crates/helm-cost/src/vehicle.rs +++ b/crates/helm-cost/src/vehicle.rs @@ -28,6 +28,10 @@ pub struct Breakdown { pub total: f64, } +/// What a combat vehicle costs, term by term. +/// +/// A shorter list than a Mek's: no cockpit, no gyro, no actuators, and a +/// turret ring instead - and a longer set of multipliers after it. pub fn breakdown(unit: &Unit, catalogue: &Catalogue) -> Result { // A support vehicle is `SupportVehicleCostCalculator` upstream and is a // different calculation rather than a variation on this one: its frame is diff --git a/crates/helm-cost/tests/conformance.rs b/crates/helm-cost/tests/conformance.rs index 092bbd8..c81e23c 100644 --- a/crates/helm-cost/tests/conformance.rs +++ b/crates/helm-cost/tests/conformance.rs @@ -23,10 +23,13 @@ fn cost_agrees_with_megamek() { panic!("set HELM_MEGAMEK and HELM_BRIDGE"); }; let path = PathBuf::from(&bridge).join("cost.jsonl"); - if !path.is_file() { - println!("no cost.jsonl in the bridge dump; run bridge/dump.sh to make one"); - return; - } + // Naming a dump is claiming to have one, and `bridge/dump.sh` writes them + // all: one missing means a dump older than the test, and passing quietly + // would report a rule as checked when nothing checked it. + assert!( + path.is_file(), + "no cost.jsonl in the bridge dump; re-run bridge/dump.sh" + ); let catalogue = helm_bridge::read_catalogue(&PathBuf::from(&bridge).join("equipment.jsonl")) .expect("equipment.jsonl"); let library = helm_unitfile::read_install(Path::new(&mm)).expect("library"); @@ -259,8 +262,7 @@ fn cost_agrees_with_megamek() { entry.internal_name, entry .cost - .map(|c| format!("{c:.0}")) - .unwrap_or_else(|| "variable".into()) + .map_or_else(|| "variable".into(), |c| format!("{c:.0}")) ); } } diff --git a/crates/helm-db/src/lib.rs b/crates/helm-db/src/lib.rs index bbece47..3380853 100644 --- a/crates/helm-db/src/lib.rs +++ b/crates/helm-db/src/lib.rs @@ -18,6 +18,7 @@ use helm_core::{ use helm_unitfile::Library; use rusqlite::{Connection, params}; +/// What a build put in the database, for the caller to report. pub struct BuildStats { pub units: usize, pub equipment_rows: usize, @@ -200,7 +201,9 @@ pub fn build( metrics.as_ref().map(|m| m.max_range), metrics.as_ref().map(|m| m.heat), metrics.as_ref().map(|m| m.dissipation), - metrics.as_ref().map(|m| m.heat_efficiency()), + metrics + .as_ref() + .map(helm_core::CombatMetrics::heat_efficiency), metrics.as_ref().map(|m| m.weapon_count as i64), metrics .as_ref() diff --git a/crates/helm-db/tests/differential.rs b/crates/helm-db/tests/differential.rs index d79bcf9..f853727 100644 --- a/crates/helm-db/tests/differential.rs +++ b/crates/helm-db/tests/differential.rs @@ -118,12 +118,12 @@ fn helm_bridge_read_units(path: &std::path::Path) -> Vec { .filter_map(|l| { let v: serde_json::Value = serde_json::from_str(l).ok()?; let mut s = ComputedStats::new(v.get("name")?.as_str()?); - s.battle_value = v.get("bv").and_then(|x| x.as_i64()); - s.weight_class = v.get("weightClass").and_then(|x| x.as_i64()); - s.canon = v.get("canon").and_then(|x| x.as_bool()); - s.invalid = v.get("invalid").and_then(|x| x.as_bool()); - s.omni = v.get("omni").and_then(|x| x.as_bool()); - s.clan = v.get("clan").and_then(|x| x.as_bool()); + s.battle_value = v.get("bv").and_then(serde_json::Value::as_i64); + s.weight_class = v.get("weightClass").and_then(serde_json::Value::as_i64); + s.canon = v.get("canon").and_then(serde_json::Value::as_bool); + s.invalid = v.get("invalid").and_then(serde_json::Value::as_bool); + s.omni = v.get("omni").and_then(serde_json::Value::as_bool); + s.clan = v.get("clan").and_then(serde_json::Value::as_bool); Some(s) }) .collect() @@ -146,7 +146,7 @@ fn helm_bridge_read_catalogue(path: &std::path::Path) -> helm_core::Catalogue { // ammunition fields, so a catalogue carrying only names makes // every weapon invisible and every damage band read zero. That is // a fixture that quietly tests nothing, not a lighter one. - let i = |k: &str| v.get(k).and_then(|x| x.as_i64()); + let i = |k: &str| v.get(k).and_then(serde_json::Value::as_i64); Some(helm_core::EquipmentEntry { internal_name: internal, name, @@ -159,7 +159,7 @@ fn helm_bridge_read_catalogue(path: &std::path::Path) -> helm_core::Catalogue { long_range: i("longRange"), average_damage: v .get("shortAV") - .and_then(|x| x.as_f64()) + .and_then(serde_json::Value::as_f64) .filter(|d| *d > 0.0), ammo_type: v .get("ammoType") diff --git a/crates/helm-facet/src/lib.rs b/crates/helm-facet/src/lib.rs index 9101f65..89cd164 100644 --- a/crates/helm-facet/src/lib.rs +++ b/crates/helm-facet/src/lib.rs @@ -52,18 +52,22 @@ impl Range { Range::default() } + /// A range with either end left open. pub fn new(start: Option, end: Option) -> Self { Range { start, end } } + /// Everything from here up. pub fn at_least(start: i64) -> Self { Range::new(Some(start), None) } + /// Everything up to here. pub fn at_most(end: i64) -> Self { Range::new(None, Some(end)) } + /// Everything between the two, both ends included. pub fn between(start: i64, end: i64) -> Self { Range::new(Some(start), Some(end)) } @@ -139,6 +143,10 @@ impl Tri { } } + /// Whether a design's answer satisfies this filter. + /// + /// A design the question does not apply to answers `None`, and only a + /// filter that asked for nothing accepts it. pub fn matches(self, value: Option) -> bool { match self { Tri::Any => true, diff --git a/crates/helm-facet/src/query.rs b/crates/helm-facet/src/query.rs index de262c5..ef645fc 100644 --- a/crates/helm-facet/src/query.rs +++ b/crates/helm-facet/src/query.rs @@ -183,7 +183,9 @@ impl UnitFacets { max_range: metrics.as_ref().map(|m| m.max_range), heat: metrics.as_ref().map(|m| m.heat), dissipation: metrics.as_ref().map(|m| m.dissipation), - heat_efficiency: metrics.as_ref().map(|m| m.heat_efficiency()), + heat_efficiency: metrics + .as_ref() + .map(helm_core::CombatMetrics::heat_efficiency), armor_pct: unit.armor_percent(), rules_level: stats.and_then(|s| s.tech_level.clone()), cost: stats.and_then(|s| s.cost), @@ -249,6 +251,7 @@ pub struct FacetQuery { } impl FacetQuery { + /// A query that selects everything, to be narrowed a filter at a time. pub fn new() -> Self { FacetQuery::default() } diff --git a/crates/helm-facet/tests/loadout.rs b/crates/helm-facet/tests/loadout.rs index 5d4f353..39e93a0 100644 --- a/crates/helm-facet/tests/loadout.rs +++ b/crates/helm-facet/tests/loadout.rs @@ -115,8 +115,8 @@ fn the_loadout_is_the_one_megamek_filters_on() { let mark = if a == b { " " } else { "!" }; println!( " {mark} {name:<44} megamek {:>4} helm {:>4}", - a.map(i64::to_string).unwrap_or_else(|| "-".into()), - b.map(i64::to_string).unwrap_or_else(|| "-".into()), + a.map_or_else(|| "-".into(), i64::to_string), + b.map_or_else(|| "-".into(), i64::to_string), ); } } diff --git a/crates/helm-force/src/c3.rs b/crates/helm-force/src/c3.rs index 2e25865..ac4f686 100644 --- a/crates/helm-force/src/c3.rs +++ b/crates/helm-force/src/c3.rs @@ -200,7 +200,7 @@ mod tests { base, id: Some(id.to_string()), master: master.map(str::to_string), - peers: peers.iter().map(|p| p.to_string()).collect(), + peers: peers.iter().map(|s| s.to_string()).collect(), } } diff --git a/crates/helm-force/src/formation.rs b/crates/helm-force/src/formation.rs index f2b67e4..f1c85c5 100644 --- a/crates/helm-force/src/formation.rs +++ b/crates/helm-force/src/formation.rs @@ -392,8 +392,10 @@ fn damage(lower: &str) -> Option { let amount = head .strip_prefix("weapon with damage") .or_else(|| head.strip_prefix("damage")) - .map(str::to_string) - .unwrap_or_else(|| head.trim_end_matches(" damage").to_string()); + .map_or_else( + || head.trim_end_matches(" damage").to_string(), + str::to_string, + ); let (min, max) = bound(&amount)?; Some(if single { Rule::SingleDamage { range, min: min? } @@ -526,7 +528,7 @@ impl Requirement { if low >= high { return None; } - let pct = (low + high) / 2.0; + let pct = f64::midpoint(low, high); let reproduces = self .minimums .iter() @@ -603,6 +605,7 @@ pub enum Verdict { } impl Verdict { + /// Whether the force meets every requirement the blueprint sets. pub fn qualifies(&self) -> bool { matches!(self, Verdict::Qualifies) } diff --git a/crates/helm-force/src/lib.rs b/crates/helm-force/src/lib.rs index 0f47aed..4fdcb8a 100644 --- a/crates/helm-force/src/lib.rs +++ b/crates/helm-force/src/lib.rs @@ -37,6 +37,7 @@ pub struct ForceUnit { } impl ForceUnit { + /// One unit in a force, at what it is worth before a crew is named. pub fn new(chassis: impl Into, model: impl Into, base: i64) -> Self { ForceUnit { chassis: chassis.into(), @@ -46,6 +47,7 @@ impl ForceUnit { } } + /// The same unit, flown by this crew. pub fn with_pilot(mut self, pilot: Pilot) -> Self { self.pilot = pilot; self @@ -69,14 +71,17 @@ pub struct Force { } impl Force { + /// A roster of units, in the order they were listed. pub fn new(units: Vec) -> Self { Force { units } } + /// How many units are in it. pub fn len(&self) -> usize { self.units.len() } + /// Whether it holds no units at all, which is not a force worth totalling. pub fn is_empty(&self) -> bool { self.units.is_empty() } diff --git a/crates/helm-force/src/pilot.rs b/crates/helm-force/src/pilot.rs index 1cab03e..b2aa7e9 100644 --- a/crates/helm-force/src/pilot.rs +++ b/crates/helm-force/src/pilot.rs @@ -32,6 +32,7 @@ impl Default for Pilot { } impl Pilot { + /// A crew at the two skills that decide what a unit is worth in its hands. pub fn new(gunnery: u8, piloting: u8) -> Self { Pilot { gunnery, piloting } } diff --git a/crates/helm-force/tests/formations.rs b/crates/helm-force/tests/formations.rs index 21a08b4..f4659c2 100644 --- a/crates/helm-force/tests/formations.rs +++ b/crates/helm-force/tests/formations.rs @@ -305,10 +305,10 @@ fn a_force_is_what_megamek_says_it_is() { }; let bridge = PathBuf::from(std::env::var("HELM_BRIDGE").unwrap()); let path = bridge.join("formation_forces.jsonl"); - if !path.is_file() { - println!("no formation_forces.jsonl in the bridge dump; run bridge/dump.sh to make one"); - return; - } + assert!( + path.is_file(), + "no formation_forces.jsonl in the bridge dump; re-run bridge/dump.sh" + ); let oracle = std::fs::read_to_string(&path).expect("formation_forces.jsonl"); let by_name: BTreeMap<&str, &Blueprint> = inputs .blueprints diff --git a/crates/helm-unitfile/src/blk.rs b/crates/helm-unitfile/src/blk.rs index bec09eb..62822c6 100644 --- a/crates/helm-unitfile/src/blk.rs +++ b/crates/helm-unitfile/src/blk.rs @@ -16,6 +16,11 @@ use helm_core::{Format, Mount, SuitMount, Unit}; use crate::Error; +/// Read a `.blk`, which is how MegaMek writes everything that is not a Mek. +/// +/// A block-structured file rather than a Mek's line-structured one, and one +/// whose blocks differ by unit type - so what is read is what is there, +/// and a tag this does not know is kept rather than refused. pub fn parse_blk(path: &str, text: &str) -> Result { let mut unit = Unit { path: path.to_string(), @@ -58,10 +63,10 @@ pub fn parse_blk(path: &str, text: &str) -> Result { // Blank lines at either end of a block are layout, not value. At least // one shipped file opens with an empty line, which without // this yields a unit type of "\nTank". - while body.last().is_some_and(|l| l.is_empty()) { + while body.last().is_some_and(|s| s.is_empty()) { body.pop(); } - while body.first().is_some_and(|l| l.is_empty()) { + while body.first().is_some_and(|s| s.is_empty()) { body.remove(0); } @@ -162,8 +167,7 @@ pub fn parse_blk(path: &str, text: &str) -> Result { for (idx, points) in armor_values.iter().enumerate() { let key = names .and_then(|n| n.get(idx).copied()) - .map(str::to_string) - .unwrap_or_else(|| format!("LOC{idx}")); + .map_or_else(|| format!("LOC{idx}"), str::to_string); unit.armor_locations.insert(key, *points); } } diff --git a/crates/helm-unitfile/src/lib.rs b/crates/helm-unitfile/src/lib.rs index 02d80ec..deec497 100644 --- a/crates/helm-unitfile/src/lib.rs +++ b/crates/helm-unitfile/src/lib.rs @@ -22,6 +22,7 @@ pub use helm_core::{Format, Mount, Unit}; mod blk; +/// Which picture belongs to which design, from the file the game reads. pub mod mekset; mod mtf; mod mul; diff --git a/crates/helm-unitfile/src/library.rs b/crates/helm-unitfile/src/library.rs index ab396ee..b175964 100644 --- a/crates/helm-unitfile/src/library.rs +++ b/crates/helm-unitfile/src/library.rs @@ -60,6 +60,11 @@ pub fn read_zip_text(reader: R) -> Result, Ok(out) } +/// Read every design out of a zipped library, of either format. +/// +/// A design that will not parse is recorded as a failure rather than +/// stopping the read: one bad file in eleven thousand should cost that file +/// and nothing else. pub fn read_zip(reader: R) -> Result { let mut archive = zip::ZipArchive::new(reader)?; let mut units = Vec::new(); diff --git a/crates/helm-unitfile/src/mekset.rs b/crates/helm-unitfile/src/mekset.rs index 993caa4..8ce3745 100644 --- a/crates/helm-unitfile/src/mekset.rs +++ b/crates/helm-unitfile/src/mekset.rs @@ -78,6 +78,7 @@ pub struct MekSet { } impl MekSet { + /// An empty mapping, to be filled from a `mekset.txt` and its includes. pub fn new() -> Self { Self::default() } diff --git a/crates/helm-unitfile/src/mtf.rs b/crates/helm-unitfile/src/mtf.rs index d62a37e..2f0b802 100644 --- a/crates/helm-unitfile/src/mtf.rs +++ b/crates/helm-unitfile/src/mtf.rs @@ -35,6 +35,10 @@ const CRIT_SECTIONS: &[&str] = &[ "center leg", ]; +/// Read a `.mtf`, which is how MegaMek writes a BattleMek. +/// +/// `path` is kept on the unit rather than used: a design says where it came +/// from, and a failure has to be able to name the file. pub fn parse_mtf(path: &str, text: &str) -> Result { let mut unit = Unit { path: path.to_string(), diff --git a/crates/helm-unitfile/src/mul.rs b/crates/helm-unitfile/src/mul.rs index 2ac74e8..dfeff85 100644 --- a/crates/helm-unitfile/src/mul.rs +++ b/crates/helm-unitfile/src/mul.rs @@ -1496,18 +1496,16 @@ mod tests { if a == b { return Ok(()); } - let differ = a - .lines() - .zip(b.lines()) - .find(|(x, y)| x != y) - .map(|(x, y)| format!("\n was: {x}\n now: {y}")) - .unwrap_or_else(|| { + let differ = a.lines().zip(b.lines()).find(|(x, y)| x != y).map_or_else( + || { format!( "\n {} lines became {}", a.lines().count(), b.lines().count() ) - }); + }, + |(x, y)| format!("\n was: {x}\n now: {y}"), + ); Err(differ) } diff --git a/crates/helm-wasm/src/lib.rs b/crates/helm-wasm/src/lib.rs index 981a43c..3577536 100644 --- a/crates/helm-wasm/src/lib.rs +++ b/crates/helm-wasm/src/lib.rs @@ -209,9 +209,18 @@ pub unsafe extern "C" fn helm_mul_from_json(ptr: *const u8, len: usize) -> i64 { .get("pilotName") .and_then(|n| n.as_str()) .map(str::to_string), - gunnery: unit.get("gunnery").and_then(|g| g.as_u64()).unwrap_or(4) as u8, - piloting: unit.get("piloting").and_then(|p| p.as_u64()).unwrap_or(5) as u8, - pilot_hits: unit.get("pilotHits").and_then(|h| h.as_u64()).unwrap_or(0) as u8, + gunnery: unit + .get("gunnery") + .and_then(serde_json::Value::as_u64) + .unwrap_or(4) as u8, + piloting: unit + .get("piloting") + .and_then(serde_json::Value::as_u64) + .unwrap_or(5) as u8, + pilot_hits: unit + .get("pilotHits") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0) as u8, armor: condition.armor, structure: condition.structure, destroyed: condition.destroyed,