diff --git a/crates/helm-bv/src/attribution.rs b/crates/helm-bv/src/attribution.rs new file mode 100644 index 0000000..81a14e4 --- /dev/null +++ b/crates/helm-bv/src/attribution.rs @@ -0,0 +1,454 @@ +//! Where a battle value went. +//! +//! Two questions, and they are the same arithmetic asked twice. "What is this +//! figure made of" is a design and its terms; "what did that hit cost me" is +//! the difference between two conditions, term by term. A player asks the +//! second one - after a match, looking at a machine worth six hundred points +//! less than it was that morning - and the honest answer names the plate, the +//! frame, the weapon and the cooling separately. +//! +//! # Why a difference is not a subtraction +//! +//! The terms do not add up to the answer. Each half is a sum multiplied by a +//! factor, both factors move when the machine is crippled, and the whole thing +//! is scaled and rounded once at the end: +//! +//! ```text +//! total = round(((armour + frame + ... ) x movement +//! + (weapons + ammunition + ...) x fire control x speed) x cockpit) +//! ``` +//! +//! So losing a leg shows up twice - as the armour and structure that went with +//! it, and again as every remaining weapon being worth less because the Mek is +//! slower. Splitting that cleanly needs a convention, and this module's is the +//! one that makes the parts sum to the whole exactly: +//! +//! ```text +//! D2.f2 - D1.f1 = (D2 - D1).f2 + D1.(f2 - f1) +//! ``` +//! +//! An additive term is valued at the factor the machine ended up with, and a +//! factor is valued against the subtotal it started from. Every other split of +//! the same difference leaves a cross term nobody can name. +//! +//! [`Attribution::residual`] carries what that identity cannot: the final +//! rounding, and the floor MegaMek puts under a defensive rating. It is a +//! fraction of a point on any real machine, and it is reported rather than +//! spread over the terms so that a bug in this module cannot hide inside them. + +use helm_core::{Catalogue, Unit}; + +use crate::defensive::Defensive; +use crate::mek::Mek; +use crate::offensive::Offensive; +use crate::{Condition, Unsupported}; + +/// One named part of the calculation. +/// +/// Ordered as the rules work through them - the defensive half, then the +/// offensive half, then what applies to both - because that is the order a +/// person reading a printout expects, and it keeps two attributions of the +/// same machine comparable line for line. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Term { + /// Armour still on the machine, at 2.5 a point through its type. + Armor, + /// The frame underneath it. + Structure, + Gyro, + /// Carried to keep hits off itself: ECM, a shield, CASE. + DefensiveEquipment, + /// Negative. What carrying something that goes off when hit costs. + Explosive, + /// 1 + TMM/10. Moves when the machine is slowed. + MovementFactor, + /// Weapons, halved once the design runs out of heat to fire them with. + Weapons, + /// Carried to hurt somebody without being fired, such as a mine dispenser. + OffensiveEquipment, + Ammo, + /// The machine's own mass, which is what it hits with when it runs out of + /// anything better. + Weight, + /// 0.9 for a machine with no advanced fire control. + FireControl, + /// What the run and the jump are worth to the offensive half. + SpeedFactor, + /// The cockpit modifier and the RISC heat sink kit, which apply to the + /// whole figure rather than to either half. + Scale, +} + +impl Term { + /// A name to print, stable enough to key on. + pub fn label(&self) -> &'static str { + match self { + Term::Armor => "armour", + Term::Structure => "structure", + Term::Gyro => "gyro", + Term::DefensiveEquipment => "defensive equipment", + Term::Explosive => "explosive", + Term::MovementFactor => "movement factor", + Term::Weapons => "weapons", + Term::OffensiveEquipment => "offensive equipment", + Term::Ammo => "ammunition", + Term::Weight => "weight", + Term::FireControl => "fire control", + Term::SpeedFactor => "speed factor", + Term::Scale => "cockpit", + } + } + + /// Whether this term is a multiplier rather than a number of points. + /// + /// Worth knowing before printing one: a movement factor of 1.2 and an + /// armour term of 1.2 are not the same kind of thing. + pub fn is_factor(&self) -> bool { + matches!( + self, + Term::MovementFactor | Term::FireControl | Term::SpeedFactor | Term::Scale + ) + } +} + +/// Every term of one design's battle value, in one condition. +/// +/// The two ratings were always available; this is them and the arithmetic +/// between them, so that a caller can show the whole calculation without +/// reaching for anything private. +#[derive(Debug, Clone, PartialEq)] +pub struct Terms { + pub defensive: Defensive, + pub offensive: Offensive, + /// The cockpit modifier and the RISC kit, multiplied together: they apply + /// at the same place and nothing distinguishes them afterwards. + pub scale: f64, + /// The figure itself, rounded once as the rules round it. + pub battle_value: i64, +} + +impl Terms { + /// What one term is worth in this condition. + pub fn get(&self, term: Term) -> f64 { + match term { + Term::Armor => self.defensive.armor, + Term::Structure => self.defensive.structure, + Term::Gyro => self.defensive.gyro, + Term::DefensiveEquipment => self.defensive.equipment, + Term::Explosive => self.defensive.explosive, + Term::MovementFactor => self.defensive.factor, + Term::Weapons => self.offensive.weapons, + Term::OffensiveEquipment => self.offensive.equipment, + Term::Ammo => self.offensive.ammo, + Term::Weight => self.offensive.weight, + Term::FireControl => self.offensive.fire_control, + Term::SpeedFactor => self.offensive.speed_factor, + Term::Scale => self.scale, + } + } + + /// The defensive terms before the floor and the factor. + fn defensive_sum(&self) -> f64 { + self.defensive.armor + + self.defensive.structure + + self.defensive.gyro + + self.defensive.equipment + + self.defensive.explosive + } + + /// The offensive terms before the fire control multiplier and the factor. + fn offensive_sum(&self) -> f64 { + self.offensive.weapons + + self.offensive.equipment + + self.offensive.ammo + + self.offensive.weight + } +} + +/// Work out every term for a design in the condition it is in. +pub fn terms_in( + unit: &Unit, + catalogue: &Catalogue, + condition: &Condition, +) -> Result { + let mek = crate::readable(unit, catalogue, condition)?; + from_mek(&mek) +} + +/// The same, from a design already read. +pub(crate) fn from_mek(mek: &Mek<'_>) -> Result { + let defensive = crate::defensive::defensive(mek)?; + let offensive = crate::offensive::offensive(mek)?; + // A RISC heat sink override kit is worth a hundredth of the whole design, + // and lands on the total beside the cockpit rather than on either half. + let kit = if mek.has_risc_heat_sink_kit { + 1.01 + } else { + 1.0 + }; + let scale = mek.cockpit_modifier * kit; + // The two ratings are added, the scale has its say on the total rather + // than on either half, and the result is rounded once - which is the last + // place a half point can change an answer. + let battle_value = ((defensive.total + offensive.total) * scale).round() as i64; + Ok(Terms { + defensive, + offensive, + scale, + battle_value, + }) +} + +/// One term, either side of what happened. +#[derive(Debug, Clone, PartialEq)] +pub struct Change { + pub term: Term, + pub before: f64, + pub after: f64, + /// What this term is worth to the difference, in battle value points. + /// Negative is value lost. + pub points: f64, +} + +/// Where the difference between two conditions went. +#[derive(Debug, Clone, PartialEq)] +pub struct Attribution { + pub before: i64, + pub after: i64, + /// Only the terms that moved, worst loss first. A machine that lost + /// nothing attributes to nothing. + pub changes: Vec, + /// The rounding, and MegaMek's floor under a defensive rating. The changes + /// plus this equal `after - before` exactly. + pub residual: f64, +} + +impl Attribution { + /// What the difference is, as the figures a player sees. + pub fn points(&self) -> i64 { + self.after - self.before + } +} + +/// What one machine's condition cost it, term by term. +/// +/// `from` is what it is being compared against - the factory-fresh design for +/// a post-match report, or last turn's condition for a running one. +pub fn attribute( + unit: &Unit, + catalogue: &Catalogue, + from: &Condition, + to: &Condition, +) -> Result { + let before = terms_in(unit, catalogue, from)?; + let after = terms_in(unit, catalogue, to)?; + Ok(between(&before, &after)) +} + +/// The same, for two sets of terms already worked out. +/// +/// Split out because a caller scoring a whole force has both in hand already +/// and reading a design twice more is most of what this costs. +pub fn between(before: &Terms, after: &Terms) -> Attribution { + let mut changes = Vec::new(); + // An additive term is valued at the factor the machine ended up with, and + // a factor against the subtotal it started from. See the module note: it + // is the split that leaves no cross term. + let defensive_scale = after.defensive.factor * after.scale; + let offensive_scale = after.offensive.fire_control * after.offensive.speed_factor * after.scale; + + let mut push = |term: Term, weight: f64| { + let (b, a) = (before.get(term), after.get(term)); + if b != a { + changes.push(Change { + term, + before: b, + after: a, + points: (a - b) * weight, + }); + } + }; + + for term in [ + Term::Armor, + Term::Structure, + Term::Gyro, + Term::DefensiveEquipment, + Term::Explosive, + ] { + push(term, defensive_scale); + } + push(Term::MovementFactor, before.defensive_sum() * after.scale); + for term in [ + Term::Weapons, + Term::OffensiveEquipment, + Term::Ammo, + Term::Weight, + ] { + push(term, offensive_scale); + } + // Both offensive factors are valued against the same starting subtotal, + // so applying one of them first would charge the other for its effect. + // Taken in the order they are applied: fire control, then speed. + push( + Term::FireControl, + before.offensive_sum() * before.offensive.speed_factor * after.scale, + ); + push( + Term::SpeedFactor, + before.offensive_sum() * after.offensive.fire_control * after.scale, + ); + push(Term::Scale, before.defensive.total + before.offensive.total); + + changes.sort_by(|a, b| { + a.points + .partial_cmp(&b.points) + .unwrap_or(std::cmp::Ordering::Equal) + .then(a.term.cmp(&b.term)) + }); + + let sum: f64 = changes.iter().map(|c| c.points).sum(); + Attribution { + before: before.battle_value, + after: after.battle_value, + residual: (after.battle_value - before.battle_value) as f64 - sum, + changes, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Terms with every number distinct, so that a decomposition reading the + /// wrong field is visibly wrong rather than coincidentally right. + fn terms(armor: f64, weapons: f64, movement: f64, speed: f64) -> Terms { + let defensive = Defensive { + armor, + structure: 120.0, + gyro: 50.0, + equipment: 30.0, + explosive: -15.0, + subtotal: armor + 185.0, + factor: movement, + total: (armor + 185.0) * movement, + }; + let offensive = Offensive { + weapons, + equipment: 7.0, + ammo: 44.0, + weight: 100.0, + subtotal: (weapons + 151.0) * 0.9, + fire_control: 0.9, + speed_factor: speed, + total: (weapons + 151.0) * 0.9 * speed, + heat_budget: 20, + heat_used: 25.0, + }; + let scale = 1.0; + Terms { + battle_value: ((defensive.total + offensive.total) * scale).round() as i64, + defensive, + offensive, + scale, + } + } + + /// The property the whole module rests on. If the parts do not sum to the + /// difference, a report built from them is telling somebody a story about + /// a number that is not the one they are looking at. + #[test] + fn the_parts_sum_to_the_difference() { + let before = terms(900.0, 800.0, 1.2, 1.44); + let after = terms(300.0, 640.0, 1.1, 1.0); + let a = between(&before, &after); + let sum: f64 = a.changes.iter().map(|c| c.points).sum(); + assert!( + (sum + a.residual - a.points() as f64).abs() < 1e-9, + "{sum} + {} != {}", + a.residual, + a.points() + ); + // Rounding is the only thing left over, so it cannot be a whole point. + assert!(a.residual.abs() < 1.0, "residual {}", a.residual); + } + + /// Losing nothing attributes to nothing, rather than to a list of zeroes. + #[test] + fn an_untouched_machine_attributes_to_nothing() { + let same = terms(900.0, 800.0, 1.2, 1.44); + let a = between(&same, &same); + assert!(a.changes.is_empty()); + assert_eq!(a.points(), 0); + assert_eq!(a.residual, 0.0); + } + + /// A term that moved on its own is worth exactly its own change through + /// the factors below it, and nothing is credited to anything else. + #[test] + fn plate_alone_is_charged_at_the_movement_factor() { + let before = terms(900.0, 800.0, 1.2, 1.44); + let after = terms(300.0, 800.0, 1.2, 1.44); + let a = between(&before, &after); + assert_eq!(a.changes.len(), 1); + assert_eq!(a.changes[0].term, Term::Armor); + assert!((a.changes[0].points - (-600.0 * 1.2)).abs() < 1e-9); + } + + /// Slowing a machine down costs it on both halves, and the two are not the + /// same term: one is what its plate is worth to it, the other what its + /// weapons are. + #[test] + fn slowing_down_is_charged_to_both_factors_and_to_nothing_else() { + let before = terms(900.0, 800.0, 1.2, 1.44); + let after = terms(900.0, 800.0, 1.1, 1.0); + let a = between(&before, &after); + let terms: Vec = a.changes.iter().map(|c| c.term).collect(); + assert_eq!(terms, vec![Term::SpeedFactor, Term::MovementFactor]); + // Worst loss first, and the speed factor moved further. + assert!(a.changes[0].points < a.changes[1].points); + } + + /// The two offensive factors are applied one after the other, so a split + /// that values both against the same starting point charges one of them + /// for the other's effect. No Mek in the library moves its fire control, + /// so nothing else checks this. + #[test] + fn the_two_offensive_factors_do_not_charge_each_other() { + let before = terms(900.0, 800.0, 1.2, 1.44); + let mut after = terms(900.0, 800.0, 1.2, 1.0); + after.offensive.fire_control = 1.0; + after.offensive.subtotal = after.offensive.weapons + 151.0; + after.offensive.total = after.offensive.subtotal * after.offensive.speed_factor; + after.battle_value = + ((after.defensive.total + after.offensive.total) * after.scale).round() as i64; + + let a = between(&before, &after); + let sum: f64 = a.changes.iter().map(|c| c.points).sum(); + assert!( + (sum + a.residual - a.points() as f64).abs() < 1e-9, + "{sum} + {} != {}", + a.residual, + a.points() + ); + assert!(a.residual.abs() < 1.0, "residual {}", a.residual); + } + + /// Emptying a magazine *raises* a battle value - the rounds stop being + /// worth anything and stop exploding, and the second is worth more than + /// the first. An attribution has to show that as a gain, not as an + /// absolute size. + #[test] + fn a_term_that_gained_is_reported_as_a_gain() { + let mut before = terms(900.0, 800.0, 1.2, 1.44); + before.defensive.explosive = -60.0; + let after = terms(900.0, 800.0, 1.2, 1.44); + let a = between(&before, &after); + let explosive = a + .changes + .iter() + .find(|c| c.term == Term::Explosive) + .expect("the penalty moved"); + assert!(explosive.points > 0.0, "{explosive:?}"); + } +} diff --git a/crates/helm-bv/src/lib.rs b/crates/helm-bv/src/lib.rs index 8635ad7..4890272 100644 --- a/crates/helm-bv/src/lib.rs +++ b/crates/helm-bv/src/lib.rs @@ -32,6 +32,7 @@ //! battle value that is quietly approximate is worse than no battle value, //! because a force list built on it looks fine. +mod attribution; mod clusters; mod conformance; mod defensive; @@ -42,6 +43,7 @@ mod offensive; mod rules; mod structure; +pub use attribution::{Attribution, Change, Term, Terms, attribute, between, terms_in}; pub use clusters::{Cluster, clusters, labels, oddity}; pub use conformance::{Conformance, Mismatch, Tally}; pub use defensive::{Defensive, defensive, tmm}; @@ -261,15 +263,33 @@ pub fn breakdown_in( catalogue: &Catalogue, condition: &Condition, ) -> Result { + let mek = readable(unit, catalogue, condition)?; + let terms = attribution::from_mek(&mek)?; + let mut out = BvBreakdown::new(unit.display_name()); + out.defensive = Some(terms.defensive.total); + out.offensive = Some(terms.offensive.total); + out.battle_value = Some(terms.battle_value); + Ok(out) +} + +/// Read a design, or say why it cannot be scored. +/// +/// The gate every entry point goes through, so that a design declined here is +/// declined by all of them. Both refusals are deliberate rather than a +/// shortfall: a unit type with no calculator, and a LAM, whose battle value is +/// worked out over three movement modes with a heat budget of its own. None of +/// that is written, and a LAM scored as a Mek is wrong rather than +/// approximately right. +fn readable<'a>( + unit: &Unit, + catalogue: &'a Catalogue, + condition: &Condition, +) -> Result, Unsupported> { if !is_mek(unit) { return Err(Unsupported::UnitType( unit.unit_type.clone().unwrap_or_else(|| "unknown".into()), )); } - // A LAM flies, and its battle value is worked out over three movement - // modes with a heat budget of its own. None of that is written, and a LAM - // scored as a Mek is wrong rather than approximately right - so it is - // declined outright, the way an unimplemented armour type is. if unit .config .as_deref() @@ -277,25 +297,7 @@ pub fn breakdown_in( { return Err(Unsupported::NotImplemented("LAM movement modes")); } - let mek = mek::Mek::read_in(unit, catalogue, condition)?; - let mut out = BvBreakdown::new(unit.display_name()); - let defensive = defensive::defensive(&mek)?; - let offensive = offensive::offensive(&mek)?; - out.defensive = Some(defensive.total); - out.offensive = Some(offensive.total); - // The two ratings are added, the cockpit has its say on the total rather - // than on either half, and the result is rounded once - which is the last - // place a half point can change an answer. - // A RISC heat sink override kit is worth a hundredth of the whole design, - // and lands on the total beside the cockpit rather than on either half. - let kit = if mek.has_risc_heat_sink_kit { - 1.01 - } else { - 1.0 - }; - let total = (defensive.total + offensive.total) * mek.cockpit_modifier * kit; - out.battle_value = Some(total.round() as i64); - Ok(out) + mek::Mek::read_in(unit, catalogue, condition) } /// Fill the shared computed type, so that comparing this producer against the diff --git a/crates/helm-bv/src/offensive.rs b/crates/helm-bv/src/offensive.rs index 62297b5..8640382 100644 --- a/crates/helm-bv/src/offensive.rs +++ b/crates/helm-bv/src/offensive.rs @@ -29,6 +29,13 @@ pub struct Offensive { /// anything better. pub weight: f64, pub subtotal: f64, + /// What a design that cannot aim properly keeps: 0.9 without advanced + /// fire control, 1 with it. + /// + /// Recorded rather than folded silently into `subtotal` because it sits + /// between the terms above and the total, so anything reading the terms + /// back out has to know it is there. + pub fire_control: f64, pub speed_factor: f64, pub total: f64, /// Heat it can spend on shooting, and what its weapons make. Reported @@ -73,13 +80,13 @@ pub fn offensive(mek: &Mek<'_>) -> Result { let weight = weight(mek); let equipment = offensive_equipment(mek); - let subtotal = weapons + equipment + ammo + weight; // A Mek that cannot aim properly is worth nine tenths of what it carries. - let subtotal = if mek.has_advanced_fire_control { - subtotal + let fire_control = if mek.has_advanced_fire_control { + 1.0 } else { - subtotal * 0.9 + 0.9 }; + let subtotal = (weapons + equipment + ammo + weight) * fire_control; // Whichever of jumping and swimming carries the design further. let factor = speed_factor(speed_factor_mp(mek.run_mp, mek.jump_mp.max(mek.umu_mp))); Ok(Offensive { @@ -88,6 +95,7 @@ pub fn offensive(mek: &Mek<'_>) -> Result { ammo, weight, subtotal, + fire_control, speed_factor: factor, total: subtotal * factor, heat_budget: budget,