diff --git a/crates/helm-bv/src/lib.rs b/crates/helm-bv/src/lib.rs index 5c44f99..6b1ad0e 100644 --- a/crates/helm-bv/src/lib.rs +++ b/crates/helm-bv/src/lib.rs @@ -41,6 +41,7 @@ mod equipment; mod loadout; mod mek; mod offensive; +mod repair; mod rules; mod structure; @@ -52,8 +53,9 @@ pub use defensive::{Defensive, defensive, tmm}; pub use equipment::RULE_FLAGS; pub use mek::Mek; pub use offensive::{Offensive, offensive, speed_factor}; +pub use repair::{Repair, Slot, Total, repairs}; pub use rules::{Adjustments, RULES, Rule, adjustments, fired}; -pub use structure::Shape; +pub use structure::{Shape, per_location as structure_per_location}; use helm_core::{BvBreakdown, Catalogue, ComputedStats, Unit}; diff --git a/crates/helm-bv/src/repair.rs b/crates/helm-bv/src/repair.rs new file mode 100644 index 0000000..13db3f9 --- /dev/null +++ b/crates/helm-bv/src/repair.rs @@ -0,0 +1,311 @@ +//! What it would take to put a machine back the way it was. +//! +//! The same two sides as everything else here - a design and the condition it +//! is in - read for a different question. Battle value asks what the machine +//! is worth now; this asks what is missing, per location, in the units a +//! technician works in: points of plate, points of frame, the equipment that +//! was shot out, and the magazines that need filling. +//! +//! Nothing here is costed. What a repair takes in time, parts and C-bills is a +//! campaign's question and belongs wherever campaigns are kept; what is +//! missing is a fact about two files, and that is what this reports. + +use helm_core::Unit; + +use crate::{Condition, structure}; + +/// One thing in a critical slot that needs attention. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Slot { + /// Numbered from zero, as everything but a `.mul` numbers them. + pub at: usize, + /// What the design has in that slot. + pub what: String, +} + +/// What one location needs. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Repair { + /// The location's full name, as the design writes it: `Left Torso`. + pub location: String, + /// Points of plate to replace, front and rear separately - they are + /// separate figures on the sheet and separate work in the bay. + pub armor: i64, + pub rear_armor: i64, + /// Points of frame to replace. + pub structure: i64, + /// Equipment shot out, in slot order. + pub destroyed: Vec, + /// Magazines with nothing left in them. + pub reload: Vec, + /// Nothing left of the frame, so the location is off rather than damaged. + /// + /// Worth its own flag: replacing a limb is not the same job as patching + /// one, and the plate and equipment figures below it are everything that + /// was on it rather than a list of separate repairs. + pub gone: bool, +} + +impl Repair { + /// Whether this location needs anything at all. + pub fn is_needed(&self) -> bool { + self.armor > 0 + || self.rear_armor > 0 + || self.structure > 0 + || !self.destroyed.is_empty() + || !self.reload.is_empty() + } +} + +/// What a machine needs, location by location. +/// +/// In the design's own location order - head first, then the torsos, then the +/// limbs - rather than in whatever order the file happened to write them, and +/// only the locations that need something. +/// +/// An empty list is a machine that came back the way it left. +pub fn repairs(design: &Unit, condition: &Condition) -> Vec { + let shape = structure::Shape::from_config(design.config.as_deref()); + let frame = design + .mass + .and_then(|tons| structure::per_location(tons, shape)); + + let mut out = Vec::new(); + for (code, name) in helm_unitfile::locations_for_config(design.config.as_deref()) { + let built = |key: &str| design.armor_locations.get(key).copied().unwrap_or(0); + let left = |key: &str, built: i64| condition.armor.get(key).copied().unwrap_or(built); + + let front = built(code); + // Only the three torsos have rear plate, and it is a location of its + // own on the sheet. Deriving the key from the location's first letter + // is nearly right and quietly wrong - a *left arm* would come out as + // the left torso's rear. + let rear_code = match *code { + "CT" => Some("RTC"), + "LT" => Some("RTL"), + "RT" => Some("RTR"), + _ => None, + }; + let rear = rear_code.map(built).unwrap_or(0); + + let built_frame = frame + .as_ref() + .and_then(|f| f.iter().find(|(l, _)| l == code)) + .map(|(_, points)| *points) + .unwrap_or(0); + let frame_left = condition + .structure + .get(*code) + .copied() + .unwrap_or(built_frame); + + let slots = design.criticals.get(*name); + let named = |at: &usize| Slot { + at: *at, + what: slots + .and_then(|s| s.get(*at)) + .cloned() + .unwrap_or_else(|| "unknown".into()), + }; + let taken = + |from: &std::collections::BTreeMap>| { + from.get(*name) + .map(|slots| slots.iter().map(named).collect()) + .unwrap_or_default() + }; + + let repair = Repair { + location: (*name).to_string(), + armor: (front - left(code, front)).max(0), + rear_armor: rear_code.map_or(0, |key| (rear - left(key, rear)).max(0)), + structure: (built_frame - frame_left).max(0), + destroyed: taken(&condition.destroyed), + reload: taken(&condition.empty_ammo), + // A location with no frame left is off the machine. Guarded on the + // design having a frame figure at all, so a design whose tonnage + // is not in the table does not read as having lost every limb. + gone: built_frame > 0 && frame_left <= 0, + }; + if repair.is_needed() { + out.push(repair); + } + } + out +} + +/// Points of plate, points of frame, items and magazines, added up. +/// +/// The one-line version, for a list of machines where the per-location detail +/// is one machine's worth of reading. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Total { + pub armor: i64, + pub structure: i64, + pub items: usize, + pub reloads: usize, + /// Locations that are off rather than damaged. + pub gone: usize, +} + +impl Total { + pub fn of(repairs: &[Repair]) -> Total { + Total { + armor: repairs.iter().map(|r| r.armor + r.rear_armor).sum(), + structure: repairs.iter().map(|r| r.structure).sum(), + items: repairs.iter().map(|r| r.destroyed.len()).sum(), + reloads: repairs.iter().map(|r| r.reload.len()).sum(), + gone: repairs.iter().filter(|r| r.gone).count(), + } + } + + pub fn is_nothing(&self) -> bool { + *self == Total::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn design() -> Unit { + Unit { + chassis: "Griffin".into(), + model: "GRF-1N".into(), + format_str: "mtf".into(), + config: Some("Biped".into()), + mass: Some(55.0), + armor_locations: [ + ("CT", 20), + ("RTC", 6), + ("LT", 15), + ("RTL", 5), + ("LA", 11), + ("LL", 18), + ] + .into_iter() + .map(|(l, p)| (l.to_string(), p)) + .collect(), + criticals: [( + "Left Arm".to_string(), + vec![ + "Shoulder".to_string(), + "Upper Arm Actuator".to_string(), + "PPC".to_string(), + "IS Ammo LRM-10".to_string(), + ], + )] + .into_iter() + .collect(), + ..Default::default() + } + } + + fn find<'a>(repairs: &'a [Repair], location: &str) -> &'a Repair { + repairs + .iter() + .find(|r| r.location == location) + .unwrap_or_else(|| panic!("no repair for {location}, got {repairs:?}")) + } + + #[test] + fn a_machine_that_came_back_whole_needs_nothing() { + assert_eq!(repairs(&design(), &Condition::undamaged()), vec![]); + } + + /// Front and rear are separate figures on the sheet and separate work in + /// the bay, so they are never added together here. + #[test] + fn front_and_rear_plate_are_counted_apart() { + let condition = Condition { + armor: [("CT".to_string(), 4), ("RTC".to_string(), 1)] + .into_iter() + .collect(), + ..Condition::undamaged() + }; + let repair = repairs(&design(), &condition); + assert_eq!(find(&repair, "Center Torso").armor, 16); + assert_eq!(find(&repair, "Center Torso").rear_armor, 5); + } + + /// The bug this was written after: the rear key used to be `RT` plus the + /// location's first letter, so a left *arm* read the left *torso*'s rear + /// plate and every damaged torso invented damage on two limbs. + #[test] + fn a_limb_is_never_given_the_torsos_rear_plate() { + let condition = Condition { + armor: [("RTL".to_string(), 0)].into_iter().collect(), + ..Condition::undamaged() + }; + let repair = repairs(&design(), &condition); + assert_eq!(find(&repair, "Left Torso").rear_armor, 5); + assert!( + repair.iter().all(|r| r.location == "Left Torso"), + "{repair:?}" + ); + } + + /// Structure comes from the tonnage table rather than the file, and a + /// location with none left is off the machine rather than damaged. + #[test] + fn a_location_with_no_frame_left_is_gone() { + let condition = Condition { + structure: [("LA".to_string(), 0)].into_iter().collect(), + ..Condition::undamaged() + }; + let repair = repairs(&design(), &condition); + // A 55-tonner's arm is 9 points. + assert_eq!(find(&repair, "Left Arm").structure, 9); + assert!(find(&repair, "Left Arm").gone); + } + + /// A slot names what was in it, because "slot 3" is not a repair order and + /// "PPC" is. + #[test] + fn what_was_shot_out_is_named() { + let condition = Condition { + destroyed: [("Left Arm".to_string(), [2].into())].into_iter().collect(), + empty_ammo: [("Left Arm".to_string(), [3].into())].into_iter().collect(), + ..Condition::undamaged() + }; + let repair = repairs(&design(), &condition); + let arm = find(&repair, "Left Arm"); + assert_eq!( + arm.destroyed, + vec![Slot { + at: 2, + what: "PPC".into() + }] + ); + assert_eq!( + arm.reload, + vec![Slot { + at: 3, + what: "IS Ammo LRM-10".into() + }] + ); + assert!(!arm.gone); + } + + #[test] + fn a_total_adds_the_two_plate_figures_together_and_nothing_else() { + let condition = Condition { + armor: [("CT".to_string(), 4), ("RTC".to_string(), 1)] + .into_iter() + .collect(), + structure: [("LA".to_string(), 0)].into_iter().collect(), + destroyed: [("Left Arm".to_string(), [2].into())].into_iter().collect(), + ..Condition::undamaged() + }; + let total = Total::of(&repairs(&design(), &condition)); + assert_eq!( + total, + Total { + armor: 21, + structure: 9, + items: 1, + reloads: 0, + gone: 1, + } + ); + } +}