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 {