diff --git a/CLAUDE.md b/CLAUDE.md index e7d8bab..6edcf40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,11 @@ open ground with nothing moving. at one hex that drowns the shape of its curve. - Rear-mounted weapons are not in it either. They cannot fire at what the front guns are firing at. +- `crates/helm-facet/tests/firepower.rs` pins it to designs a player would + recognise - an Awesome is worse in your face than at six hexes, a Catapult + outreaches an Atlas - so a change to the model has to argue with a machine + rather than with a number: `HELM_MEGAMEK= HELM_BRIDGE= cargo + test -p helm-facet --test firepower -- --ignored`. ## Feature branch workflow diff --git a/crates/helm-facet/tests/firepower.rs b/crates/helm-facet/tests/firepower.rs new file mode 100644 index 0000000..c63aad6 --- /dev/null +++ b/crates/helm-facet/tests/firepower.rs @@ -0,0 +1,180 @@ +//! What the firepower model says about designs a player would recognise. +//! +//! The model is an opinion - what a design expects to *land* against a gunnery +//! 4 crew on open ground - and an opinion needs pinning to something somebody +//! can check. These are machines with well known loadouts and shapes everybody +//! who has played would predict: an Awesome is worse in your face than at six +//! hexes because a PPC cannot be aimed inside three, an Atlas is a brawler, a +//! Longbow's enormous figure is rockets it fires once. +//! +//! Run it against a real install: +//! +//! HELM_MEGAMEK= HELM_BRIDGE= \ +//! cargo test -p helm-facet --test firepower -- --ignored + +use std::path::{Path, PathBuf}; + +use helm_core::{Catalogue, REACH, Unit}; + +fn library() -> (Vec, Catalogue) { + let (Ok(mm), Ok(bridge)) = (std::env::var("HELM_MEGAMEK"), std::env::var("HELM_BRIDGE")) else { + panic!("set HELM_MEGAMEK and HELM_BRIDGE"); + }; + let catalogue = helm_bridge::read_catalogue(&PathBuf::from(bridge).join("equipment.jsonl")) + .expect("equipment.jsonl"); + let library = helm_unitfile::read_install(Path::new(&mm)).expect("library"); + (library.units, catalogue) +} + +/// The curve for one design, in whole points rather than tenths. +fn curve_of<'a>(units: &'a [Unit], catalogue: &Catalogue, name: &str) -> (Vec, &'a Unit) { + let unit = units + .iter() + .find(|u| u.name == name) + .unwrap_or_else(|| panic!("{name} is not in this library")); + let curve = helm_core::expected_damage_curve(unit, catalogue) + .into_iter() + .map(|tenths| tenths as f64 / 10.0) + .collect(); + (curve, unit) +} + +/// Where a design does its best, as hexes. +fn best_at(curve: &[f64]) -> Vec { + let most = curve.iter().copied().fold(0.0, f64::max); + curve + .iter() + .enumerate() + .filter(|(_, d)| **d > 0.0 && **d >= most - 1.0) + .map(|(at, _)| at + 1) + .collect() +} + +#[test] +#[ignore = "needs a MegaMek install and a bridge dump; set HELM_MEGAMEK and HELM_BRIDGE"] +fn the_shapes_are_the_ones_a_player_would_predict() { + let (units, catalogue) = library(); + let curve = |name: &str| curve_of(&units, &catalogue, name).0; + + // Three PPCs and a small laser. A PPC cannot be aimed inside three hexes, + // so the design is worse in somebody's face than at arm's length - which + // is the thing every player knows about a PPC boat and the thing a plain + // sum of damage cannot say. + let awesome = curve("Awesome AWS-8Q"); + assert!( + awesome[0] < awesome[5], + "an Awesome should be worse at one hex than at six: {awesome:?}" + ); + assert_eq!(best_at(&awesome), [4, 5, 6]); + + // An AC/20, an LRM 20, an SRM 6 and four medium lasers: everything that + // matters is short-ranged, and past nine hexes only the launcher is left. + let atlas = curve("Atlas AS7-D"); + assert_eq!(best_at(&atlas), [3]); + assert!( + atlas[2] > atlas[14] * 5.0, + "an Atlas should be far worse at fifteen hexes: {atlas:?}" + ); + + // Two PPCs and a pile of short-ranged guns. Best in close, and still + // dangerous at range, which is why it is the machine it is. + let warhammer = curve("Warhammer WHM-6R"); + assert_eq!(best_at(&warhammer), [1, 2, 3]); + assert!(warhammer[17] > 0.0, "a Warhammer reaches eighteen hexes"); + + // Two LRM 15s and four medium lasers: it peaks in the middle, where both + // halves reach, and it is still throwing missiles at twenty-one hexes + // where the Atlas has almost nothing left. + let catapult = curve("Catapult CPLT-C1"); + assert_eq!(best_at(&catapult), [6]); + assert!( + catapult[20] > atlas[20], + "a Catapult should outreach an Atlas: {catapult:?}" + ); +} + +#[test] +#[ignore = "needs a MegaMek install and a bridge dump; set HELM_MEGAMEK and HELM_BRIDGE"] +fn what_the_model_leaves_out_stays_left_out() { + let (units, catalogue) = library(); + + // A curve is as long as the board this measures, always. + for name in ["Atlas AS7-D", "Locust LCT-1V", "Awesome AWS-8Q"] { + assert_eq!(curve_of(&units, &catalogue, name).0.len(), REACH as usize); + } + + // Artillery is fired at a map square from off the board, and is not + // firepower. A Bowman 4 carries an Arrow IV and nothing else worth + // counting. + let bowman = curve_of(&units, &catalogue, "Bowman 4").0; + assert!( + bowman.iter().all(|d| *d == 0.0), + "artillery is not firepower: {bowman:?}" + ); + + // And a hauler with no weapons at all is honestly nought rather than + // absent. + let hauler = curve_of(&units, &catalogue, "Buster BC XXI HaulerMech").0; + assert!(hauler.iter().all(|d| *d == 0.0)); +} + +#[test] +#[ignore = "needs a MegaMek install and a bridge dump; set HELM_MEGAMEK and HELM_BRIDGE"] +fn people_are_counted_as_the_people_they_are() { + let (units, catalogue) = library(); + + // A squad fights as the suits it fields. Five Elementals land more than + // four of the same suit, in the same proportion. + let five = helm_core::firepower( + units + .iter() + .find(|u| u.name == "Elemental Battle Armor [Laser](Sqd5)") + .expect("the five-suit Point"), + &catalogue, + ); + let four = helm_core::firepower( + units + .iter() + .find(|u| u.name == "Elemental Battle Armor [Laser](Sqd4)") + .expect("the four-suit squad"), + &catalogue, + ); + assert!(five > four, "{five} should beat {four}"); + assert!( + (five / four - 1.25).abs() < 0.05, + "five suits to four should be five quarters: {five} against {four}" + ); + + // And a platoon fights as the troopers it fields, which is what makes a + // rifle worth counting at all. + let platoon = curve_of(&units, &catalogue, "Foot Platoon (Rifle)").0; + assert!( + platoon[0] > 5.0, + "a rifle platoon lands something: {platoon:?}" + ); + assert!( + platoon.iter().skip(6).all(|d| *d == 0.0), + "a rifle does not reach seven hexes: {platoon:?}" + ); +} + +#[test] +#[ignore = "needs a MegaMek install and a bridge dump; set HELM_MEGAMEK and HELM_BRIDGE"] +fn the_library_is_broadly_armed() { + let (units, catalogue) = library(); + let armed = units + .iter() + .filter(|u| u.is_mek()) + .filter(|u| helm_core::firepower(u, &catalogue) > 0.0) + .count(); + let meks = units.iter().filter(|u| u.is_mek()).count(); + + // 91 of 4,294 Meks expect to land nothing, and every one of them is a + // hauler, a miner, or a machine carrying only what this leaves out. A + // figure that silently stopped scoring a whole family of weapons would + // show up here first. + assert!( + armed * 100 / meks >= 97, + "{armed} of {meks} Meks are armed, which is fewer than expected" + ); +}