From 564db782a85119e2c69d6df9d38865916623507e Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sat, 22 Aug 2026 08:37:27 -0400 Subject: [PATCH] refactor(unit-rules): one statement of each rule two calculations shared What a superheavy slot holds, what a Blue Shield is worth, what a loadout costs, how many items carry a flag and the shape of a machine crossing the wasm boundary were each written twice. A rule now reads as a condition and an effect, and a comment that restated the arithmetic below it is gone. Change-Id: I6c43d42d0e46be57f4ff289f4fce8502658d9565 --- crates/helm-bv/src/conformance.rs | 42 ++++------ crates/helm-bv/src/defensive.rs | 23 +++--- crates/helm-bv/src/infantry.rs | 5 +- crates/helm-bv/src/loadout.rs | 11 +-- crates/helm-bv/src/machine.rs | 3 - crates/helm-bv/src/rules.rs | 130 ++++++++++++++---------------- crates/helm-bv/src/wear.rs | 85 +++++++++++++------ crates/helm-core/src/catalogue.rs | 2 +- crates/helm-core/src/design.rs | 5 +- crates/helm-core/src/lib.rs | 2 +- crates/helm-core/src/metrics.rs | 53 +++++------- crates/helm-core/src/unit.rs | 18 +++++ crates/helm-cost/src/items.rs | 48 +++++------ crates/helm-cost/src/mek.rs | 14 +--- crates/helm-cost/src/vehicle.rs | 11 +-- crates/helm-facet/src/query.rs | 23 +----- crates/helm-wasm/src/lib.rs | 117 ++++++++++++++++++++------- 17 files changed, 310 insertions(+), 282 deletions(-) diff --git a/crates/helm-bv/src/conformance.rs b/crates/helm-bv/src/conformance.rs index 5b14706..c7805da 100644 --- a/crates/helm-bv/src/conformance.rs +++ b/crates/helm-bv/src/conformance.rs @@ -440,6 +440,18 @@ mod tests { }) } + /// The same figures as MegaMek's answer rather than ours, which is what + /// every one of these tests is comparing against. + fn theirs(bv: Option, def: Option, off: Option) -> BvBreakdown { + BvBreakdown { + name: "x".into(), + battle_value: bv, + defensive: def, + offensive: off, + ..Default::default() + } + } + #[test] fn a_design_with_no_oracle_is_not_counted_as_checked() { let mut c = report(); @@ -451,13 +463,7 @@ mod tests { #[test] fn agreement_and_disagreement_land_in_different_buckets() { let mut c = report(); - let theirs = BvBreakdown { - name: "x".into(), - battle_value: Some(1897), - defensive: Some(1155.6), - offensive: Some(741.5), - ..Default::default() - }; + let theirs = theirs(Some(1897), Some(1155.6), Some(741.5)); c.observe( "Atlas AS7-D", &ours(Some(1897), Some(1155.6), Some(741.5)), @@ -479,13 +485,7 @@ mod tests { #[test] fn a_finished_half_is_measured_while_the_other_half_is_not_yet_written() { let mut c = report(); - let theirs = BvBreakdown { - name: "x".into(), - battle_value: Some(1897), - defensive: Some(1155.6), - offensive: Some(741.5), - ..Default::default() - }; + let theirs = theirs(Some(1897), Some(1155.6), Some(741.5)); c.observe( "Atlas AS7-D", &ours(None, Some(1155.6), None), @@ -502,11 +502,7 @@ mod tests { #[test] fn a_type_we_do_not_score_is_out_of_scope_rather_than_a_gap() { let mut c = report(); - let theirs = BvBreakdown { - name: "x".into(), - battle_value: Some(900), - ..Default::default() - }; + let theirs = theirs(Some(900), None, None); let tank: Result = Err(Unsupported::UnitType("Tank".into())); c.observe("Behemoth", &tank, Some(&theirs)); c.observe("Behemoth II", &tank, Some(&theirs)); @@ -528,13 +524,7 @@ mod tests { #[test] fn everything_matching_reads_as_clean() { let mut c = report(); - let theirs = BvBreakdown { - name: "x".into(), - battle_value: Some(1897), - defensive: Some(1155.6), - offensive: Some(741.5), - ..Default::default() - }; + let theirs = theirs(Some(1897), Some(1155.6), Some(741.5)); c.observe( "Atlas AS7-D", &ours(Some(1897), Some(1155.6), Some(741.5)), diff --git a/crates/helm-bv/src/defensive.rs b/crates/helm-bv/src/defensive.rs index e7ef86e..3b8fbcb 100644 --- a/crates/helm-bv/src/defensive.rs +++ b/crates/helm-bv/src/defensive.rs @@ -73,15 +73,20 @@ pub fn defensive(machine: &Machine<'_>) -> Result }) } -/// Armour, per location so that patchwork plate is scored as what it is. -fn armor(machine: &Machine<'_>) -> Result { - // A Blue Shield Particle Field Damper adds a fifth to the plate's - // multiplier and to the frame's. Four designs carry one. - let blue_shield = if machine.loadout.has_flag("F_BLUE_SHIELD") { +/// What a Blue Shield Particle Field Damper adds to a multiplier. +/// +/// Four designs carry one, and both halves of the defensive figure ask. +fn blue_shield(machine: &Machine<'_>) -> f64 { + if machine.loadout.has_flag("F_BLUE_SHIELD") { 0.2 } else { 0.0 - }; + } +} + +/// Armour, per location so that patchwork plate is scored as what it is. +fn armor(machine: &Machine<'_>) -> Result { + let blue_shield = blue_shield(machine); // A suit's plate is worth what its own table says, and a trooper is worth // a point before any of it is counted - MegaMek's `1 + armor x 2.5`. @@ -117,11 +122,7 @@ fn armor(machine: &Machine<'_>) -> Result { /// The frame, through what it is made of and what the engine does to it. fn structure(machine: &Machine<'_>) -> Result { - let blue_shield = if machine.loadout.has_flag("F_BLUE_SHIELD") { - 0.2 - } else { - 0.0 - }; + let blue_shield = blue_shield(machine); Ok(machine.structure_points as f64 * PER_STRUCTURE_POINT * (structure_multiplier(&machine.structure_kind)? + blue_shield) diff --git a/crates/helm-bv/src/infantry.rs b/crates/helm-bv/src/infantry.rs index d4ce231..7917355 100644 --- a/crates/helm-bv/src/infantry.rs +++ b/crates/helm-bv/src/infantry.rs @@ -242,7 +242,6 @@ impl<'a> Platoon<'a> { /// hard they are to hit. fn defensive(&self) -> Defensive { let mut troopers = self.troopers as f64 * 1.5 * self.damage_divisor; - // A pheromone effuser adds a twentieth of the platoon. if self.has("gas_effuser_pheromone") { troopers += self.troopers as f64 * 0.05; } @@ -432,8 +431,8 @@ impl<'a> Platoon<'a> { piloting = piloting.saturating_sub(1); gunnery = gunnery.saturating_sub(1); } - // Anything that feeds the trooper a better picture of the field buys - // a point of gunnery. MegaMek groups six implants under one label. + // A sensory implant buys a point of gunnery. MegaMek groups six of + // them under one label. const SENSORY: [&str; 6] = [ "mm_implants", "enh_mm_implants", diff --git a/crates/helm-bv/src/loadout.rs b/crates/helm-bv/src/loadout.rs index e0eba0b..f398ce4 100644 --- a/crates/helm-bv/src/loadout.rs +++ b/crates/helm-bv/src/loadout.rs @@ -187,16 +187,7 @@ impl<'a> Loadout<'a> { catalogue: &'a Catalogue, condition: &crate::Condition, ) -> Loadout<'a> { - // A superheavy packs two critical slots' worth of equipment into each - // physical slot, which shows up in a `.mtf` two ways: two of the same - // one-slot item written either side of a pipe, and a two-slot weapon - // sitting in a single slot on its own. An Ares carries three ER Medium - // Pulse Lasers - two slots each everywhere else - in three slots. - let per_slot = if unit.mass.is_some_and(|t| t > 100.0) { - 2 - } else { - 1 - }; + let per_slot = unit.slots_per_critical(); // Item-slots and physical slots counted separately, because a // superheavy may put two of the same item in one slot and the two // counts then differ. diff --git a/crates/helm-bv/src/machine.rs b/crates/helm-bv/src/machine.rs index aa6b57a..58d6248 100644 --- a/crates/helm-bv/src/machine.rs +++ b/crates/helm-bv/src/machine.rs @@ -315,9 +315,6 @@ impl<'a> Machine<'a> { /// total rather than on one half, which is why a design with a small cockpit /// can have both its ratings exactly right and its battle value wrong by five /// percent. Two hundred and six designs were failing on precisely that. -/// -/// A small or torso-mounted cockpit is worth 0.95, an interface cockpit 1.3, -/// and everything else 1.0. fn cockpit_modifier(cockpit: &CockpitKind, is_drone: bool) -> f64 { match cockpit { CockpitKind::Small | CockpitKind::TorsoMounted => 0.95, diff --git a/crates/helm-bv/src/rules.rs b/crates/helm-bv/src/rules.rs index 72d52e5..faa6b07 100644 --- a/crates/helm-bv/src/rules.rs +++ b/crates/helm-bv/src/rules.rs @@ -49,6 +49,19 @@ pub struct Adjustments { pub target_movement: i64, } +impl Adjustments { + /// These adjustments where the design carries the thing, and nothing where + /// it does not. + /// + /// Every rule here is one condition and one effect. Written as an `if`, + /// the effect is nested inside the condition and each rule ends in the + /// same four lines of `} else { NONE }`; written this way the two read in + /// the order they are thought of. + const fn when(self, carried: bool) -> Adjustments { + if carried { self } else { NONE } + } +} + /// One named fact about how something a design carries changes its rating. pub struct Rule { /// What it is, in the words somebody would use to look it up. @@ -71,14 +84,11 @@ pub const RULES: &[Rule] = &[ Rule { name: "battle armour", apply: |machine| { - if machine.kind == crate::machine::Chassis::BattleArmor { - Adjustments { - heat_budget: 0, - target_movement: 1, - } - } else { - NONE + Adjustments { + target_movement: 1, + ..NONE } + .when(machine.kind == crate::machine::Chassis::BattleArmor) }, }, Rule { @@ -88,40 +98,36 @@ pub const RULES: &[Rule] = &[ // a different thing and the two do stack: a Tornado PA(L) is paid for // both. apply: |machine| { - if machine.loadout.has_flag("F_VISUAL_CAMO") - && !machine.suit_armor.is_some_and(|s| s.mimetic) - { - Adjustments { - heat_budget: 0, - target_movement: 2, - } - } else { - NONE + Adjustments { + target_movement: 2, + ..NONE } + .when( + machine.loadout.has_flag("F_VISUAL_CAMO") + && !machine.suit_armor.is_some_and(|s| s.mimetic), + ) }, }, Rule { name: "battle armour stealth plate", // Worth two steps, or three for the improved and mimetic kinds. - apply: |machine| match machine.suit_armor { - Some(suit) if suit.target_movement > 0 => Adjustments { - heat_budget: 0, - target_movement: suit.target_movement, - }, - _ => NONE, + apply: |machine| { + let steps = machine.suit_armor.map_or(0, |suit| suit.target_movement); + Adjustments { + target_movement: steps, + ..NONE + } + .when(steps > 0) }, }, Rule { name: "chameleon shield", apply: |machine| { - if machine.loadout.has_flag("F_CHAMELEON_SHIELD") { - Adjustments { - heat_budget: -6, - target_movement: 2, - } - } else { - NONE + Adjustments { + heat_budget: -6, + target_movement: 2, } + .when(machine.loadout.has_flag("F_CHAMELEON_SHIELD")) }, }, Rule { @@ -129,40 +135,31 @@ pub const RULES: &[Rule] = &[ // Spent to shed heat, and worth more the more sinks there are to help. apply: |machine| { let pods = machine.loadout.ammo_count("COOLANT_POD"); - if pods > 0 { - Adjustments { - heat_budget: (machine.heat_sink_count * pods + 4) / 5, - ..NONE - } - } else { - NONE + Adjustments { + heat_budget: (machine.heat_sink_count * pods + 4) / 5, + ..NONE } + .when(pods > 0) }, }, Rule { name: "emergency coolant system", apply: |machine| { - if machine.loadout.has_flag("F_EMERGENCY_COOLANT_SYSTEM") { - Adjustments { - heat_budget: 4, - ..NONE - } - } else { - NONE + Adjustments { + heat_budget: 4, + ..NONE } + .when(machine.loadout.has_flag("F_EMERGENCY_COOLANT_SYSTEM")) }, }, Rule { name: "null signature system", apply: |machine| { - if machine.loadout.has_flag("F_NULL_SIG") { - Adjustments { - heat_budget: -10, - target_movement: 2, - } - } else { - NONE + Adjustments { + heat_budget: -10, + target_movement: 2, } + .when(machine.loadout.has_flag("F_NULL_SIG")) }, }, Rule { @@ -171,14 +168,11 @@ pub const RULES: &[Rule] = &[ // On an Ares with twenty-three doubles that is ten more heat, which is // two more weapons fired at full value. apply: |machine| { - if machine.loadout.has_flag("F_RADICAL_HEATSINK") { - Adjustments { - heat_budget: (machine.heat_sink_count * 2 + 4) / 5, - ..NONE - } - } else { - NONE + Adjustments { + heat_budget: (machine.heat_sink_count * 2 + 4) / 5, + ..NONE } + .when(machine.loadout.has_flag("F_RADICAL_HEATSINK")) }, }, Rule { @@ -186,14 +180,11 @@ pub const RULES: &[Rule] = &[ // The plate is ordinary; what it buys is being harder to hit, and it // costs most of a light Mek's heat budget to run. apply: |machine| { - if machine.is_stealthy() { - Adjustments { - heat_budget: -10, - target_movement: 2, - } - } else { - NONE + Adjustments { + heat_budget: -10, + target_movement: 2, } + .when(machine.is_stealthy()) }, }, Rule { @@ -202,14 +193,11 @@ pub const RULES: &[Rule] = &[ // void signature sets a floor rather than adding to what is there. // That exception is applied where the modifier is worked out. apply: |machine| { - if machine.loadout.has_flag("F_VOID_SIG") { - Adjustments { - heat_budget: -10, - ..NONE - } - } else { - NONE + Adjustments { + heat_budget: -10, + ..NONE } + .when(machine.loadout.has_flag("F_VOID_SIG")) }, }, ]; diff --git a/crates/helm-bv/src/wear.rs b/crates/helm-bv/src/wear.rs index ea3fdb6..08da120 100644 --- a/crates/helm-bv/src/wear.rs +++ b/crates/helm-bv/src/wear.rs @@ -264,15 +264,11 @@ fn patch( from: Condition, target: i64, best: &mut Worn, - mut consider: impl FnMut(Condition, &mut Worn) -> Result, + consider: impl FnMut(Condition, &mut Worn) -> Result, ) -> Result<(), Unsupported> { let shot_through = breached(unit, &from); - let mut condition = from; - for _ in 0..REFINEMENTS { - if best.is_exact() { - return Ok(()); - } - let Some((location, points)) = condition + refine_toward(from, target, Toward::Up, best, consider, |condition| { + condition .armor .iter() .filter(|(location, points)| { @@ -289,15 +285,7 @@ fn patch( missing(*a).cmp(&missing(*b)).then(b.0.cmp(a.0)) }) .map(|(location, points)| (location.clone(), *points)) - else { - return Ok(()); - }; - condition.armor.insert(location, points + 1); - if consider(condition.clone(), best)? >= target { - return Ok(()); - } - } - Ok(()) + }) } /// Take armour off a point at a time, from wherever there is most of it left. @@ -308,26 +296,71 @@ fn refine( from: Condition, target: i64, best: &mut Worn, - mut consider: impl FnMut(Condition, &mut Worn) -> Result, + consider: impl FnMut(Condition, &mut Worn) -> Result, ) -> Result<(), Unsupported> { - let mut condition = from; - for _ in 0..REFINEMENTS { - if best.is_exact() { - return Ok(()); - } + refine_toward(from, target, Toward::Down, best, consider, |condition| { // The thickest location, by name where two are equal so that the same // design always wears the same way. - let Some((location, points)) = condition + condition .armor .iter() .filter(|(_, points)| **points > 0) .max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0))) .map(|(location, points)| (location.clone(), *points)) - else { + }) +} + +/// Which way a refinement is moving, and how it knows it has arrived. +#[derive(Clone, Copy)] +enum Toward { + /// Putting armour back, until the figure comes up to the target. + Up, + /// Taking armour off, until it comes down to it. + Down, +} + +impl Toward { + /// What one step does to a location's armour. + fn step(self) -> i64 { + match self { + Toward::Up => 1, + Toward::Down => -1, + } + } + + /// Whether a figure has reached the target from the side it started on. + fn arrived(self, scored: i64, target: i64) -> bool { + match self { + Toward::Up => scored >= target, + Toward::Down => scored <= target, + } + } +} + +/// Move one point of armour at a time until the figure arrives, or the budget +/// runs out, or `pick` says there is nowhere left to take it from. +/// +/// The two callers differ only in which location they choose and which way +/// they move it; the budget, the exact-match early exit and the stopping rule +/// are the same search and are written once. +fn refine_toward( + from: Condition, + target: i64, + toward: Toward, + best: &mut Worn, + mut consider: impl FnMut(Condition, &mut Worn) -> Result, + mut pick: impl FnMut(&Condition) -> Option<(String, i64)>, +) -> Result<(), Unsupported> { + let mut condition = from; + for _ in 0..REFINEMENTS { + if best.is_exact() { + return Ok(()); + } + let Some((location, points)) = pick(&condition) else { return Ok(()); }; - condition.armor.insert(location, points - 1); - if consider(condition.clone(), best)? <= target { + condition.armor.insert(location, points + toward.step()); + if toward.arrived(consider(condition.clone(), best)?, target) { return Ok(()); } } diff --git a/crates/helm-core/src/catalogue.rs b/crates/helm-core/src/catalogue.rs index 1c45c25..477d7fd 100644 --- a/crates/helm-core/src/catalogue.rs +++ b/crates/helm-core/src/catalogue.rs @@ -131,7 +131,7 @@ pub struct EquipmentEntry { pub damage_divisor: Option, /// Whether this item's battle value depends on the design carrying it. /// - /// A hatchet is worth a fifth of the Mek swinging it, so the catalogue + /// A hatchet is worth a fifth of the design's weight, so the catalogue /// cannot hold a number - MegaMek stores a sentinel and computes the value /// per unit. Anything with this set has no useful `battle_value`. pub variable_battle_value: bool, diff --git a/crates/helm-core/src/design.rs b/crates/helm-core/src/design.rs index b8db0d2..1384066 100644 --- a/crates/helm-core/src/design.rs +++ b/crates/helm-core/src/design.rs @@ -61,9 +61,8 @@ pub struct HeatSinks { /// What one heat sink sheds, by the family it belongs to. /// -/// A double sheds two and a single one. So does a compact: what it buys is -/// space rather than heat - half a critical slot each, for half a ton more - -/// which is why the kind is read for its dissipation and not for its name. +/// A compact buys space rather than heat: half a critical slot each, for half +/// a ton more. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HeatSinkKind { Single, diff --git a/crates/helm-core/src/lib.rs b/crates/helm-core/src/lib.rs index 364b497..7ff67c9 100644 --- a/crates/helm-core/src/lib.rs +++ b/crates/helm-core/src/lib.rs @@ -41,7 +41,7 @@ pub use design::{ }; pub use metrics::{ CombatMetrics, FORMATION_RANGES, FORMATION_SINGLE_RANGES, can_make_anti_mek_attacks, - can_ride_a_mek, damage_at_range, equipment_list, infantry_armor_is_encumbering, + can_ride_a_mek, count_carrying, damage_at_range, equipment_list, infantry_armor_is_encumbering, infantry_can_make_anti_mek_attacks, infantry_can_swarm, infantry_is_mechanized, is_conventional_infantry, jumping_mp, max_single_damage_at_range, mounted_items, one_shot_ammo, platoon_weapons, slot_size, walking_mp, weapon_bays, diff --git a/crates/helm-core/src/metrics.rs b/crates/helm-core/src/metrics.rs index 7750cfc..1f68934 100644 --- a/crates/helm-core/src/metrics.rs +++ b/crates/helm-core/src/metrics.rs @@ -312,11 +312,7 @@ pub fn mounted_items<'a>(unit: &Unit, catalogue: &'a Catalogue) -> Vec<(&'a Equi return counts; } - let per_slot = if unit.mass.is_some_and(|t| t > 100.0) { - 2 - } else { - 1 - }; + let per_slot = unit.slots_per_critical(); slots .into_iter() .filter_map(|(name, (filled, locations))| { @@ -348,6 +344,19 @@ pub fn mounted_items<'a>(unit: &Unit, catalogue: &'a Catalogue) -> Vec<(&'a Equi .collect() } +/// How many of a design's mounted items carry a flag. +/// +/// Over the counts [`mounted_items`] returns rather than over slots, so an +/// improved jump jet filling two of them is one jet: counting slots gives a +/// Firestarter FS9-B sixteen points of jump where it has eight. +pub fn count_carrying(items: &[(&EquipmentEntry, i64)], flag: &str) -> i64 { + items + .iter() + .filter(|(entry, _)| entry.has_flag(flag)) + .map(|(_, count)| count) + .sum() +} + /// Everything MegaMek would list as being on this design, invented parts /// included. /// @@ -825,13 +834,7 @@ fn hands_free(unit: &Unit, catalogue: &Catalogue, manipulators: i64) -> bool { return false; } let items = mounted_items(unit, catalogue); - let count = |flag: &str| -> i64 { - items - .iter() - .filter(|(e, _)| e.has_flag(flag)) - .map(|(_, n)| n) - .sum() - }; + let count = |flag: &str| count_carrying(&items, flag); if count("F_MAGNETIC_CLAMP") > 0 { return true; } @@ -875,13 +878,7 @@ pub fn jumping_mp(unit: &Unit, catalogue: &Catalogue) -> Option { // them, and counting slots gives a Firestarter FS9-B sixteen points of // jump where it has eight. let items = mounted_items(unit, catalogue); - let total = |flag: &str| -> i64 { - items - .iter() - .filter(|(e, _)| e.has_flag(flag)) - .map(|(_, n)| n) - .sum() - }; + let total = |flag: &str| count_carrying(&items, flag); let jets = total("F_JUMP_JET"); let umus = total("F_UMU"); let wing = total("F_PARTIAL_WING") > 0; @@ -1023,13 +1020,9 @@ mod tests { equipment: mounts .iter() .map(|n| Mount { - bay: false, name: (*n).to_string(), location: None, - rear: false, - carried_on: None, - shots: None, - trooper: None, + ..Default::default() }) .collect(), ..Default::default() @@ -1259,13 +1252,9 @@ mod reading_the_catalogue { equipment: weapons .iter() .map(|name| Mount { - bay: false, name: (*name).into(), location: Some("Right Arm".into()), - rear: false, - carried_on: None, - shots: None, - trooper: None, + ..Default::default() }) .collect(), ..Default::default() @@ -1538,13 +1527,9 @@ mod anti_mek { equipment: hands .iter() .map(|name| Mount { - bay: false, name: (*name).into(), location: Some("LA".into()), - rear: false, - carried_on: None, - shots: None, - trooper: None, + ..Default::default() }) .collect(), ..Default::default() diff --git a/crates/helm-core/src/unit.rs b/crates/helm-core/src/unit.rs index dbd10e8..b01ee04 100644 --- a/crates/helm-core/src/unit.rs +++ b/crates/helm-core/src/unit.rs @@ -171,6 +171,24 @@ impl Unit { } } + /// How many critical slots' worth of equipment one physical slot holds. + /// + /// A superheavy packs two into each, which shows up in a `.mtf` two ways: + /// two of the same one-slot item written either side of a pipe, and a + /// two-slot weapon sitting in a single slot on its own. An Ares carries + /// three ER Medium Pulse Lasers - two slots each everywhere else - in + /// three slots. + /// + /// Both the battle value loadout and the metrics count items by this, and + /// the two have to agree or the same design has two item counts. + pub fn slots_per_critical(&self) -> usize { + if self.mass.is_some_and(|t| t > 100.0) { + 2 + } else { + 1 + } + } + /// Whether this design is a Mek. /// /// A `.mtf` is always a Mek and declares no unit type; a `.blk` declares diff --git a/crates/helm-cost/src/items.rs b/crates/helm-cost/src/items.rs index 7f8794e..9cffd9a 100644 --- a/crates/helm-cost/src/items.rs +++ b/crates/helm-cost/src/items.rs @@ -173,14 +173,10 @@ struct Priced { /// the item. Anything not listed is nought, which is a known shortfall rather /// than a claim - see TODO.md. const PRICED_BY_THE_DESIGN: &[Priced] = &[ - // Sized by the slot line: `Communications Equipment:SIZE:2.0` is two tons - // at ten thousand a ton. Priced { flag: "F_COMMUNICATIONS", price: |f| f.size() * 10_000.0, }, - // Two and a half tons plus one per bed, and two tons plus a half per - // drone, both at ten thousand a ton. Priced { flag: "F_MASH", price: |f| (2.5 + f.size()) * 10_000.0, @@ -189,9 +185,6 @@ const PRICED_BY_THE_DESIGN: &[Priced] = &[ flag: "F_DRONE_CARRIER_CONTROL", price: |f| (2.0 + f.size() * 0.5) * 10_000.0, }, - // A tenth of the weight it carries, split between the design's rings: - // four light machine guns on one sponson is half a ton, two thousand - // C-bills. Priced { flag: "F_SPONSON_TURRET", price: |f| small_turret(f.unit, f.catalogue, "(ST)", "F_SPONSON_TURRET") * 4_000.0, @@ -209,8 +202,6 @@ const PRICED_BY_THE_DESIGN: &[Priced] = &[ flag: "F_JET_BOOSTER", price: |f| f.rating * 10_000.0, }, - // Priced by the engine rating and by the design's weight: a ton per - // twenty, or per twenty-five where the design is Clan. Priced { flag: "F_MASC", price: |f| { @@ -218,7 +209,6 @@ const PRICED_BY_THE_DESIGN: &[Priced] = &[ f.rating * 1_000.0 * (f.tons / per).round().max(1.0) }, }, - // Sized by the design's weight, in fifteen-ton steps. Priced { flag: "S_HATCHET", price: |f| (f.tons / 15.0).ceil() * 5_000.0, @@ -227,8 +217,6 @@ const PRICED_BY_THE_DESIGN: &[Priced] = &[ flag: "S_CLAW", price: |f| f.tons * 200.0, }, - // A twentieth of the design's weight, rounded up to the half ton, at ten - // thousand a ton. Priced { flag: "S_SWORD", price: |f| crate::next_half_ton(f.tons / 20.0) * 10_000.0, @@ -237,13 +225,10 @@ const PRICED_BY_THE_DESIGN: &[Priced] = &[ flag: "S_MACE", price: |_| 130_000.0, }, - // A twentieth of the design's weight plus a ton of housing. Priced { flag: "S_RETRACTABLE_BLADE", price: |f| ((f.tons / 20.0).ceil() + 1.0) * 10_000.0, }, - // Five hundred a point of engine rating per ton of design, and seven - // fifty where the item is a QuadVee's wheels. Priced { flag: "F_TRACKS", price: |f| { @@ -255,8 +240,6 @@ const PRICED_BY_THE_DESIGN: &[Priced] = &[ (per * f.rating * f.tons / 75.0).ceil() }, }, - // A twentieth of the design's weight where the wing is Clan and seven - // hundredths where it is not, in half tons at fifty thousand each. Priced { flag: "F_PARTIAL_WING", price: |f| { @@ -268,7 +251,6 @@ const PRICED_BY_THE_DESIGN: &[Priced] = &[ flag: "F_TALON", price: |f| (f.tons / 15.0).ceil() * 300.0, }, - // Ten thousand flat and a thousand a ton. Priced { flag: "F_DRONE_OPERATING_SYSTEM", price: |f| 10_000.0 + f.tons * 1_000.0, @@ -281,8 +263,6 @@ const PRICED_BY_THE_DESIGN: &[Priced] = &[ flag: "F_SPIKES", price: |f| f.tons * 50.0, }, - // Seven hundred a ton in a leg and five hundred in an arm; the slot line - // says which limb it is in. Priced { flag: "F_ACTUATOR_ENHANCEMENT_SYSTEM", price: |f| { @@ -294,16 +274,14 @@ const PRICED_BY_THE_DESIGN: &[Priced] = &[ (f.tons * per).ceil() }, }, - // Sealing a Mek against vacuum is 225 a ton. Sealing a vehicle is not - // charged for here at all: it is a quarter on the finished hull instead. + // A vehicle's sealing is not charged here: it is a quarter on the + // finished hull. Priced { flag: "F_ENVIRONMENTAL_SEALING", price: |f| { if f.unit.is_mek() { f.tons * 225.0 } else { 0.0 } }, }, - // A thousand a ton fully amphibious. Limited is eight hundredths of the - // tonnage, rounded up, at five thousand each. Priced { flag: "F_FULLY_AMPHIBIOUS", price: |f| f.tons * 1_000.0, @@ -312,8 +290,6 @@ const PRICED_BY_THE_DESIGN: &[Priced] = &[ flag: "F_LIMITED_AMPHIBIOUS", price: |f| (f.tons * 0.08).ceil() * 5_000.0, }, - // A tenth of the design's weight where the item is Clan and fifteen - // hundredths where it is not, in half tons at a hundred thousand each. Priced { flag: "F_ARMORED_MOTIVE_SYSTEM", price: |f| { @@ -346,6 +322,26 @@ pub(crate) fn each( } } +/// What everything mounted costs, at one of each item's price. +/// +/// The plate is dropped here rather than by each calculation: it fills +/// critical slots *and* has its own line, so counting the slots would charge +/// a ferro-fibrous design for its armour twice. +pub(crate) fn mounted( + loadout: &[(&helm_core::EquipmentEntry, i64)], + unit: &Unit, + catalogue: &Catalogue, + tons: f64, + rating: i64, +) -> f64 { + loadout + .iter() + .filter(|(entry, _)| !is_plate(entry)) + .fold(0.0, |total, (entry, count)| { + total + each(entry, unit, catalogue, tons, rating) * *count as f64 + }) +} + /// Whether an item is the design's plate, which every caller drops. pub(crate) fn is_plate(entry: &helm_core::EquipmentEntry) -> bool { entry.classes.iter().any(|c| c == "ArmorType") diff --git a/crates/helm-cost/src/mek.rs b/crates/helm-cost/src/mek.rs index 5560fea..c5adb1b 100644 --- a/crates/helm-cost/src/mek.rs +++ b/crates/helm-cost/src/mek.rs @@ -358,12 +358,7 @@ fn gyro(unit: &Unit, tons: f64) -> Result { /// charged at the rate the last one sets. fn jump_jets(unit: &Unit, catalogue: &Catalogue, tons: f64) -> f64 { let items = helm_core::mounted_items(unit, catalogue); - let count = |flag: &str| -> f64 { - items - .iter() - .filter(|(e, _)| e.has_flag(flag)) - .fold(0.0, |n, (_, c)| n + *c as f64) - }; + let count = |flag: &str| helm_core::count_carrying(&items, flag) as f64; // Counted off the slots rather than read off `jump mp:`, which a `.mtf` // does not always mean: a partial wing adds to the distance and not to // the jets, and the jets are what is being bought. @@ -504,12 +499,7 @@ fn equipment(unit: &Unit, catalogue: &Catalogue, tons: f64, rating: i64) -> f64 .unwrap_or(0.0) * *count as f64 }); - let mounted = items - .iter() - .filter(|(entry, _)| !crate::items::is_plate(entry)) - .fold(0.0, |total, (entry, count)| { - total + crate::items::each(entry, unit, catalogue, tons, rating) * *count as f64 - }); + let mounted = crate::items::mounted(&items, unit, catalogue, tons, rating); mounted + loaded + clan_case(unit, catalogue) diff --git a/crates/helm-cost/src/vehicle.rs b/crates/helm-cost/src/vehicle.rs index 5506fab..2a82cb0 100644 --- a/crates/helm-cost/src/vehicle.rs +++ b/crates/helm-cost/src/vehicle.rs @@ -434,14 +434,9 @@ fn equipment( tons: f64, rating: i64, ) -> f64 { - let mounted = loadout - .iter() - .filter(|(entry, _)| !crate::items::is_plate(entry)) - // Priced the same way here as on a Mek: a supercharger by the engine - // it is strapped to, a communications set by the tonnage on its line. - .fold(0.0, |total, (entry, count)| { - total + crate::items::each(entry, unit, catalogue, tons, rating) * *count as f64 - }); + // Priced the same way here as on a Mek: a supercharger by the engine it is + // strapped to, a communications set by the tonnage on its line. + let mounted = crate::items::mounted(loadout, unit, catalogue, tons, rating); mounted + crate::items::targeting_computer(loadout, unit) + crate::items::clan_case(unit, catalogue) diff --git a/crates/helm-facet/src/query.rs b/crates/helm-facet/src/query.rs index ef645fc..6d8f460 100644 --- a/crates/helm-facet/src/query.rs +++ b/crates/helm-facet/src/query.rs @@ -478,40 +478,25 @@ mod tests { name: "Atlas AS7-D".into(), equipment: vec![ Mount { - bay: false, name: "Medium Laser".into(), location: Some("Left Arm".into()), - rear: false, - carried_on: None, - shots: None, - trooper: None, + ..Default::default() }, Mount { - bay: false, name: "Medium Laser".into(), location: Some("Right Arm".into()), - rear: false, - carried_on: None, - shots: None, - trooper: None, + ..Default::default() }, Mount { - bay: false, name: "AC/20".into(), location: Some("Right Torso".into()), - rear: false, - carried_on: None, - shots: None, - trooper: None, + ..Default::default() }, Mount { - bay: false, name: "Medium Laser".into(), location: Some("Center Torso".into()), rear: true, - carried_on: None, - shots: None, - trooper: None, + ..Default::default() }, ], ..Default::default() diff --git a/crates/helm-wasm/src/lib.rs b/crates/helm-wasm/src/lib.rs index 3577536..26b149a 100644 --- a/crates/helm-wasm/src/lib.rs +++ b/crates/helm-wasm/src/lib.rs @@ -152,20 +152,7 @@ pub unsafe extern "C" fn helm_mul_to_json(ptr: *const u8, len: usize) -> i64 { let Ok(mul) = helm_unitfile::parse_mul(text) else { return ERR_UNREADABLE; }; - let units: Vec = mul - .machines() - .map(|u| { - serde_json::json!({ - "chassis": u.chassis, - "model": u.model, - "pilotName": u.pilot_name, - "gunnery": u.gunnery, - "piloting": u.piloting, - "pilotHits": u.pilot_hits, - "condition": condition_json(u), - }) - }) - .collect(); + let units: Vec = mul.machines().map(machine_json).collect(); give(serde_json::json!({ "units": units }).to_string()) } @@ -241,6 +228,38 @@ pub unsafe extern "C" fn helm_mul_from_json(ptr: *const u8, len: usize) -> i64 { /// What a `.mul` says has happened to one machine, as the condition JSON the /// scoring calls take. +/// One machine, in the shape every call that hands a force to a page uses. +/// +/// `helm_mul_to_json` and `helm_mul_summary` both write it, and +/// `helm_mul_from_json` reads it back: three sites that have to agree on the +/// same seven names for a round trip to survive. +fn machine_json(unit: &helm_unitfile::MulUnit) -> serde_json::Value { + serde_json::json!({ + "chassis": unit.chassis, + "model": unit.model, + "pilotName": unit.pilot_name, + "gunnery": unit.gunnery, + "piloting": unit.piloting, + "pilotHits": unit.pilot_hits, + "condition": condition_json(unit), + }) +} + +/// One machine as the summary draws it: what every force-shaped call says, +/// and the three fields only a summary has. +/// +/// Its place in the file, what shape it is, and whether it is a person rather +/// than a design - none of which a page can work out from the seven fields +/// [`machine_json`] writes. +fn summary_json(at: usize, unit: &helm_unitfile::MulUnit) -> serde_json::Value { + let mut json = machine_json(unit); + let fields = json.as_object_mut().expect("machine_json writes an object"); + fields.insert("at".into(), at.into()); + fields.insert("type".into(), unit.unit_type.clone().into()); + fields.insert("isCrew".into(), unit.is_crew().into()); + json +} + fn condition_json(unit: &helm_unitfile::MulUnit) -> serde_json::Value { let slots = |m: &std::collections::BTreeMap>| { m.iter() @@ -337,20 +356,7 @@ pub extern "C" fn helm_mul_summary(handle: i64) -> i64 { .units .iter() .enumerate() - .map(|(at, u)| { - serde_json::json!({ - "at": at, - "chassis": u.chassis, - "model": u.model, - "type": u.unit_type, - "isCrew": u.is_crew(), - "pilotName": u.pilot_name, - "gunnery": u.gunnery, - "piloting": u.piloting, - "pilotHits": u.pilot_hits, - "condition": condition_json(u), - }) - }) + .map(|(at, u)| summary_json(at, u)) .collect(); give( serde_json::json!({ @@ -996,3 +1002,58 @@ fn read_condition_value(value: Option<&serde_json::Value>) -> Result helm_unitfile::MulUnit { + helm_unitfile::MulUnit { + chassis: "Nightscrip".into(), + model: "NSC-1V".into(), + unit_type: Some("Biped".into()), + pilot_name: Some("Placeholder".into()), + gunnery: 4, + piloting: 5, + pilot_hits: 1, + ..Default::default() + } + } + + /// The summary and `helm_mul_to_json` describe the same machine, so a page + /// that learned to read one can read the other. They were separate + /// literals repeating seven field names apiece, two hundred lines apart. + #[test] + fn the_summary_says_everything_the_force_json_says() { + let unit = a_machine(); + let plain = machine_json(&unit); + let summary = summary_json(0, &unit); + + for (name, value) in plain.as_object().expect("an object") { + assert_eq!( + summary.get(name), + Some(value), + "the summary drops or changes {name}" + ); + } + assert_eq!(summary.get("at"), Some(&serde_json::json!(0))); + } + + /// What the boundary writes for a condition is what it reads back. The two + /// halves of that round trip are a hundred lines apart and nothing on this + /// side of wasm was checking them against each other. + #[test] + fn a_condition_survives_being_written_and_read() { + let mut unit = a_machine(); + unit.armor.insert("CT".into(), 9); + unit.structure.insert("LA".into(), 3); + unit.destroyed + .insert("Right Torso".into(), [2usize, 4].into_iter().collect()); + + let written = condition_json(&unit); + let read = read_condition_value(Some(&written)).expect("what we just wrote"); + assert_eq!(read.armor.get("CT"), Some(&9)); + assert_eq!(read.structure.get("LA"), Some(&3)); + assert!(read.destroyed["Right Torso"].contains(&4)); + } +} -- 2.51.2