From 2ab0f4a2c8220545a4a18fcea5a43e8bb6bc83b1 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 21 Aug 2026 11:29:02 -0400 Subject: [PATCH] feat(unit-search): carry battle armour and infantry in the index The four helm-bv can price now: 8,456 designs, 3.7MB, 302KB gzipped. Every one is priced. Every Mek and every suit is drawn as itself; 99 have no art and 194 take one of MegaMek's own defaults, which is what MegaMek draws them with. is_battle_armor and is_conventional_infantry join the other two on Unit, so the index and the battle value ask one set of questions. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 25 +++++++------- crates/helm-bv/src/infantry.rs | 4 +-- crates/helm-bv/src/lib.rs | 12 +++---- crates/helm-cli/src/main.rs | 22 ++++++++++--- crates/helm-cli/tests/index.rs | 60 ++++++++++++++++++++++++++++------ crates/helm-core/src/unit.rs | 43 ++++++++++++++++++++++++ 6 files changed, 129 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index be68777..580d2d6 100644 --- a/README.md +++ b/README.md @@ -150,17 +150,20 @@ and sorts on, small enough to fetch while somebody reads the page. helm index --megamek /path/to/megamek --bridge-dir /tmp/helm-bridge --out units.json ``` -5,483 canon, valid designs against 0.51.0 — 4,279 Meks and 1,204 combat -vehicles — 2.3MB of JSON, **229KB gzipped**, each one carrying chassis and -model as two fields, era, tonnage, movement, armour, battle value, cost, the -derived combat metrics, and the sprite that draws it. - -Meks and combat vehicles because those are what a match fields and what this -repository scores. Every one of them is priced and every Mek is drawn; the six -vehicles with no sprite are ones MegaMek has no `mekset.txt` entry for either, -and it draws them with a default silhouette like anything else reading this. -`--all-types` widens it to the whole library — 10,896 designs and 4.8MB, gun -emplacements, buildings and handheld weapons among them. +8,456 canon, valid designs against 0.51.0 — 4,279 Meks, 1,204 combat vehicles, +1,184 battle armour suits and 1,789 infantry platoons — 3.7MB of JSON, **302KB +gzipped**, each one carrying chassis and model as two fields, era, tonnage, +movement, armour, battle value, cost, the derived combat metrics, and the +sprite that draws it. + +Those four because they are what a match fields and what `helm-bv` can price. +The list tracks what can be priced rather than what can be drawn: every design +here has a battle value, and art is allowed to be missing, because a figure a +screen filters on is worth more than a picture. Every Mek and every suit has +art of its own; 99 designs have none and 194 fall back to one of MegaMek's own +`defaults/`, which is what MegaMek draws them with too. `--all-types` widens it +to the whole library — 10,896 designs and 4.8MB, gun emplacements, buildings +and handheld weapons among them. It is a reduction of the same read rather than a second extract with its own truth: the same library, the same producer of computed values, the same diff --git a/crates/helm-bv/src/infantry.rs b/crates/helm-bv/src/infantry.rs index 854b397..8669a08 100644 --- a/crates/helm-bv/src/infantry.rs +++ b/crates/helm-bv/src/infantry.rs @@ -21,9 +21,7 @@ use crate::{Condition, Unsupported}; /// Whether MegaMek scores this design with `InfantryBVCalculator`. pub fn is_infantry(unit: &Unit) -> bool { - unit.unit_type - .as_deref() - .is_some_and(|t| t.eq_ignore_ascii_case("infantry")) + unit.is_conventional_infantry() } /// The anti-Mek skill of a platoon that has no gear for it. diff --git a/crates/helm-bv/src/lib.rs b/crates/helm-bv/src/lib.rs index b229466..07c0750 100644 --- a/crates/helm-bv/src/lib.rs +++ b/crates/helm-bv/src/lib.rs @@ -394,17 +394,13 @@ pub(crate) fn only_a_mek(unit: &Unit) -> Result<(), Unsupported> { )) } -/// Whether this design is a battle armour suit, which MegaMek scores one -/// trooper at a time. fn is_battle_armor(unit: &Unit) -> bool { - unit.unit_type - .as_deref() - .is_some_and(|t| t.eq_ignore_ascii_case("battlearmor")) + unit.is_battle_armor() } -/// Which calculator a design is scored with. Both are `Unit`'s own, because -/// the index has to ask the same questions to decide what it lists and a -/// second reading of `unit_type` is a second set of answers. +/// Which calculator a design is scored with. All of these are `Unit`'s own, +/// because the index has to ask the same questions to decide what it lists +/// and a second reading of `unit_type` is a second set of answers. fn is_mek(unit: &Unit) -> bool { unit.is_mek() } diff --git a/crates/helm-cli/src/main.rs b/crates/helm-cli/src/main.rs index 0a5e8c0..611fce0 100644 --- a/crates/helm-cli/src/main.rs +++ b/crates/helm-cli/src/main.rs @@ -1669,10 +1669,16 @@ fn index(args: &[String]) -> Result<(), String> { eprintln!("no units.jsonl: canon and validity are unknown, so nothing is filtered on them"); } - // What a match can field and this repository can score: Meks and combat - // vehicles. Everything else is behind --all-types, which is 10,896 - // designs and 4.8MB - gun emplacements, buildings and handheld weapons - // among them, none of which a force offers. + // What a match can field and this repository can score: Meks, combat + // vehicles, battle armour and conventional infantry - the four `helm-bv` + // has a calculator for. Everything else is behind --all-types, which is + // 10,896 designs and 4.8MB, gun emplacements, buildings and handheld + // weapons among them, none of which a force offers. + // + // The list tracks what can be priced rather than what can be drawn, so a + // design with no art still gets a row: art is a picture and battle value + // is a fact a screen filters on. 93 platoons have no `mekset.txt` entry, + // and MegaMek draws those with a default silhouette too. // // `Unit`'s own predicates, not a second reading of `unit_type` here: an // `.mtf` declares no type at all, so a filter that trusts the field @@ -1681,7 +1687,13 @@ fn index(args: &[String]) -> Result<(), String> { let mut units: Vec<&helm_core::Unit> = library .units .iter() - .filter(|u| all_types || u.is_mek() || u.is_combat_vehicle()) + .filter(|u| { + all_types + || u.is_mek() + || u.is_combat_vehicle() + || u.is_battle_armor() + || u.is_conventional_infantry() + }) .collect(); units.sort_by(|a, b| a.name.cmp(&b.name).then(a.path.cmp(&b.path))); diff --git a/crates/helm-cli/tests/index.rs b/crates/helm-cli/tests/index.rs index fc6bb1f..c25ab46 100644 --- a/crates/helm-cli/tests/index.rs +++ b/crates/helm-cli/tests/index.rs @@ -84,20 +84,31 @@ fn every_unit_is_priced_and_every_mek_is_drawn() { #[test] #[ignore = "needs a MegaMek install and a bridge dump"] -fn the_index_carries_combat_vehicles() { - // A match fields tanks and helicopters, and a report draws them. Without - // these the whole class falls back to one generic silhouette while its - // own art sits in the same bucket. +fn the_index_carries_everything_a_force_fields() { + // A match fields tanks, helicopters, battle armour and platoons, and a + // report draws all of them. Without these each whole class falls back to + // one generic silhouette while its own art sits in the same bucket. let doc = build_index(); let units = doc["units"].as_array().unwrap(); - let vedette = units - .iter() - .find(|u| u["name"] == "Vedette Medium Tank") - .expect("a Vedette is in the library"); + let named = |name: &str| { + units + .iter() + .find(|u| u["name"] == name) + .unwrap_or_else(|| panic!("{name} is in the library")) + }; + + let vedette = named("Vedette Medium Tank"); assert_eq!(vedette["unit_type"], "Tank"); assert_eq!(vedette["sprite"], "vehicles/Vedette.png"); assert!(vedette["bv"].as_i64().is_some_and(|bv| bv > 0)); + // A suit's squad size is part of its name and part of its picture, so + // this is also the check that the two are not being read apart. + let elemental = named("Elemental Battle Armor [Laser](Sqd5)"); + assert_eq!(elemental["unit_type"], "BattleArmor"); + assert_eq!(elemental["sprite"], "battle armor/Elemental_5-CO-LA.png"); + assert!(elemental["bv"].as_i64().is_some_and(|bv| bv > 0)); + // And nothing else: a gun emplacement, a building and a handheld weapon // are all in the library and none of them is a design a force offers. let kinds: std::collections::BTreeSet<&str> = units @@ -106,8 +117,37 @@ fn the_index_carries_combat_vehicles() { .collect(); assert_eq!( kinds, - ["Mek", "Tank", "VTOL"].into_iter().collect(), - "the index widened past Meks and combat vehicles" + ["BattleArmor", "Infantry", "Mek", "Tank", "VTOL"] + .into_iter() + .collect(), + "the index widened past what helm-bv can price" + ); +} + +#[test] +#[ignore = "needs a MegaMek install and a bridge dump"] +fn every_suit_is_drawn_as_itself() { + // Battle armour is the one class MegaMek names a picture for every + // design of - 1,184 of 1,184 - so a default silhouette appearing here + // means the mekset resolution stopped working rather than that MegaMek + // ran out of art. + let doc = build_index(); + let fallen_back: Vec<&str> = doc["units"] + .as_array() + .unwrap() + .iter() + .filter(|u| u["unit_type"] == "BattleArmor") + .filter(|u| { + u["sprite"].is_null() + || u["sprite"] + .as_str() + .is_some_and(|s| s.starts_with("defaults/")) + }) + .filter_map(|u| u["name"].as_str()) + .collect(); + assert!( + fallen_back.is_empty(), + "no art of its own for {fallen_back:?}" ); } diff --git a/crates/helm-core/src/unit.rs b/crates/helm-core/src/unit.rs index b127367..aa04b3e 100644 --- a/crates/helm-core/src/unit.rs +++ b/crates/helm-core/src/unit.rs @@ -189,6 +189,26 @@ impl Unit { .is_some_and(|t| t.eq_ignore_ascii_case("tank") || t.eq_ignore_ascii_case("vtol")) } + /// Whether this design is a battle armour suit, which MegaMek scores one + /// trooper at a time and multiplies out. + pub fn is_battle_armor(&self) -> bool { + self.unit_type + .as_deref() + .is_some_and(|t| t.eq_ignore_ascii_case("battlearmor")) + } + + /// Whether this design is a conventional infantry platoon. + /// + /// Not battle armour, which declares `BattleArmor` and is a different + /// calculator, a different record sheet and a different silhouette. A + /// platoon is a body of troops: its size, its weapons and its armour are + /// all declared differently from anything wearing a machine. + pub fn is_conventional_infantry(&self) -> bool { + self.unit_type + .as_deref() + .is_some_and(|t| t.eq_ignore_ascii_case("infantry")) + } + /// A battle armour suit's own weight class: 0 exoskeleton or PA(L) /// through 4 assault. `.blk` writes it as ``, and it is not /// the weight class a `MekSummary` reports, which is the unit's. @@ -324,6 +344,29 @@ mod tests { assert!(typed("Mech").is_mek(), "MegaMek's older spelling"); } + /// Battle armour and conventional infantry are two classes, not one. + /// They are scored by different calculators, drawn with different art + /// and read off differently declared files; folding them together would + /// price a platoon as a squad of suits. + #[test] + fn battle_armour_and_infantry_are_not_each_other() { + let typed = |t: &str| Unit { + format_str: "blk".into(), + unit_type: Some(t.into()), + ..Default::default() + }; + assert!(typed("BattleArmor").is_battle_armor()); + assert!(!typed("BattleArmor").is_conventional_infantry()); + assert!(typed("Infantry").is_conventional_infantry()); + assert!(!typed("Infantry").is_battle_armor()); + // Neither is a machine, and no machine is either of them. + for t in ["Mek", "Tank", "VTOL"] { + assert!(!typed(t).is_battle_armor(), "{t}"); + assert!(!typed(t).is_conventional_infantry(), "{t}"); + } + assert!(!typed("BattleArmor").is_mek()); + } + // MekBay writes `Loki (Hellbringer)` where MegaMek writes `Loki` with the // second name held apart, and a force from either has to find the design. #[test] -- 2.51.2