From 6ce156ff40aee10a0758fedd0d6eaf6ca9614d00 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 20 Aug 2026 00:08:24 -0400 Subject: [PATCH] feat(unit-rules): write a worn machine out as a .mul `helm wear ... --write worn.mul` hands over a file MegaMek can load rather than a description on the screen, which is what makes a machine worn down to a scenario's budget usable at all. It is also the first round trip that starts from a `Mul` built in code: eleven designs are worn down, written out, read back and checked to be worth what they were worn to. --- README.md | 3 ++ TODO.md | 8 +++- crates/helm-bv/tests/conformance.rs | 74 +++++++++++++++++++++++++++++ crates/helm-cli/src/main.rs | 33 +++++++++++++ crates/helm-core/src/structure.rs | 10 ++++ 5 files changed, 126 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d34da5b..362fc7e 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,9 @@ into a scenario's budget - damage a design until it is worth a given figure: helm wear "Atlas AS7-D" --to 1500 --megamek --bridge-dir +`--write worn.mul` puts the result in a file MegaMek can load, rather than only +on the screen. + `--battle` does it with damage that has a shape - MegaMek's hit table, fire that clusters, equipment lost when it gets through the plate - so every seed is a different machine worth the same, and none of them is a mission kill: diff --git a/TODO.md b/TODO.md index 136f2ac..005dc8d 100644 --- a/TODO.md +++ b/TODO.md @@ -727,8 +727,12 @@ not obvious from any one of them. - **Deliberately break the guards.** The performance gate was proved to fire by making something slower on purpose; nothing has proved the losslessness guards fail when they should. - - **Construct, write, read.** Every round trip currently starts from a - file. Building a force in code and writing it out is lightly covered. + - [x] **Construct, write, read.** Every round trip started from a file + somebody else wrote. `helm wear ... --write worn.mul` builds a `Mul` in + code and writes it, which is the case a player hits when helm hands + them a damaged machine to load into MegaMek - and a test wears eleven + designs down, writes them out, reads them back and checks each is worth + what it was worn to. - [ ] **Generate more forces from MekBay when a reader changes.** is MegaMek's own web client and its force generator diff --git a/crates/helm-bv/tests/conformance.rs b/crates/helm-bv/tests/conformance.rs index f052687..4240894 100644 --- a/crates/helm-bv/tests/conformance.rs +++ b/crates/helm-bv/tests/conformance.rs @@ -1274,3 +1274,77 @@ fn the_version_the_rules_claim_is_the_one_they_were_measured_against() { helm_bv::CONFORMED_TO_MEGAMEK ); } + +/// A machine worn down here, written out, and read back as the same machine. +/// +/// Every other round trip in this repository starts from a file somebody else +/// wrote. This one starts from a `Mul` built in code - which is the case a +/// player hits when helm hands them a damaged unit to load into MegaMek, and +/// the one nothing covered. +#[test] +#[ignore = "needs a MegaMek install and a bridge dump; set HELM_MEGAMEK and HELM_BRIDGE"] +fn a_design_worn_down_here_reads_back_as_the_same_machine() { + let Some(inputs) = inputs() else { + panic!("set HELM_MEGAMEK to a MegaMek install and HELM_BRIDGE to a bridge dump"); + }; + + let mut checked = 0; + let mut wrong: Vec = Vec::new(); + for unit in inputs.library.units.iter().step_by(400) { + let Ok(whole) = helm_bv::battle_value(unit, &inputs.catalogue) else { + continue; + }; + let Ok(worn) = helm_bv::wear_in_battle( + unit, + &inputs.catalogue, + whole * 3 / 4, + &helm_bv::Battle::default(), + ) else { + continue; + }; + checked += 1; + + let shape = helm_core::Shape::from_config(unit.config.as_deref()); + let mul = helm_unitfile::Mul { + units: vec![helm_unitfile::MulUnit { + chassis: unit.chassis.clone(), + model: unit.model.clone(), + unit_type: Some(shape.megamek_name().to_string()), + gunnery: 4, + piloting: 5, + armor: worn.condition.armor.clone(), + structure: worn.condition.structure.clone(), + destroyed: worn.condition.destroyed.clone(), + empty_ammo: worn.condition.empty_ammo.clone(), + ..Default::default() + }], + ..Default::default() + }; + + let written = helm_unitfile::write_mul(&mul); + let Ok(read) = helm_unitfile::parse_mul(&written) else { + wrong.push(format!("{}: could not be read back", unit.display_name())); + continue; + }; + let Some(entity) = read.machines().next() else { + wrong.push(format!("{}: no machine in the file", unit.display_name())); + continue; + }; + let again = helm_bv::Condition::from(entity); + match helm_bv::battle_value_in(unit, &inputs.catalogue, &again) { + Ok(value) if value == worn.value => {} + other => wrong.push(format!( + "{}: worn to {} and read back as {other:?}", + unit.display_name(), + worn.value + )), + } + } + + println!("{checked} worn designs written out and read back"); + for line in wrong.iter().take(10) { + println!(" {line}"); + } + assert!(wrong.is_empty(), "{} did not survive the trip", wrong.len()); + assert!(checked > 5, "only {checked} designs were tried"); +} diff --git a/crates/helm-cli/src/main.rs b/crates/helm-cli/src/main.rs index fed5181..e362fe2 100644 --- a/crates/helm-cli/src/main.rs +++ b/crates/helm-cli/src/main.rs @@ -104,6 +104,8 @@ WEAR a different machine worth the same, and none of them is a mission kill. --seed One battle in particular. Implies --battle. + --write Write the worn machine out as a .mul, so it can be + loaded into MegaMek rather than only read here. REPAIRS What a force needs putting right, location by location: points of plate, @@ -133,6 +135,7 @@ struct Opts { damaged: bool, why: bool, to: Option, + out_mul: Option, battle: bool, seed: Option, } @@ -151,6 +154,7 @@ fn parse_opts(args: &[String]) -> Result { damaged: false, why: false, to: None, + out_mul: None, battle: false, seed: None, }; @@ -195,6 +199,10 @@ fn parse_opts(args: &[String]) -> Result { o.bench = true; i += 1; } + "--write" => { + o.out_mul = Some(PathBuf::from(need(i)?)); + i += 2; + } "--to" => { o.to = Some( need(i)? @@ -731,6 +739,31 @@ fn wear(args: &[String]) -> Result<(), String> { " {:<16} {} armour, {} structure and {} items gone", "", total.armor, total.structure, total.items ); + + if let Some(path) = &opts.out_mul { + let mul = helm_unitfile::Mul { + units: vec![helm_unitfile::MulUnit { + chassis: unit.chassis.clone(), + model: unit.model.clone(), + unit_type: Some( + helm_core::Shape::from_config(unit.config.as_deref()) + .megamek_name() + .to_string(), + ), + gunnery: 4, + piloting: 5, + armor: worn.condition.armor.clone(), + structure: worn.condition.structure.clone(), + destroyed: worn.condition.destroyed.clone(), + empty_ammo: worn.condition.empty_ammo.clone(), + ..Default::default() + }], + ..Default::default() + }; + std::fs::write(path, helm_unitfile::write_mul(&mul)) + .map_err(|e| format!("{}: {e}", path.display()))?; + println!("\nwrote {}", path.display()); + } Ok(()) } diff --git a/crates/helm-core/src/structure.rs b/crates/helm-core/src/structure.rs index 413e70e..adf31ce 100644 --- a/crates/helm-core/src/structure.rs +++ b/crates/helm-core/src/structure.rs @@ -26,6 +26,16 @@ pub enum Shape { } impl Shape { + /// What MegaMek calls this shape in a `.mul`'s `type` attribute, which is + /// what decides how its location indices are read back. + pub fn megamek_name(&self) -> &'static str { + match self { + Shape::Biped => "Biped", + Shape::Quad => "Quad", + Shape::Tripod => "Tripod", + } + } + /// Read the `Config:` line. The `OmniMek` and `FrankenMek` suffixes say /// how a design was built rather than what shape it is. pub fn from_config(config: Option<&str>) -> Shape { -- 2.51.2