From 6e22bf92f9a217bec35609e1f73f41ba0e4ffce8 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 19 Aug 2026 20:39:57 -0400 Subject: [PATCH] test(unit-rules): nothing is damaged behind armour that held The check that caught the patching bug, kept over the library - and the mission-kill test now asserts what a machine must keep rather than how many guns it may lose. --- TODO.md | 23 +++++- crates/helm-bv/tests/conformance.rs | 120 +++++++++++++++++++++++++++- 2 files changed, 137 insertions(+), 6 deletions(-) diff --git a/TODO.md b/TODO.md index f052309..20f0eee 100644 --- a/TODO.md +++ b/TODO.md @@ -502,10 +502,25 @@ not obvious from any one of them. Nothing it produces is a mission kill. No location is destroyed - the frame stops at one point, so no limb is ever blown off - the head is left alone below its plate, the engine takes at most one hit of the three that - destroy it and the gyro one of the two, magazines are never hit (that is - an explosion and a dead crew without CASE), and the walk stops before the - offensive rating falls below half. 180 battles across the library are - checked against every one of those. + destroy it and the gyro one of the two, and magazines are never hit, that + being an explosion and a dead crew without CASE. 180 battles across the + library are checked against every one of those. + + Guns *do* come off, and should: shooting one off is often the shortest + way down to a figure, and an Atlas that lost its autocannon and kept its + lasers is a more interesting unit than one with evenly thinned plate. + What is refused is the end of that - nothing left to fire, or less than a + quarter of what it could throw. Joints are left alone for the opposite + reason: a shoulder, a hip or an actuator carries no battle value, so + shooting one off would hand somebody a machine much weaker than the + figure they asked for. + + A location is only ever damaged behind plate that was breached. The pass + that puts armour back when a hit overshoots was patching up locations it + had already been shot through - 62 of 1,760 breached locations, a Javelin + with one point of frame left behind eight points of armour. It now skips + any location with a bent frame or a wrecked component, and a test over + the library holds it there. The hits land in the sizes they land in, so the figure steps past the target as often as onto it. The even search's single-point pass finishes diff --git a/crates/helm-bv/tests/conformance.rs b/crates/helm-bv/tests/conformance.rs index 78b3733..83cab3f 100644 --- a/crates/helm-bv/tests/conformance.rs +++ b/crates/helm-bv/tests/conformance.rs @@ -845,6 +845,11 @@ fn wearing_a_design_down_lands_where_it_says() { /// second engine or gyro hit, a magazine hit - each of those is a wreck, a /// dead pilot, or a Mek that cannot stand up. /// +/// Losing a weapon is *not* one of them. A Mek that lost its PPC and kept its +/// lasers is the interesting unit in a lance; what is checked is that it kept +/// something it can fire, and enough of what it could throw to be worth +/// fielding. +/// /// Run over a sample of the library at several seeds each, because the damage /// is drawn: a guard that holds for one battle and not the next is exactly the /// bug this cannot ship with. @@ -938,8 +943,19 @@ fn a_machine_out_of_a_battle_can_still_take_the_field() { let Ok(terms) = helm_bv::terms_in(unit, &inputs.catalogue, &worn.condition) else { continue; }; - if terms.offensive.total < fresh.offensive.total * 0.5 { - wrong.push(say("its guns are gone")); + // Guns come off, and should - that is often the shortest way down + // to a figure. What must not happen is a machine with nothing left + // to fire, or one stripped past being worth taking onto a board. + // A design that never carried a weapon - a HaulerMech - has none + // to keep, and is not a wreck for lacking one. + if fresh.offensive.weapons > 0.0 && terms.offensive.weapons <= 0.0 { + wrong.push(say("it has nothing left that can fire")); + } + if terms.offensive.total < fresh.offensive.total * 0.25 { + wrong.push(say(&format!( + "only {:.0} of {:.0} offence left", + terms.offensive.total, fresh.offensive.total + ))); } // And worth no more than it was: with magazines left alone there // is no lever here that raises a figure. @@ -1014,6 +1030,106 @@ fn every_battle_is_its_own_and_every_seed_repeats() { ); } +/// Nothing is damaged behind armour that was never breached. +/// +/// Damage reaches the frame through plate that is gone, so a location with a +/// bent frame or a wrecked component has to have nothing left on the side the +/// fire came in - the front, or the rear on a torso. A machine that says +/// otherwise is describing an impossibility, and a `.mul` of it would be read +/// back by MegaMek as exactly that. +/// +/// This is the check that caught the pass which puts plate back on when a hit +/// overshoots the target: it was patching up locations it had already been +/// shot through, on 62 of 1,760 breached locations. +#[test] +#[ignore = "needs a MegaMek install and a bridge dump; set HELM_MEGAMEK and HELM_BRIDGE"] +fn nothing_is_damaged_behind_armour_that_held() { + let Some(inputs) = inputs() else { + panic!("set HELM_MEGAMEK to a MegaMek install and HELM_BRIDGE to a bridge dump"); + }; + + let mut breached = 0; + let mut wrong: Vec = Vec::new(); + for unit in inputs.library.units.iter().step_by(60) { + let Ok(whole) = helm_bv::battle_value(unit, &inputs.catalogue) else { + continue; + }; + let Some(frame) = unit.mass.and_then(|tons| { + helm_core::structure_per_location( + tons, + helm_core::Shape::from_config(unit.config.as_deref()), + ) + }) else { + continue; + }; + + for seed in 1..=6 { + let battle = helm_bv::Battle { + seed, + ..helm_bv::Battle::default() + }; + // Seven tenths, which is enough damage to get through the plate on + // most designs without being so much that everything is stripped + // and the check has nothing to say. + let Ok(worn) = + helm_bv::wear_in_battle(unit, &inputs.catalogue, whole * 7 / 10, &battle) + else { + continue; + }; + + for (code, built) in &frame { + let bent = worn + .condition + .structure + .get(*code) + .is_some_and(|left| left < built); + let name = helm_unitfile::locations_for_config(unit.config.as_deref()) + .iter() + .find(|(c, _)| c == code) + .map(|(_, name)| *name) + .unwrap_or(""); + let wrecked = worn + .condition + .destroyed + .get(name) + .is_some_and(|slots| !slots.is_empty()); + if !bent && !wrecked { + continue; + } + breached += 1; + + let front = worn.condition.armor.get(*code).copied().unwrap_or(0); + let rear = match *code { + "CT" => worn.condition.armor.get("RTC").copied(), + "LT" => worn.condition.armor.get("RTL").copied(), + "RT" => worn.condition.armor.get("RTR").copied(), + _ => None, + }; + if front != 0 && rear != Some(0) { + wrong.push(format!( + "{} seed {seed} {code}: {front} front and {rear:?} rear still on it", + unit.display_name() + )); + } + } + } + } + + println!("{breached} breached locations"); + for line in wrong.iter().take(20) { + println!(" {line}"); + } + assert!( + wrong.is_empty(), + "{} locations damaged behind plate that held", + wrong.len() + ); + assert!( + breached > 100, + "only {breached} locations were breached at all" + ); +} + /// Every `.mul` the MegaMek install ships, read, written back, and read again. /// /// The structural round trip is checked in helm-unitfile against files written -- 2.51.2