//! What MegaMek works out that no unit file says. //! //! Battle value, C-bill cost and the Alpha Strike conversion are derived from //! a design when MegaMek loads it. They are in no `.mtf` or `.blk`, and //! producing them means applying the construction rules. //! //! This type is named for its shape and not its source, which is the whole //! point of it. Two things fill it: //! //! * the Java bridge, by asking MegaMek — what happens today; //! * `helm-bv`, by computing it — what should happen. //! //! Nothing downstream is allowed to tell which, so `helm-db` never grows a //! battle value dependency. And because both producers fill one type, //! conformance is a diff between two `ComputedStats` rather than a bespoke //! comparison harness. /// A unit's Alpha Strike conversion. /// /// Kept as MegaMek's own notation rather than exploded — `damage` is a string /// like `5/5/2` and `specials` like `AC2/2/-, IF1, SRCH` — because that /// notation is the game's, and parsing it is a job for whoever needs the parts. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct AlphaStrike { pub point_value: Option, pub unit_type: Option, pub size: Option, pub tmm: Option, pub damage: Option, /// Null for 967 units in MegaMek 0.51.0, 887 of them Tanks: /// `ASTurretSummary.getSpecialsDisplayString()` throws on certain turreted /// designs. That is an upstream bug, and it is deviation number one. pub specials: Option, } impl AlphaStrike { pub fn is_empty(&self) -> bool { self == &AlphaStrike::default() } } /// The derived figures for one unit. /// /// Every field is optional: a database built without a producer is a smaller /// answer, not a broken one. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ComputedStats { /// MegaMek's display name, `chassis (clanname) model`. The join key back /// to a [`crate::Unit`]. pub name: String, pub battle_value: Option, pub cost: Option, pub tech_level: Option, pub weight_class: Option, pub canon: Option, pub invalid: Option, pub omni: Option, pub clan: Option, /// What the design walks once everything it carries has had its say, /// which is not the figure its file declares - a shield costs a point. pub walk_mp: Option, /// Derived from walk MP and the engine, so computed rather than declared. pub run_mp: Option, /// What the design jumps, which is its jets and not its `jump mp:` line. pub jump_mp: Option, pub alpha_strike: AlphaStrike, } impl ComputedStats { pub fn new(name: impl Into) -> Self { ComputedStats { name: name.into(), ..Default::default() } } /// Fields where two producers disagree, as `(field, left, right)`. /// /// This is the conformance harness in one method: run the bridge and /// `helm-bv` over the same library, and every non-empty result is either a /// bug of ours or a registered deviation of theirs. Fields the other side /// did not populate are not disagreements — a producer that computes only /// battle value should not read as wrong about cost. pub fn differences(&self, other: &ComputedStats) -> Vec<(&'static str, String, String)> { let mut out = Vec::new(); fn cmp( out: &mut Vec<(&'static str, String, String)>, field: &'static str, a: &Option, b: &Option, ) { if let (Some(a), Some(b)) = (a, b) && a != b { out.push((field, format!("{a:?}"), format!("{b:?}"))); } } cmp( &mut out, "battle_value", &self.battle_value, &other.battle_value, ); cmp(&mut out, "cost", &self.cost, &other.cost); cmp(&mut out, "tech_level", &self.tech_level, &other.tech_level); cmp( &mut out, "weight_class", &self.weight_class, &other.weight_class, ); cmp(&mut out, "canon", &self.canon, &other.canon); cmp(&mut out, "invalid", &self.invalid, &other.invalid); cmp(&mut out, "omni", &self.omni, &other.omni); cmp(&mut out, "clan", &self.clan, &other.clan); cmp(&mut out, "run_mp", &self.run_mp, &other.run_mp); let (a, b) = (&self.alpha_strike, &other.alpha_strike); cmp(&mut out, "as.point_value", &a.point_value, &b.point_value); cmp(&mut out, "as.unit_type", &a.unit_type, &b.unit_type); cmp(&mut out, "as.size", &a.size, &b.size); cmp(&mut out, "as.tmm", &a.tmm, &b.tmm); cmp(&mut out, "as.damage", &a.damage, &b.damage); cmp(&mut out, "as.specials", &a.specials, &b.specials); out } } #[cfg(test)] mod tests { use super::*; #[test] fn identical_stats_do_not_differ() { let mut a = ComputedStats::new("Atlas AS7-D"); a.battle_value = Some(1897); let b = a.clone(); assert!(a.differences(&b).is_empty()); } #[test] fn a_disagreement_names_the_field_and_both_answers() { let mut a = ComputedStats::new("Atlas AS7-D"); a.battle_value = Some(1897); let mut b = ComputedStats::new("Atlas AS7-D"); b.battle_value = Some(1900); let diff = a.differences(&b); assert_eq!(diff.len(), 1); assert_eq!(diff[0].0, "battle_value"); assert_eq!((diff[0].1.as_str(), diff[0].2.as_str()), ("1897", "1900")); } // A producer that computes battle value and nothing else must not read as // wrong about every field it declined to fill. #[test] fn a_field_only_one_side_populated_is_not_a_disagreement() { let mut bridge = ComputedStats::new("Atlas AS7-D"); bridge.battle_value = Some(1897); bridge.cost = Some(9_626_000); let mut ours = ComputedStats::new("Atlas AS7-D"); ours.battle_value = Some(1897); assert!(bridge.differences(&ours).is_empty()); } }