From c5022a3f9b65091cd00ff18d178a55acd9a0f095 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 21 Aug 2026 09:28:55 -0400 Subject: [PATCH] feat(unit-rules): the two infantry tables MegaMek keeps in code Beast-mounted platoons and the one squad with prosthetic limbs were the last 82 declined; all 1,792 now agree. Neither table is in `data/`, so `DumpEquipment` writes both into equipment.jsonl, which is already built, trimmed and shipped once. Change-Id: Ifeabcd4ed1d353f748df91360c05e89ee1df2a02 --- TODO.md | 19 ++--- bridge/DumpEquipment.java | 70 +++++++++++++++++ crates/helm-bridge/src/lib.rs | 60 ++++++++++++-- crates/helm-bv/conformance.txt | 12 +-- crates/helm-bv/src/infantry.rs | 126 +++++++++++++++++++++++++++--- crates/helm-core/src/catalogue.rs | 112 ++++++++++++++++++++++++++ crates/helm-core/src/lib.rs | 2 +- 7 files changed, 369 insertions(+), 32 deletions(-) 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, -- 2.51.2