diff --git a/TODO.md b/TODO.md
index 112ec1e..f381eff 100644
--- a/TODO.md
+++ b/TODO.md
@@ -502,11 +502,10 @@ The long pole, and the reason `helm-core` does no I/O. Tracked as
worth two hexes to a light suit and one to a heavy one, and a bin of
rounds belongs to the launcher with its own rack size. Written up in
`crates/helm-bv/UPSTREAM.md`.
-- [x] **Infantry.** 1,792 designs. 1,710 of the 1,711 that are not
- beast-mounted agree with MegaMek. A platoon shares nothing with the rest
- of the crate - no armour points, no slots, no heat - so
- `crates/helm-bv/src/infantry.rs` is its own calculation joined at
- `breakdown_in`.
+- [x] **Infantry.** All 1,792 designs agree with MegaMek. A platoon shares
+ nothing with the rest of the crate - no armour points, no slots, no
+ heat - so `crates/helm-bv/src/infantry.rs` is its own calculation joined
+ at `breakdown_in`.
The rules that were not obvious: a platoon that can climb a Mek counts
its weapons twice; one that can climb without the gear for it is
@@ -516,10 +515,12 @@ The long pole, and the reason `helm-core` does no I/O. Tracked as
and the total is rounded twice, once after the training bonus and again
after the skill.
- Still declined: the 81 beast-mounted platoons and the one squad with
- prosthetic enhancements. Both need tables MegaMek keeps in code rather
- than in a data file - `InfantryMount` and `ProstheticEnhancementType` -
- so both want a bridge dump of their own.
+ The last 82 needed two tables MegaMek keeps in code rather than in
+ `data/`: the animals a platoon can ride and the limbs a trooper can have
+ replaced. `bridge/DumpEquipment.java` writes both into `equipment.jsonl`
+ beside the equipment, because that file is already built, trimmed and
+ shipped to a browser and a second one would have to be threaded through
+ all of it.
- [ ] **Support vehicles.** 225 designs, declined by type. They use the same
calculator, but a support vehicle has a BAR rating on its armour, skips
the amphibious modifiers, and has a chassis-and-fuel weight model of its
diff --git a/bridge/DumpEquipment.java b/bridge/DumpEquipment.java
index 90dcbeb..cfac02c 100644
--- a/bridge/DumpEquipment.java
+++ b/bridge/DumpEquipment.java
@@ -133,6 +133,7 @@ public final class DumpEquipment {
System.err.println("skipped equipment: " + t);
}
}
+ written += extras(out);
}
System.out.printf("dumped %d equipment types (%d skipped, %d unrenderable fields) -> %s%n",
@@ -140,6 +141,75 @@ public final class DumpEquipment {
System.exit(0);
}
+ /**
+ * Two tables MegaMek keeps in code rather than in `data/`, written into
+ * the same file as the equipment.
+ *
+ *
Neither is an EquipmentType, and both are needed to score a
+ * conventional infantry platoon: an animal carries the platoon and lends
+ * it the animal's own protection, and a prosthetic limb changes what a
+ * trooper does climbing a Mek. A `.blk` names them and says nothing about
+ * them - `motion_type: Beast:Camel` is the whole of it - so the figures
+ * have to be read off the shelf here.
+ *
+ *
They share the equipment file because they share its whole
+ * pipeline: one artifact is built, trimmed and shipped to a browser, and
+ * a second one would have to be threaded through all of that to be worth
+ * the same. `_class` tells them apart on the way back in.
+ */
+ private static int extras(PrintWriter out) {
+ int written = 0;
+ for (megamek.common.units.InfantryMount mount
+ : megamek.common.units.InfantryMount.sampleMounts) {
+ // Keyed the way a `.blk` names it, so nothing has to know the
+ // prefix on the way back in.
+ out.println(record(mount, "InfantryMount", "Beast:" + mount.name(), mount.name()));
+ written++;
+ }
+ for (megamek.common.enums.ProstheticEnhancementType type
+ : megamek.common.enums.ProstheticEnhancementType.values()) {
+ out.println(record(type, "ProstheticEnhancementType", type.name(),
+ type.getDisplayName()));
+ written++;
+ }
+ return written;
+ }
+
+ /** One non-equipment record, with its own fields walked the same way. */
+ private static String record(Object value, String className, String key, String name) {
+ StringBuilder sb = new StringBuilder(512);
+ sb.append('{');
+ str(sb, "_class");
+ sb.append(':');
+ str(sb, className);
+ sb.append(',');
+ str(sb, "_internalName");
+ sb.append(':');
+ str(sb, key);
+ sb.append(',');
+ str(sb, "_name");
+ sb.append(':');
+ str(sb, name);
+ for (Class> c = value.getClass(); c != null && c != Object.class; c = c.getSuperclass()) {
+ for (Field f : c.getDeclaredFields()) {
+ if (Modifier.isStatic(f.getModifiers())) {
+ continue;
+ }
+ try {
+ f.setAccessible(true);
+ sb.append(',');
+ str(sb, f.getName());
+ sb.append(':');
+ val(sb, f.get(value), 0);
+ } catch (Throwable t) {
+ // inaccessible field, nothing to record
+ }
+ }
+ }
+ sb.append('}');
+ return sb.toString();
+ }
+
private static Object callName(EquipmentType et, boolean display) {
try {
return display ? et.getName() : et.getInternalName();
diff --git a/crates/helm-bridge/src/lib.rs b/crates/helm-bridge/src/lib.rs
index 6225def..b5780e3 100644
--- a/crates/helm-bridge/src/lib.rs
+++ b/crates/helm-bridge/src/lib.rs
@@ -19,7 +19,9 @@
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
-use helm_core::{AlphaStrike, BvBreakdown, Catalogue, ComputedStats, EquipmentEntry};
+use helm_core::{
+ AlphaStrike, BvBreakdown, Catalogue, ComputedStats, EquipmentEntry, InfantryMount, Prosthetic,
+};
use helm_force::{Blueprint, Grouping, Requirement, Rule};
use serde_json::Value;
@@ -190,8 +192,16 @@ pub const CATALOGUE_FIELDS: &[&str] = &[
"shots",
"damageShort",
// An infantry armour kit's, which is what a platoon has instead of
- // armour points.
+ // armour points. Also an animal's, which multiplies the kit's.
"damageDivisor",
+ // The two rows that are not equipment: an animal a platoon rides and a
+ // limb a trooper has had replaced.
+ // `_name` carries what each is called, and both are already kept.
+ "antiMekModifier",
+ "damagePerTrooper",
+ "movementMode",
+ "movementPoints",
+ "secondaryGroundMP",
"explosive",
"heat",
"longRange",
@@ -293,12 +303,52 @@ pub fn read_catalogue(path: &Path) -> Result {
/// no file to read: this is the half of the crate that has to reach wasm.
pub fn parse_catalogue(text: &str) -> Result {
let mut entries = Vec::new();
+ let mut mounts = Vec::new();
+ let mut prosthetics = Vec::new();
for (_, v) in parse_objects(text)? {
- if let Some(e) = equipment_record(&v) {
- entries.push(e);
+ // Two of the rows are not equipment at all. They ride in this file
+ // because they share its pipeline - built, trimmed and shipped to a
+ // browser once - and `_class` tells them apart.
+ match s(&v, "_class").as_deref() {
+ Some("InfantryMount") => {
+ if let Some(m) = mount_record(&v) {
+ mounts.push(m);
+ }
+ }
+ Some("ProstheticEnhancementType") => {
+ if let Some(p) = prosthetic_record(&v) {
+ prosthetics.push(p);
+ }
+ }
+ _ => {
+ if let Some(e) = equipment_record(&v) {
+ entries.push(e);
+ }
+ }
}
}
- Ok(Catalogue::new(entries))
+ Ok(Catalogue::with_infantry(entries, mounts, prosthetics))
+}
+
+fn mount_record(v: &Value) -> Option {
+ Some(InfantryMount {
+ name: s(v, "name").or_else(|| s(v, "_name"))?,
+ movement_mode: s(v, "movementMode").unwrap_or_default(),
+ movement_points: i(v, "movementPoints").unwrap_or(0),
+ secondary_ground_mp: i(v, "secondaryGroundMP").unwrap_or(0),
+ damage_divisor: f(v, "damageDivisor").unwrap_or(1.0),
+ })
+}
+
+fn prosthetic_record(v: &Value) -> Option {
+ Some(Prosthetic {
+ key: s(v, "_internalName")?,
+ name: s(v, "displayName")
+ .or_else(|| s(v, "_name"))
+ .unwrap_or_default(),
+ damage_per_trooper: f(v, "damagePerTrooper").unwrap_or(0.0),
+ anti_mek_modifier: i(v, "antiMekModifier").unwrap_or(0),
+ })
}
fn objects(path: &Path) -> Result, Error> {
diff --git a/crates/helm-bv/conformance.txt b/crates/helm-bv/conformance.txt
index e781ebe..e633a19 100644
--- a/crates/helm-bv/conformance.txt
+++ b/crates/helm-bv/conformance.txt
@@ -4,9 +4,9 @@ megamek 0.51.0
10988 designs with a MegaMek answer, 8490 of a type this scores
- battle value 8365 match 12 differ 0 not computed 98.5%
- defensive rating 8372 match 5 differ 0 not computed 98.6%
- offensive rating 8369 match 8 differ 0 not computed 98.6%
+ battle value 8447 match 11 differ 0 not computed 99.5%
+ defensive rating 8453 match 5 differ 0 not computed 99.6%
+ offensive rating 8451 match 7 differ 0 not computed 99.5%
out of scope, by unit type (2498 designs)
502 AeroSpaceFighter
@@ -27,7 +27,6 @@ out of scope, by unit type (2498 designs)
not scored at all, by reason
23 not implemented: LAM movement modes
- 81 not implemented: beast-mounted infantry
5 not implemented: this armour type
4 not implemented: this structure type
@@ -48,8 +47,6 @@ known deviations, still counted above
worst disagreements: battle value
1 off by 1 or less
10 within 5%
- 1 within 20%
- -7.4% Manei Domini Attack Squad 'Tau Zombie' ours 150.0 megamek 162.0
-5.0% Uni ATAE-70 ArtilleryMech ours 822.0 megamek 865.0
+3.4% Jabberwocky JAW-67 MilitiaMech ours 938.0 megamek 907.0
+3.2% Super-Griffin GRF-2N-X ours 1310.0 megamek 1269.0
@@ -74,10 +71,9 @@ worst disagreements: defensive rating
worst disagreements: offensive rating
1 off by 1 or less
2 within 5%
- 4 within 20%
+ 3 within 20%
1 further out
-26.7% Uni ATAE-70 ArtilleryMech ours 118.0 megamek 161.0
- -9.1% Manei Domini Attack Squad 'Tau Zombie' ours 87.2 megamek 95.9
+7.5% Jabberwocky JAW-67 MilitiaMech ours 443.2 megamek 412.4
+7.3% Super-Griffin GRF-2N-X ours 604.5 megamek 563.2
+6.9% UrbanKnight UM-DKX ours 240.4 megamek 224.9
diff --git a/crates/helm-bv/src/infantry.rs b/crates/helm-bv/src/infantry.rs
index 2e70112..cca9f24 100644
--- a/crates/helm-bv/src/infantry.rs
+++ b/crates/helm-bv/src/infantry.rs
@@ -74,6 +74,8 @@ struct Platoon<'a> {
/// platoon and nothing else in the library. MegaMek's own option names,
/// as the file lists them.
augmentation: Vec,
+ /// Replaced limbs, and how many of each a trooper carries.
+ prosthetics: Vec<(&'a helm_core::Prosthetic, i64)>,
}
/// Score a platoon, or say what stopped us.
@@ -149,6 +151,11 @@ impl<'a> Platoon<'a> {
if augmented("dermal_armor") {
damage_divisor += 1.0;
}
+ // An animal's hide multiplies rather than adds: a hipposaur carries
+ // its riders' kit to four times what it was worth on foot.
+ if let Some(mount) = catalogue.mount(unit.motion_type.as_deref().unwrap_or_default()) {
+ damage_divisor *= mount.damage_divisor;
+ }
let encumbering = kit_encumbers || flag("encumberingarmor");
let mut field_guns = Vec::new();
@@ -182,10 +189,11 @@ impl<'a> Platoon<'a> {
0
};
- let mode = Movement::read(unit)?;
+ let mode = Movement::read(unit, catalogue)?;
// Muscle grown or wired in carries the support weapon without
- // slowing anybody down.
- let carries_easily = augmented("tsm_implant") || augmented("dermal_armor");
+ // slowing anybody down, and so does an animal.
+ let carries_easily =
+ augmented("tsm_implant") || augmented("dermal_armor") || mode.mount.is_some();
let moves = mode.distances(
encumbering,
if carries_easily { 0 } else { per_squad },
@@ -221,6 +229,14 @@ impl<'a> Platoon<'a> {
.split_whitespace()
.map(str::to_string)
.collect(),
+ prosthetics: [1, 2]
+ .into_iter()
+ .filter_map(|n| {
+ let named = field(&format!("prostheticEnhancement{n}"))?;
+ let count = number(&format!("prostheticEnhancement{n}Count")).unwrap_or(0);
+ Some((catalogue.prosthetic(&named)?, count))
+ })
+ .collect(),
})
}
@@ -281,7 +297,19 @@ impl<'a> Platoon<'a> {
// archaic weapon - a sword, a spear - is no use at either, so it is
// left out of the second count.
if self.anti_mek {
- weapons += self.anti_mek_arms();
+ let climbing = self.anti_mek_arms();
+ weapons += climbing;
+ // A hand built for gripping is worth a fifth of what the platoon
+ // does on the way up. Any prosthetic that helps at all is worth
+ // the same fifth - MegaMek reads the best one and pays a flat
+ // rate for it.
+ if self
+ .prosthetics
+ .iter()
+ .any(|(p, _)| p.anti_mek_modifier != 0)
+ {
+ weapons += climbing * 0.2;
+ }
}
// What has been done to the troopers, each worth a flat amount a
// trooper: wired muscle for what it does to a rifle butt, a toxin
@@ -296,6 +324,14 @@ impl<'a> Platoon<'a> {
weapons += self.troopers as f64 * each;
}
}
+ // And a replaced limb, which is worth its own rate per trooper for
+ // each one carried.
+ let prosthetic: f64 = self
+ .prosthetics
+ .iter()
+ .map(|(p, count)| p.damage_per_trooper * *count as f64)
+ .sum();
+ weapons += self.troopers as f64 * prosthetic;
let guns: f64 = self
.field_guns
.iter()
@@ -458,7 +494,7 @@ struct Distances {
/// walks one hex, every tracked platoon three, every jump platoon three under
/// its packs. What varies is what the platoon is carrying, which comes off
/// those figures rather than being written down.
-struct Movement {
+struct Movement<'a> {
walk: i64,
jump: i64,
umu: i64,
@@ -466,18 +502,24 @@ struct Movement {
vtol: bool,
submarine: bool,
mechanized: bool,
+ /// The animal the platoon rides, where it rides one. A mounted platoon
+ /// moves at the animal's pace and is not slowed by anything it carries.
+ mount: Option<&'a helm_core::InfantryMount>,
}
-impl Movement {
- fn read(unit: &Unit) -> Result {
+impl<'a> Movement<'a> {
+ fn read(unit: &Unit, catalogue: &'a Catalogue) -> Result {
let mode = unit
.motion_type
.as_deref()
.map(str::trim)
.ok_or(Unsupported::Missing("motion_type"))?;
+ // An animal replaces the platoon's own movement with its own, in its
+ // own mode: a branth flies, a hipposaur swims and walks a hex.
+ if let Some(mount) = catalogue.mount(mode) {
+ return Ok(Self::mounted(mount));
+ }
let lower = mode.to_ascii_lowercase();
- // A platoon riding an animal moves at the animal's pace and carries
- // the animal's own protection, and neither is in the file.
if lower.starts_with("beast") {
return Err(Unsupported::NotImplemented("beast-mounted infantry"));
}
@@ -511,9 +553,34 @@ impl Movement {
lower.as_str(),
"tracked" | "wheeled" | "hover" | "vtol" | "microcopter" | "submarine"
),
+ mount: None,
})
}
+ /// The same, for a platoon carried by an animal.
+ fn mounted(mount: &'a helm_core::InfantryMount) -> Self {
+ let swims = mount.movement_mode.eq_ignore_ascii_case("submarine");
+ let flies = mount.movement_mode.eq_ignore_ascii_case("vtol");
+ Movement {
+ walk: if swims {
+ mount.secondary_ground_mp
+ } else if flies {
+ 0
+ } else {
+ mount.movement_points
+ },
+ jump: if flies { mount.movement_points } else { 0 },
+ umu: if swims { mount.movement_points } else { 0 },
+ tracked: false,
+ vtol: flies,
+ submarine: swims,
+ // A rider is off the ground rather than on his own feet, which is
+ // the same reason a hovercraft's passengers cannot climb a Mek.
+ mechanized: swims || flies,
+ mount: Some(mount),
+ }
+ }
+
/// What the platoon actually covers, after what it is carrying.
///
/// Infantry has no separate running distance: a platoon covers the same
@@ -596,6 +663,7 @@ mod tests {
anti_mek_gear: false,
specializations: 0,
augmentation: Vec::new(),
+ prosthetics: Vec::new(),
}
}
@@ -697,8 +765,48 @@ mod tests {
vtol: false,
submarine: false,
mechanized: true,
+ mount: None,
};
assert_eq!(mode.distances(false, 0, None, &[]).run, 4);
assert_eq!(mode.distances(false, 0, None, &[&entry]).run, 1);
}
+
+ #[test]
+ fn an_animal_replaces_the_platoons_own_movement_with_its_own() {
+ let hipposaur = helm_core::InfantryMount {
+ name: "Hipposaur".into(),
+ movement_mode: "Submarine".into(),
+ movement_points: 2,
+ secondary_ground_mp: 1,
+ damage_divisor: 4.0,
+ };
+ let mode = Movement::mounted(&hipposaur);
+ // It swims two and walks one, and nothing the riders carry slows it.
+ let d = mode.distances(true, 4, None, &[]);
+ assert_eq!((d.run, d.jump, d.umu), (1, 0, 2));
+ }
+
+ #[test]
+ fn a_gripping_hand_is_worth_a_fifth_of_the_climb_and_nothing_to_a_platoon_that_cannot() {
+ let grappler = helm_core::Prosthetic {
+ key: "GRAPPLER".into(),
+ name: "Grappler".into(),
+ damage_per_trooper: 0.0,
+ anti_mek_modifier: -2,
+ };
+ static RIFLE: std::sync::OnceLock = std::sync::OnceLock::new();
+ let rifle = RIFLE.get_or_init(|| EquipmentEntry {
+ battle_value: Some(10.0),
+ ..EquipmentEntry::default()
+ });
+
+ let mut p = platoon(10);
+ p.primary = Some(rifle);
+ p.prosthetics = vec![(&grappler, 2)];
+ // On the ground it counts once and the hand is worth nothing.
+ assert!((p.offensive().subtotal - 100.0).abs() < 1e-9);
+ // Climbing, it counts twice and the hand adds a fifth of the second.
+ p.anti_mek = true;
+ assert!((p.offensive().subtotal - 220.0).abs() < 1e-9);
+ }
}
diff --git a/crates/helm-core/src/catalogue.rs b/crates/helm-core/src/catalogue.rs
index 445c4cf..e802c7d 100644
--- a/crates/helm-core/src/catalogue.rs
+++ b/crates/helm-core/src/catalogue.rs
@@ -194,6 +194,46 @@ fn tech_score(entry: &EquipmentEntry, clan: bool) -> u8 {
}
}
+/// An animal a platoon rides, which is neither equipment nor a vehicle.
+///
+/// A `.blk` names one - `motion_type: Beast:Camel` - and says nothing else
+/// about it. MegaMek keeps the figures in code rather than in `data/`, so they
+/// come across the bridge with the equipment and are keyed the way the file
+/// names them.
+#[derive(Debug, Clone, PartialEq)]
+pub struct InfantryMount {
+ /// The animal's own name, without the `Beast:` the file writes.
+ pub name: String,
+ /// MegaMek's movement mode for a platoon riding it, which is the
+ /// animal's rather than the platoon's.
+ pub movement_mode: String,
+ /// How far it carries the platoon, in its own mode.
+ pub movement_points: i64,
+ /// And on the ground, where its own mode is not the ground: a hipposaur
+ /// swims two and walks one.
+ pub secondary_ground_mp: i64,
+ /// What riding it divides incoming damage by, on top of whatever the
+ /// platoon is wearing.
+ pub damage_divisor: f64,
+}
+
+/// A limb a trooper has had replaced.
+///
+/// The same bargain as [`InfantryMount`]: a `.blk` names one and a count, and
+/// every figure behind the name is upstream's.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Prosthetic {
+ /// MegaMek's constant - `GRAPPLER`.
+ pub key: String,
+ /// And what it is called - `Grappler`. A design may name either.
+ pub name: String,
+ /// What one of them adds to a trooper's own damage.
+ pub damage_per_trooper: f64,
+ /// What it does to a climbing roll. Anything other than nought is worth
+ /// a fifth of the platoon's climbing value.
+ pub anti_mek_modifier: i64,
+}
+
/// Every equipment type MegaMek knows, with lookups by both spellings.
#[derive(Debug, Clone, Default)]
pub struct Catalogue {
@@ -208,10 +248,25 @@ pub struct Catalogue {
/// carries is not in the name, so a caller that knows the design's tech
/// base can ask for the right one - see [`Catalogue::resolve_for`].
by_norm_all: HashMap>,
+ /// The animals a platoon can ride, by the name a `.blk` writes.
+ mounts: HashMap,
+ /// The prosthetics a trooper can carry, reachable by either spelling.
+ prosthetics: Vec,
}
impl Catalogue {
pub fn new(entries: Vec) -> Self {
+ Self::with_infantry(entries, Vec::new(), Vec::new())
+ }
+
+ /// The same, with the two tables MegaMek keeps in code rather than in a
+ /// data file. Both are needed to score a platoon that rides or has been
+ /// rebuilt, and neither is equipment.
+ pub fn with_infantry(
+ entries: Vec,
+ mounts: Vec,
+ prosthetics: Vec,
+ ) -> Self {
let mut by_internal = HashMap::new();
let mut by_norm = HashMap::new();
let mut by_norm_all: HashMap> = HashMap::new();
@@ -232,9 +287,30 @@ impl Catalogue {
by_internal,
by_norm,
by_norm_all,
+ mounts: mounts
+ .into_iter()
+ .map(|m| (normalize(&m.name), m))
+ .collect(),
+ prosthetics,
}
}
+ /// The animal a platoon rides, by the name its file writes - with or
+ /// without the `Beast:` in front of it.
+ pub fn mount(&self, name: &str) -> Option<&InfantryMount> {
+ self.mounts.get(&normalize(
+ name.strip_prefix("Beast:").unwrap_or(name).trim(),
+ ))
+ }
+
+ /// A prosthetic by either spelling: MegaMek's constant or its name.
+ pub fn prosthetic(&self, name: &str) -> Option<&Prosthetic> {
+ let key = normalize(name);
+ self.prosthetics
+ .iter()
+ .find(|p| normalize(&p.key) == key || normalize(&p.name) == key)
+ }
+
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
@@ -385,4 +461,40 @@ mod tests {
]);
assert_eq!(c.resolve("AAA Missile").unwrap().name, "AAA Missile (IS)");
}
+
+ #[test]
+ fn an_animal_is_found_by_the_name_its_file_writes() {
+ let cat = Catalogue::with_infantry(
+ Vec::new(),
+ vec![InfantryMount {
+ name: "Coventry Kangaroo".into(),
+ movement_mode: "Foot Infantry".into(),
+ movement_points: 3,
+ secondary_ground_mp: 0,
+ damage_divisor: 1.0,
+ }],
+ Vec::new(),
+ );
+ // A `.blk` writes `motion_type: Beast:Coventry Kangaroo`, and the
+ // table knows it as "Coventry Kangaroo".
+ assert!(cat.mount("Beast:Coventry Kangaroo").is_some());
+ assert!(cat.mount("coventry kangaroo").is_some());
+ assert!(cat.mount("Beast:Horse").is_none());
+ }
+
+ #[test]
+ fn a_prosthetic_answers_to_either_spelling() {
+ let cat = Catalogue::with_infantry(
+ Vec::new(),
+ Vec::new(),
+ vec![Prosthetic {
+ key: "RUMAL_GARROTE".into(),
+ name: "Rumal/Garrote".into(),
+ damage_per_trooper: 0.14,
+ anti_mek_modifier: 0,
+ }],
+ );
+ assert!(cat.prosthetic("RUMAL_GARROTE").is_some());
+ assert!(cat.prosthetic("Rumal/Garrote").is_some());
+ }
}
diff --git a/crates/helm-core/src/lib.rs b/crates/helm-core/src/lib.rs
index 1c54cd3..1dd67cd 100644
--- a/crates/helm-core/src/lib.rs
+++ b/crates/helm-core/src/lib.rs
@@ -33,7 +33,7 @@ mod structure;
mod unit;
pub use bv::{BvBreakdown, RATING_TOLERANCE, bv_skill_multiplier};
-pub use catalogue::{Catalogue, EquipmentEntry};
+pub use catalogue::{Catalogue, EquipmentEntry, InfantryMount, Prosthetic};
pub use computed::{AlphaStrike, ComputedStats};
pub use design::{
ArmorKind, C3, CockpitKind, Engine, EngineKind, GyroKind, HeatSinkKind, HeatSinks,