From 0bb2bf14a1a397e4a880b73ae336a119979cdced Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 21 Aug 2026 09:34:54 -0400 Subject: [PATCH] feat(unit-rules): show a platoon's working in bv-report --unit The per-design view is the diagnostic for a disagreement and it was Mek-shaped: an infantry platoon read as nought tons with no armour and was declined twice over. It now prints the terms a platoon has, reads every bv dump rather than two of them, and records in UPSTREAM.md that MegaMek's summary cache and its own calculator disagree about 343 of them. Change-Id: Idb945c50c351bd1a8fac2248f4ae04a3d86e9f39 --- TODO.md | 7 ++ crates/helm-bv/UPSTREAM.md | 27 ++++++++ crates/helm-bv/src/attribution.rs | 23 ++++++- crates/helm-bv/src/infantry.rs | 21 +++--- crates/helm-bv/src/lib.rs | 8 +-- crates/helm-bv/tests/conformance.rs | 6 +- crates/helm-cli/src/main.rs | 102 ++++++++++++++++++++++------ 7 files changed, 152 insertions(+), 42 deletions(-) diff --git a/TODO.md b/TODO.md index f381eff..8c0c089 100644 --- a/TODO.md +++ b/TODO.md @@ -515,6 +515,13 @@ The long pole, and the reason `helm-core` does no I/O. Tracked as and the total is rounded twice, once after the training bonus and again after the skill. + One thing came out of it that is not this crate's: MegaMek's own + summary cache records a different battle value from its own calculator + for 343 platoons, always by the anti-Mek skill multiplier. `helm build` + takes its `battle_value` column from the summary, so a platoon's row + and `helm bv-report` disagree until that source moves. + `crates/helm-bv/UPSTREAM.md` has the survey. + The last 82 needed two tables MegaMek keeps in code rather than in `data/`: the animals a platoon can ride and the limbs a trooper can have replaced. `bridge/DumpEquipment.java` writes both into `equipment.jsonl` diff --git a/crates/helm-bv/UPSTREAM.md b/crates/helm-bv/UPSTREAM.md index b4d3ef4..7471f96 100644 --- a/crates/helm-bv/UPSTREAM.md +++ b/crates/helm-bv/UPSTREAM.md @@ -256,3 +256,30 @@ equipment list, and reading `basic > 1` everywhere leaves 3 suits scored wrongly. helm reproduces both - `can_ride_a_mek` for the calculator's question and `is_issued_anti_mek_attacks` for the loader's - which is how it agrees with MegaMek on all 1,188 suits and on all 1,188 equipment lists at once. + +## The summary cache's battle value for infantry is not the calculator's + +`MekSummary.getBV()` and `BVCalculator.calculateBV(false, false)` agree on +every Mek in the library and disagree on 343 of the 1,792 conventional +infantry platoons. The difference is always the same one: the summary carries +the base figure and the calculator applies the anti-Mek skill on top of it. + +Foot Platoon (Rifle) is the smallest case. The calculator's own report: + +``` + Defensive BR + Offensive BR: 42 + 48.082 = 90.082 + --- Base Unit BV: 90 + Pilot Modifier: 90 x 0.85 (Skill) = 76.5 + --- Adjusted BV: 76.5, rn = 77 +``` + +`units.jsonl` records 90 for the same design. The 0.85 is +`ConvInfantry.ANTI_MEK_SKILL_NO_GEAR`, which is 8: a platoon that can climb a +Mek but carries no `AntiMekGear` is scored as untrained at it. It is a +property of the design rather than of a crew - every platoon in the library +is crewed by the regular gunnery 4 - so both answers are about the same stock +platoon, and they differ by 15%. + +Surveyed over the whole library: 4,295 Meks, none differing; 1,792 platoons, +343 differing, and each of those 343 by exactly the skill multiplier. helm +follows the calculator, which is the figure MegaMek plays with. diff --git a/crates/helm-bv/src/attribution.rs b/crates/helm-bv/src/attribution.rs index 3c8690e..8fd806a 100644 --- a/crates/helm-bv/src/attribution.rs +++ b/crates/helm-bv/src/attribution.rs @@ -123,10 +123,23 @@ pub struct Terms { /// 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. + /// A second multiplier, applied to the *rounded* base rather than to the + /// sum. 1.0 for everything except a conventional infantry platoon, whose + /// own skill at climbing a Mek is fixed by the design and lands after the + /// figure has already been rounded once. + pub skill: f64, + /// The figure itself, rounded as the rules round it. pub battle_value: i64, } +impl Terms { + /// The figure before [`Terms::skill`] has had its say, which is what a + /// rounding rule is checked against. + pub fn base(&self) -> i64 { + helm_core::round_half_up((self.defensive.total + self.offensive.total) * self.scale) + } +} + impl Terms { /// What one term is worth in this condition. pub fn get(&self, term: Term) -> f64 { @@ -171,6 +184,12 @@ pub fn terms_in( catalogue: &Catalogue, condition: &Condition, ) -> Result { + // A platoon is scored on its own and joins at the same terms, so anything + // reading the working - `helm bv-report --unit`, the attribution - gets + // one answer for both rather than a refusal for half the library. + if crate::infantry::is_infantry(unit) { + return crate::infantry::breakdown(unit, catalogue, condition); + } let machine = crate::readable(unit, catalogue, condition)?; from_mek(&machine) } @@ -195,6 +214,7 @@ pub(crate) fn from_mek(machine: &Machine<'_>) -> Result { defensive, offensive, scale, + skill: 1.0, battle_value, }) } @@ -351,6 +371,7 @@ mod tests { defensive, offensive, scale, + skill: 1.0, } } diff --git a/crates/helm-bv/src/infantry.rs b/crates/helm-bv/src/infantry.rs index cca9f24..854b397 100644 --- a/crates/helm-bv/src/infantry.rs +++ b/crates/helm-bv/src/infantry.rs @@ -326,17 +326,16 @@ impl<'a> Platoon<'a> { } // And a replaced limb, which is worth its own rate per trooper for // each one carried. - let prosthetic: f64 = self - .prosthetics - .iter() - .map(|(p, count)| p.damage_per_trooper * *count as f64) - .sum(); + let prosthetic = self.prosthetics.iter().fold(0.0, |total, (p, count)| { + total + p.damage_per_trooper * *count as f64 + }); weapons += self.troopers as f64 * prosthetic; - let guns: f64 = self + // Folded from a positive zero rather than summed: Rust's `Sum` folds + // from negative zero, and a platoon with no guns prints `-0.0`. + let guns = self .field_guns .iter() - .map(|e| e.battle_value.unwrap_or(0.0)) - .sum(); + .fold(0.0, |total, e| total + e.battle_value.unwrap_or(0.0)); let ammo = self.ammo(guns); let subtotal = weapons + guns + ammo; @@ -377,11 +376,10 @@ impl<'a> Platoon<'a> { /// chassis caps it: a magazine cannot be worth more than the gun it /// feeds. fn ammo(&self, guns: f64) -> f64 { - let total: f64 = self + let total = self .field_ammo .iter() - .map(|e| e.battle_value.unwrap_or(0.0)) - .sum(); + .fold(0.0, |total, e| total + e.battle_value.unwrap_or(0.0)); total.min(guns.max(0.0)) } @@ -468,6 +466,7 @@ impl<'a> Platoon<'a> { defensive, offensive, scale, + skill, battle_value, } } diff --git a/crates/helm-bv/src/lib.rs b/crates/helm-bv/src/lib.rs index d5441a2..b229466 100644 --- a/crates/helm-bv/src/lib.rs +++ b/crates/helm-bv/src/lib.rs @@ -326,13 +326,7 @@ pub fn breakdown_in( catalogue: &Catalogue, condition: &Condition, ) -> Result { - // A platoon shares none of the machinery below: no armour, no slots, no - // heat. It is scored on its own and joins here, with the same terms. - let terms = if infantry::is_infantry(unit) { - infantry::breakdown(unit, catalogue, condition)? - } else { - attribution::from_mek(&readable(unit, catalogue, condition)?)? - }; + let terms = attribution::terms_in(unit, catalogue, condition)?; let mut out = BvBreakdown::new(unit.display_name()); out.defensive = Some(terms.defensive.total); out.offensive = Some(terms.offensive.total); diff --git a/crates/helm-bv/tests/conformance.rs b/crates/helm-bv/tests/conformance.rs index 338abe6..114ed9c 100644 --- a/crates/helm-bv/tests/conformance.rs +++ b/crates/helm-bv/tests/conformance.rs @@ -1690,10 +1690,12 @@ fn the_designs_a_rounding_rule_decides_are_decided_the_way_megamek_decides_them( on_a_half += 1; } - // Half up, which is the rule and is what MegaMek does. + // Half up, which is the rule and is what MegaMek does. A platoon's + // own skill lands on the rounded figure rather than on the sum, so + // it is applied here rather than folded into `exact`. assert_eq!( terms.battle_value, - helm_core::round_half_up(exact), + helm_core::round_half_up(helm_core::round_half_up(exact) as f64 * terms.skill), "{} rounds against its own rule", unit.display_name() ); diff --git a/crates/helm-cli/src/main.rs b/crates/helm-cli/src/main.rs index e0ac9f3..0a5e8c0 100644 --- a/crates/helm-cli/src/main.rs +++ b/crates/helm-cli/src/main.rs @@ -450,11 +450,21 @@ fn bv_report(args: &[String]) -> Result<(), String> { }) .collect(); // The bv dumps carry the working of MegaMek's calculation and not only its - // answer, so each rating can be checked on its own - Meks in one file and - // vehicles in the other. A bridge dump made before they existed still - // checks the total. + // answer, so each rating can be checked on its own - one file per + // calculator. A bridge dump made before they existed still checks the + // total. + // + // They also carry a better total than the summary does: MegaMek's own + // summary cache records the base figure for a platoon whose anti-Mek + // skill moves it, and the calculator records what it plays with. See + // `crates/helm-bv/UPSTREAM.md`. let mut found = false; - for file in ["bv.jsonl", "bv-vehicle.jsonl"] { + for file in [ + "bv.jsonl", + "bv-vehicle.jsonl", + "bv-ba.jsonl", + "bv-infantry.jsonl", + ] { let path = bridge.join(file); if !path.is_file() { continue; @@ -551,6 +561,51 @@ fn one_unit( let name = unit.display_name(); println!("{name}"); + + // A platoon has no tonnage, no engine, no armour, no slots and no heat, + // so neither the header line nor the Mek-shaped view below has anything + // to say about one. Its own terms do. + if unit + .unit_type + .as_deref() + .is_some_and(|t| t.eq_ignore_ascii_case("infantry")) + { + let field = |key: &str| { + unit.fields + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(key)) + .map(|(_, v)| v.trim()) + .unwrap_or("?") + }; + println!( + " {} squads of {}, {}", + field("squadn"), + field("squad_size"), + unit.motion_type.as_deref().unwrap_or("no motion type"), + ); + match helm_bv::terms_in(unit, catalogue, &helm_bv::Condition::undamaged()) { + Ok(t) => { + println!("\ndefensive"); + println!(" troopers {:>10.1}", t.defensive.structure); + println!(" x factor {:>10.2}", t.defensive.factor); + println!(" = {:>22.1}", t.defensive.total); + println!("\noffensive"); + println!(" weapons {:>10.1}", t.offensive.weapons); + println!(" field guns {:>10.1}", t.offensive.weight); + println!(" ammo {:>10.1}", t.offensive.ammo); + println!(" x speed {:>10.2}", t.offensive.speed_factor); + println!(" = {:>22.1}", t.offensive.total); + println!("\n x type {:>10.2}", t.scale); + println!(" base {:>10}", t.base()); + println!(" x skill {:>10.2}", t.skill); + println!(" battle value {:>10}", t.battle_value); + } + Err(e) => println!("\nnot scored: {e}"), + } + print_megamek(megamek, &name); + return Ok(()); + } + println!( " {} tons, {} walk, {} jump, {}, {}", unit.mass.unwrap_or_default(), @@ -612,26 +667,31 @@ fn one_unit( } } - if let Some(theirs) = megamek.get(&name) { - println!("\nmegamek"); - println!( - " defensive {:>10.1}", - theirs.defensive.unwrap_or_default() - ); - println!( - " offensive {:>10.1}", - theirs.offensive.unwrap_or_default() - ); - println!( - " battle value {:>10}", - theirs.battle_value.unwrap_or_default() - ); - } else { - println!("\nmegamek has no record for this design"); - } + print_megamek(megamek, &name); Ok(()) } +/// What MegaMek makes of the same design, or that it has never seen it. +fn print_megamek(megamek: &std::collections::BTreeMap, name: &str) { + let Some(theirs) = megamek.get(name) else { + println!("\nmegamek has no record for this design"); + return; + }; + println!("\nmegamek"); + println!( + " defensive {:>10.1}", + theirs.defensive.unwrap_or_default() + ); + println!( + " offensive {:>10.1}", + theirs.offensive.unwrap_or_default() + ); + println!( + " battle value {:>10}", + theirs.battle_value.unwrap_or_default() + ); +} + /// Say what is wrong with a `.mul`, and exit non-zero if anything is. /// /// The file a player uploads is the one thing here that nobody in this -- 2.51.2