From 43cf163895d004f29bfb1ae3e0e6efdfcc6567b4 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 20 Aug 2026 16:56:10 -0400 Subject: [PATCH] feat(unit-rules): battle value for combat vehicles Tanks, hovercraft, VTOLs and naval vessels: 1,215 of 1,216 agree with MegaMek, and the one that does not is recorded. The whole conformance report is now 5,467 of 5,510 designs with both ratings measured. One calculation rather than two. MegaMek's `CombatVehicleBVCalculator` changes five things about `BVCalculator` - the plate is worth less for being on a vehicle, the frame is a tenth of the tonnage per armoured location, the tonnage counts for half, there is no heat budget to run out of, and only the front hull has a say in which way the machine faces - so `Machine` carries a `Chassis` and those five places ask it. The oracle found three things reading the rules would not have: a machine that cannot move gets no movement modifier, so a stealth trailer is a target like any other; a vehicle's ammunition carries no explosive penalty; and MegaMek's facing rule is written against location numbers that mean something else on a superheavy hull, which is in UPSTREAM.md and reproduced here. --- TODO.md | 26 +++ bridge/DumpBv.java | 13 +- bridge/dump.sh | 7 +- crates/helm-bv/UPSTREAM.md | 32 ++++ crates/helm-bv/conformance.txt | 23 ++- crates/helm-bv/src/conformance.rs | 23 ++- crates/helm-bv/src/defensive.rs | 40 ++++- crates/helm-bv/src/lib.rs | 67 +++++++- crates/helm-bv/src/loadout.rs | 155 ++++++++++++++++++ crates/helm-bv/src/machine.rs | 31 ++++ crates/helm-bv/src/offensive.rs | 81 +++++++--- crates/helm-bv/src/vehicle.rs | 243 ++++++++++++++++++++++++++++ crates/helm-bv/tests/conformance.rs | 22 ++- crates/helm-cli/src/main.rs | 25 +-- 14 files changed, 722 insertions(+), 66 deletions(-) create mode 100644 crates/helm-bv/src/vehicle.rs diff --git a/TODO.md b/TODO.md index 331f707..73bae69 100644 --- a/TODO.md +++ b/TODO.md @@ -403,6 +403,32 @@ The long pole, and the reason `helm-core` does no I/O. Tracked as rarest. The two ratings are summed and the total rounded once, which is the last place a half point can change an answer. +- [x] **Battle value for combat vehicles.** 1,215 of 1,216 tanks, hovercraft, + VTOLs and naval vessels agree with MegaMek, and the one that does not is + recorded as a known deviation. The whole report is now 5,467 of 5,510 + designs, both ratings included. + + It is one calculation and not two. MegaMek has a `BVCalculator` and a + subclass per unit type, and `CombatVehicleBVCalculator` changes five + things: the plate is worth less for being on a vehicle, the frame is a + tenth of the tonnage in each location that wears armour, the tonnage + counts for half, there is no heat budget to run out of, and only the + front hull has a say in which way the machine faces. So `Mek` became + `Machine`, it carries a `Chassis`, and the five places that differ ask + it. + + What the oracle found that reading the rules would not have: a + trailer with a stealth system is not hard to hit, because a machine that + cannot move gets no movement modifier at all; a superheavy hull numbers + its locations differently and MegaMek's facing rule is written against + the numbers rather than the locations, which is in + `crates/helm-bv/UPSTREAM.md`; and a vehicle's ammunition carries no + explosive penalty - none of the 1,256 reports has the line. +- [ ] **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 + own. Reading them as combat vehicles would be wrong rather than + approximately right. - [ ] **The clusters still outstanding**, from `helm bv-report --clusters`. Each is self-contained and none of them blocks the others: diff --git a/bridge/DumpBv.java b/bridge/DumpBv.java index aec3693..ea9018b 100644 --- a/bridge/DumpBv.java +++ b/bridge/DumpBv.java @@ -33,7 +33,7 @@ import java.util.Map; * summary cache, because the calculator needs a real unit. That is the whole * cost of the program. * - * java -cp "MegaMek.jar:lib/*" DumpBv.java out.jsonl [unitType] [--report] + * java -cp "MegaMek.jar:lib/*" DumpBv.java out.jsonl [unitType ...] [--report] * * Must run with MegaMek's install dir as the working directory. */ @@ -44,13 +44,17 @@ public final class DumpBv { System.err.println("usage: DumpBv [unitType]"); System.exit(2); } - String only = null; + // Several types may be named. A combat vehicle is scored by the + // calculator every unit falls back to, and MekSummary files it under + // whichever of Tank, VTOL and Naval it looks like - so asking for + // "vehicles" is asking for three names. + java.util.Set only = new java.util.LinkedHashSet<>(); boolean wantReport = false; for (int i = 1; i < args.length; i++) { if (args[i].equals("--report")) { wantReport = true; } else { - only = args[i]; + only.add(args[i].toLowerCase(java.util.Locale.ROOT)); } } @@ -72,7 +76,8 @@ public final class DumpBv { for (MekSummary ms : cache.getAllMeks()) { String name = safeName(ms); try { - if (only != null && !only.equalsIgnoreCase(ms.getUnitType())) { + if (!only.isEmpty() + && !only.contains(ms.getUnitType().toLowerCase(java.util.Locale.ROOT))) { continue; } Entity entity = ms.loadEntity(); diff --git a/bridge/dump.sh b/bridge/dump.sh index 919aca5..598881f 100755 --- a/bridge/dump.sh +++ b/bridge/dump.sh @@ -91,8 +91,11 @@ run DumpBv.java bv.jsonl Mek --report # The same for combat vehicles, which are the next thing helm-bv scores. Much # cheaper than the Meks: there are fewer of them and a tank has no heat budget # to walk. +# Three names for one calculator: everything MegaMek does not recognise as +# something else is scored as a combat vehicle, and MekSummary files those +# under whichever of Tank, VTOL and Naval the design looks like. echo "dumping battle value working for vehicles ..." -run DumpBv.java bv-tank.jsonl Tank --report +run DumpBv.java bv-vehicle.jsonl Tank VTOL Naval --report # The same oracle for designs that have been shot at. A .mtf describes a design # as it leaves the factory and nothing else, so what a Mek is worth once its @@ -159,6 +162,6 @@ if [ -f "$LANCES" ]; then rm -f "$OUT_DIR/one_force.jsonl" fi -echo "wrote $OUT_DIR/units.jsonl, $OUT_DIR/equipment.jsonl, $OUT_DIR/bv.jsonl, $OUT_DIR/bv-tank.jsonl," +echo "wrote $OUT_DIR/units.jsonl, $OUT_DIR/equipment.jsonl, $OUT_DIR/bv.jsonl, $OUT_DIR/bv-vehicle.jsonl," echo " $OUT_DIR/damaged.jsonl, $OUT_DIR/mul.jsonl, $OUT_DIR/formations.jsonl," echo " $OUT_DIR/matches.jsonl and $OUT_DIR/formation_forces.jsonl" diff --git a/crates/helm-bv/UPSTREAM.md b/crates/helm-bv/UPSTREAM.md index e228990..ae08a07 100644 --- a/crates/helm-bv/UPSTREAM.md +++ b/crates/helm-bv/UPSTREAM.md @@ -192,3 +192,35 @@ disagreement comes from - helm reads the criticals and finds one. Which half of the file MegaMek believes has not been established here. What is plain from the file alone is that the two halves disagree with each other. + +## `isNominalRear` decides by location number, and the numbers move + +`CombatVehicleBVCalculator.isNominalRear` is what halves a weapon for pointing +the wrong way, and it refuses to halve anything in location 5 or 6: + +``` +return (switchRearAndFront ^ rearWeaponFilter().test(m)) + && (m.getLocation() != 5) + && (m.getLocation() != 6); +``` + +On a `Tank` those two numbers are the turret and the second turret, which is +the rule as written: a turret turns, so nothing in one is ever facing +backwards. On a `SuperHeavyTank` the same two numbers are the rear left +quarter and the rear itself, and its turrets are 7 and 8. The exclusion +therefore lands on the wrong locations for every superheavy vehicle: + +* weapons in the actual rear are never halved. A Devastator II Superheavy + Tank's rear SRM 4 counts 39 rather than 19.5, and a Teppō Artillery + Vehicle's rear Light Machine Gun counts 5 rather than 2.5. +* weapons in a turret *are* halved once the arcs switch. A Destrier Siege + Vehicle's two Long Tom Cannon count 164.5 each instead of 329, which is 308 + points of the design. + +`rearLocation()` already asks `instanceof SuperHeavyTank` a few lines above, so +the information is to hand; the two constants are what is missing it. +`LargeSupportTank` shares the layout and presumably the same problem, though +helm does not score those yet. + +helm reproduces this, in `Loadout::read_vehicle` - agreeing with MegaMek is the +job - and the three designs above are the evidence that it does. diff --git a/crates/helm-bv/conformance.txt b/crates/helm-bv/conformance.txt index 335d7a2..dc949cf 100644 --- a/crates/helm-bv/conformance.txt +++ b/crates/helm-bv/conformance.txt @@ -2,13 +2,13 @@ helm-bv conformance rules 0.1.0 megamek 0.51.0 -10988 designs with a MegaMek answer, 4294 of a type this scores +10988 designs with a MegaMek answer, 5510 of a type this scores - battle value 4252 match 10 differ 0 not computed 99.0% - defensive rating 4258 match 4 differ 0 not computed 99.2% - offensive rating 4255 match 7 differ 0 not computed 99.1% + battle value 5467 match 11 differ 0 not computed 99.2% + defensive rating 5473 match 5 differ 0 not computed 99.3% + offensive rating 5471 match 7 differ 0 not computed 99.3% -out of scope, by unit type (6694 designs) +out of scope, by unit type (5478 designs) 502 AeroSpaceFighter 1188 BattleArmor 106 BuildingEntity @@ -25,8 +25,6 @@ out of scope, by unit type (6694 designs) 33 SpaceStation 176 SupportTank 24 SupportVTOL - 1086 Tank - 130 VTOL 123 Warship not scored at all, by reason @@ -41,13 +39,20 @@ known deviations, still counted above variant from an April Fools' sourcebook - the equipment is real in MegaMek, the rule is not worth carrying for a single Mek. + Donar Assault Helicopter (Close Support) + carries a Clan Active Probe on a mast mount, which MegaMek + counts as 22 points of defensive equipment where the catalogue says + the probe is worth 12. One design in 1,216 vehicles, and + the eleven points belong to something no other design puts on + a mast. worst disagreements: battle value 1 off by 1 or less - 9 within 5% + 10 within 5% -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 + -1.9% Donar Assault Helicopter (Close Support) ours 571.0 megamek 582.0 +1.6% UrbanKnight UM-DKX ours 990.0 megamek 974.0 -0.8% Tessen TSN-X4R 'Rapunzel' ours 1482.0 megamek 1494.0 -0.5% Vulpes VLP-1DX 'Beast' ours 1223.0 megamek 1229.0 @@ -58,6 +63,8 @@ worst disagreements: battle value worst disagreements: defensive rating 4 within 5% + 1 within 20% + -5.3% Donar Assault Helicopter (Close Support) ours 185.8 megamek 196.3 -0.6% Hybrid Rifleman RFL-3N (Sneede) ours 610.2 megamek 613.8 -0.5% Atlas AS7-00 (Jurn) ours 1125.6 megamek 1131.6 -0.4% Hybrid Rifleman RFL-3N (Sneede II) ours 797.4 megamek 801.0 diff --git a/crates/helm-bv/src/conformance.rs b/crates/helm-bv/src/conformance.rs index 05fdb63..855a444 100644 --- a/crates/helm-bv/src/conformance.rs +++ b/crates/helm-bv/src/conformance.rs @@ -40,13 +40,22 @@ use crate::Unsupported; /// Nothing is excluded from the counts by being here - the report still says a /// design differs. Being listed only means the next person does not have to /// find out why. -pub const KNOWN_DEVIATIONS: &[(&str, &str)] = &[( - "UrbanKnight UM-DKX", - "carries a chain drape, which costs a point of walking. One design in the \ - library has one, and it is a novelty variant from an April Fools' \ - sourcebook - the equipment is real in MegaMek, the rule is not worth \ - carrying for a single Mek.", -)]; +pub const KNOWN_DEVIATIONS: &[(&str, &str)] = &[ + ( + "UrbanKnight UM-DKX", + "carries a chain drape, which costs a point of walking. One design in \ + the library has one, and it is a novelty variant from an April \ + Fools' sourcebook - the equipment is real in MegaMek, the rule is \ + not worth carrying for a single Mek.", + ), + ( + "Donar Assault Helicopter (Close Support)", + "carries a Clan Active Probe on a mast mount, which MegaMek counts as \ + 22 points of defensive equipment where the catalogue says the probe \ + is worth 12. One design in 1,216 vehicles, and the eleven points \ + belong to something no other design puts on a mast.", + ), +]; /// One design where we and MegaMek disagree about one figure. #[derive(Debug, Clone, PartialEq)] diff --git a/crates/helm-bv/src/defensive.rs b/crates/helm-bv/src/defensive.rs index 01e5b15..bb04560 100644 --- a/crates/helm-bv/src/defensive.rs +++ b/crates/helm-bv/src/defensive.rs @@ -17,7 +17,7 @@ use std::collections::BTreeMap; use helm_core::{ArmorKind, EngineKind, GyroKind, StructureKind}; use crate::equipment; -use crate::machine::Machine; +use crate::machine::{Chassis, Machine}; /// Battle value per point of armour, before the type multiplier. const PER_ARMOR_POINT: f64 = 2.5; @@ -51,6 +51,11 @@ pub fn defensive(machine: &Machine<'_>) -> Result let explosive = explosive(machine); let subtotal = armor + structure + gyro + equipment + explosive; + // What the machine is worth for being the kind of machine it is. A Mek + // carries 1.0 and is unaffected; a vehicle is worth less for being a + // vehicle. It lands here, between the explosive penalty and the movement + // factor, which is where `processTypeModifier` sits upstream. + let subtotal = subtotal * machine.type_modifier; // MegaMek floors this at one point. A design whose ammunition is worth // more in penalties than its armour is worth in protection is still worth // something to shoot at. @@ -118,6 +123,11 @@ fn structure(machine: &Machine<'_>) -> Result { } fn gyro(machine: &Machine<'_>) -> Result { + // A vehicle has no gyro. It has a drivetrain, and MegaMek charges nothing + // for one. + if machine.kind == Chassis::CombatVehicle { + return Ok(0.0); + } Ok(machine.tons * gyro_multiplier(&machine.gyro, machine.has_interface_cockpit)?) } @@ -166,6 +176,14 @@ fn defensive_equipment(machine: &Machine<'_>) -> f64 { /// /// Counted per critical slot, in each location that is not protected. fn explosive(machine: &Machine<'_>) -> f64 { + // Not charged to a vehicle. Ammunition on a tank goes off the same way it + // does on a Mek and is dangerous in the same way, but MegaMek's report for + // a combat vehicle carries no explosive line at all - none of the 1,256 in + // the library - and helm agrees with MegaMek rather than with the reading + // of the rule. + if machine.kind == Chassis::CombatVehicle { + return 0.0; + } // A capacitor is wired to a PPC in its own location, and only that PPC can // go off. Asking whether the design has one anywhere charges every PPC on // it: an Awesome AWS-11M carries eight and was charged for all of them. @@ -376,7 +394,11 @@ fn location_is_exposed(machine: &Machine<'_>, location: &str) -> bool { /// The best of running and jumping, where jumping is worth one more than the /// same distance run, and stealth systems add two on top of that. fn movement_factor(machine: &Machine<'_>) -> f64 { - let running = tmm(machine.run_mp); + // Being off the ground is worth a point in its own right, which is what + // `Compute.getTargetMovementModifier` is told when it is asked about a + // VTOL or a WiGE. + let airborne = i64::from(machine.airborne && machine.run_mp > 0); + let running = tmm(machine.run_mp) + airborne; let jumping = if machine.jump_mp > 0 { tmm(machine.jump_mp) + 1 } else { @@ -387,8 +409,18 @@ fn movement_factor(machine: &Machine<'_>) -> f64 { let swimming = tmm(machine.umu_mp); // Signature systems and stealth plate make a design harder to hit; they // are counted in `rules` so the set can be inventoried. - let mut modifier = - running.max(jumping).max(swimming) + crate::rules::adjustments(machine).target_movement; + // A machine that cannot move is not hard to hit, whatever it is wearing. + // MegaMek works each modifier out per mode and returns zero before the + // stealth bonus is added when that mode has no movement points, so a Gun + // Trailer with a stealth system parked in a field is a target and nothing + // more. + let moves = machine.run_mp > 0 || machine.jump_mp > 0 || machine.umu_mp > 0; + let mut modifier = running.max(jumping).max(swimming) + + if moves { + crate::rules::adjustments(machine).target_movement + } else { + 0 + }; // A void signature system does not add to what the design already has; it // guarantees a floor of three, and nudges a design that was already on // three up to four. diff --git a/crates/helm-bv/src/lib.rs b/crates/helm-bv/src/lib.rs index 0730d34..a2aec62 100644 --- a/crates/helm-bv/src/lib.rs +++ b/crates/helm-bv/src/lib.rs @@ -7,6 +7,19 @@ //! bin, a C3 link. Those are deltas from a known design, and there is nothing //! to take a delta from unless we own the formula. //! +//! # What it scores +//! +//! BattleMeks and combat vehicles: tanks, hovercraft, VTOLs and naval +//! vessels. MegaMek has one calculator with a subclass per unit type, and the +//! subclasses differ by very little - a vehicle is worth less for being a +//! vehicle, its tonnage counts for half, and it has no heat budget to run out +//! of. So this crate has one calculation and a [`Machine`] that says which of +//! those apply, rather than two calculations that agree by hand. +//! +//! Everything else - aerospace, battle armour, infantry, support vehicles, +//! anything that flies between planets - is declined by type rather than +//! guessed at. +//! //! # What this crate is written against //! //! The published BV 2.0 rules, checked against MegaMek's own answers for every @@ -44,6 +57,7 @@ mod machine; mod offensive; mod repair; mod rules; +mod vehicle; mod wear; pub use attribution::{Attribution, Change, Term, Terms, attribute, between, terms_in}; @@ -326,6 +340,9 @@ fn readable<'a>( catalogue: &'a Catalogue, condition: &Condition, ) -> Result, Unsupported> { + if is_combat_vehicle(unit) { + return crate::vehicle::read_in(unit, catalogue, condition); + } if !is_mek(unit) { return Err(Unsupported::UnitType( unit.unit_type.clone().unwrap_or_else(|| "unknown".into()), @@ -358,6 +375,18 @@ pub fn computed_stats(unit: &Unit, catalogue: &Catalogue) -> ComputedStats { /// A `.mtf` is always a Mek and declares no unit type; a `.blk` declares one. /// Reading it the other way round - trusting the field - silently drops every /// Mek in the library, which is the whole population this crate is aimed at. +/// Whether this design is one MegaMek scores with its combat vehicle +/// calculator. +/// +/// `Tank` only for now. A VTOL, a support tank and a large support tank are +/// each their own calculator upstream, and reading them as a tank would be +/// wrong rather than approximately right. +fn is_combat_vehicle(unit: &Unit) -> bool { + unit.unit_type + .as_deref() + .is_some_and(|t| t.eq_ignore_ascii_case("tank") || t.eq_ignore_ascii_case("vtol")) +} + fn is_mek(unit: &Unit) -> bool { match unit.unit_type.as_deref() { None => unit.format_str == "mtf", @@ -385,18 +414,46 @@ mod tests { } #[test] - fn a_blk_vehicle_is_declined_by_type_and_not_by_a_generic_failure() { - let tank = Unit { + fn a_unit_type_with_no_calculator_is_declined_by_type() { + let dropship = Unit { format_str: "blk".into(), - unit_type: Some("Tank".into()), + unit_type: Some("Dropship".into()), ..Default::default() }; assert_eq!( - breakdown(&tank, &Catalogue::default()).unwrap_err(), - Unsupported::UnitType("Tank".into()) + breakdown(&dropship, &Catalogue::default()).unwrap_err(), + Unsupported::UnitType("Dropship".into()) ); } + #[test] + fn a_tank_and_a_vtol_are_both_read_as_combat_vehicles() { + for kind in ["Tank", "VTOL"] { + let unit = Unit { + format_str: "blk".into(), + unit_type: Some(kind.into()), + ..Default::default() + }; + assert!(is_combat_vehicle(&unit), "{kind} should be a vehicle"); + // Read far enough to want a tonnage, rather than declined for + // being the wrong kind of thing. + assert_eq!( + breakdown(&unit, &Catalogue::default()).unwrap_err(), + Unsupported::Missing("mass") + ); + } + } + + #[test] + fn a_support_vehicle_is_not_a_combat_vehicle() { + let support = Unit { + format_str: "blk".into(), + unit_type: Some("SupportTank".into()), + ..Default::default() + }; + assert!(!is_combat_vehicle(&support)); + } + // The point of the shared type: a producer that fills one field is not // wrong about the ones it left alone. #[test] diff --git a/crates/helm-bv/src/loadout.rs b/crates/helm-bv/src/loadout.rs index e14a612..3b60c61 100644 --- a/crates/helm-bv/src/loadout.rs +++ b/crates/helm-bv/src/loadout.rs @@ -394,6 +394,114 @@ impl<'a> Loadout<'a> { loadout } + /// Read a vehicle's equipment, where a line is a whole item. + /// + /// A `.mtf` writes one line per critical slot, so a Gauss rifle appears + /// seven times and the reader above divides back down. A `.blk` writes one + /// line per *mounting*, in a block named for the location. Running the + /// slot reader over that would find a seventh of a Gauss rifle - so the + /// count is multiplied back up here to the footprint the rest of this + /// module divides by, and both readers hand on the same shape. + pub fn read_vehicle(unit: &Unit, catalogue: &'a Catalogue) -> Loadout<'a> { + // Which hull this is, because the locations are numbered differently + // and the rules are written against the numbers. A superheavy has + // quarters rather than sides - front right, rear left - and its rear + // is location six where a normal tank's is four. + // + // Read off the blocks the file actually has rather than off the + // tonnage: a Moray Heavy Attack Submarine is 140 tons and has plain + // sides, because the hundred-ton limit a superheavy exists to break is + // a ground vehicle's. + let superheavy = is_superheavy(unit); + let mut per_location: BTreeMap = BTreeMap::new(); + let mut modular = 0i64; + for mount in &unit.equipment { + let name = clean(&mount.name).name; + let Some(entry) = catalogue.resolve(&name) else { + continue; + }; + let location = mount.location.clone().unwrap_or_default(); + if entry.has_flag("F_MODULAR_ARMOR") { + modular += entry.damage_capacity.unwrap_or(0); + } + let footprint = entry.criticals.filter(|c| *c > 0).unwrap_or(1).max(1) as usize; + let index = location_index(&location, superheavy); + let counts = per_location + .entry(Placement { + name: entry.internal_name.clone(), + // Whatever MegaMek's `isNominalRear` refuses to halve, + // which it decides by location number: five or six. On a + // normal tank those are the two turrets, which is the + // rule as written - a turret turns, so it never faces + // backwards. On a superheavy they are the rear left + // quarter and the rear itself, and the turret is seven. + // See `UPSTREAM.md`: the numbers are hardcoded and the + // rule lands on whatever happens to be at them. + turret: matches!(index, Some(5 | 6)), + rear: index == Some(rear_location(superheavy)), + location, + armored: false, + }) + .or_default(); + counts.0 += footprint; + counts.1 += footprint; + } + + let items = per_location + .into_iter() + .filter_map( + |( + Placement { + name, + location, + rear, + armored, + turret, + }, + (slots, physical_slots), + )| { + Some(Item { + entry: catalogue.get(&name)?, + // A vehicle has no arms, and nothing on one turns + // except a turret. + arm: false, + aes: false, + location, + slots, + physical_slots, + rear, + armored, + turret, + }) + }, + ) + .collect(); + + let mut loadout = Loadout { + items, + mountings: Vec::new(), + present: BTreeSet::new(), + lost_cooling: 0, + // Modular armour is armour bolted on and declared as equipment, + // and it is worth its points wherever it is fitted. One line is + // one pack here, so the points are the pack's own. + modular_armor: modular, + had_jump_jets: unit.jump_mp.is_some_and(|mp| mp > 0), + armored_gyro_slots: 0, + armored_system_slots: 0, + per_slot: 1, + mechanical_jump_booster_mp: 0, + }; + loadout.mountings = loadout.total_mountings(); + loadout.present = loadout + .items + .iter() + .flat_map(|item| item.entry.flags.iter()) + .map(|flag| flag.rsplit('.').next().unwrap_or(flag.as_str())) + .collect(); + loadout + } + pub fn items(&self) -> &[Item<'a>] { &self.items } @@ -856,6 +964,53 @@ fn shot_out_across( } /// Whether a location is an arm, which can turn to face what it is shooting. +/// Whether the hull is quartered, which is what a `SuperHeavyTank` is here. +pub(crate) fn is_superheavy(unit: &Unit) -> bool { + unit.equipment + .iter() + .filter_map(|m| m.location.as_deref()) + .any(|l| { + matches!( + l.trim().to_ascii_lowercase().as_str(), + "front right" | "front left" | "rear right" | "rear left" + ) + }) +} + +/// The location number MegaMek gives a `.blk` block, which is what its rules +/// about facing are written against. +/// +/// Two layouts, and the same names mean different numbers in each. +/// `Tank`: body, front, right, left, rear, turret, turret 2. +/// `SuperHeavyTank`: body, front, front right, front left, rear right, +/// rear left, rear, turret, turret 2. +pub(crate) fn location_index(location: &str, superheavy: bool) -> Option { + let name = location.trim().to_ascii_lowercase(); + let table: &[&str] = if superheavy { + &[ + "body", + "front", + "front right", + "front left", + "rear right", + "rear left", + "rear", + "turret", + "turret 2", + ] + } else { + &[ + "body", "front", "right", "left", "rear", "turret", "turret 2", + ] + }; + table.iter().position(|l| *l == name) +} + +/// Where the rear is, which is not the same number on the two hulls. +pub(crate) fn rear_location(superheavy: bool) -> usize { + if superheavy { 6 } else { 4 } +} + fn is_arm(location: &str) -> bool { let l = location.to_ascii_lowercase(); l == "left arm" || l == "right arm" diff --git a/crates/helm-bv/src/machine.rs b/crates/helm-bv/src/machine.rs index c0a2e2d..5099c7d 100644 --- a/crates/helm-bv/src/machine.rs +++ b/crates/helm-bv/src/machine.rs @@ -22,8 +22,24 @@ use helm_core::{ArmorKind, Catalogue, CockpitKind, EngineKind, GyroKind, Structu use crate::loadout::Loadout; use crate::{Unsupported, defensive}; +/// What is being scored, where the rules differ by it. +/// +/// The two ratings are computed the same way for everything - armour is worth +/// two and a half a point whatever is wearing it - and MegaMek expresses the +/// differences as a subclass overriding a handful of steps. This is that +/// subclass, as data: the steps ask what they are looking at rather than +/// dispatching, because there are four differences and not four calculations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Chassis { + Mek, + /// A tank, hovercraft, VTOL or naval vessel: everything MegaMek scores + /// with `CombatVehicleBVCalculator`. + CombatVehicle, +} + /// Everything the two ratings are computed from. pub struct Machine<'a> { + pub kind: Chassis, pub tons: f64, pub shape: helm_core::Shape, /// Clan-built. Its CASE is structural and appears in no critical slot, so @@ -41,6 +57,18 @@ pub struct Machine<'a> { /// decides both what the frame is worth and whether CASE can save a torso. pub engine_side_slots: i64, pub gyro: GyroKind, + /// What the design's own construction is worth defensively, applied to the + /// defensive rating between the explosive penalty and the movement factor. + /// + /// A Mek has none of this and carries 1.0. A vehicle is worth less for + /// being a vehicle - nine tenths on tracks, six tenths on water - and + /// worth some of it back for being amphibious or sealed. + pub type_modifier: f64, + /// Airborne while it moves, which is a point of movement modifier on top + /// of the distance. A VTOL and a WiGE are; a hovercraft is not, and the + /// difference is a Hiryo at 5 against a Kanga at 4 for the same twelve + /// hexes. + pub airborne: bool, /// Movement, as battle value counts it: running includes the sprint gear. pub run_mp: i64, pub jump_mp: i64, @@ -178,6 +206,9 @@ impl<'a> Machine<'a> { - loadout.lost_cooling(); Ok(Machine { + kind: Chassis::Mek, + type_modifier: 1.0, + airborne: false, tons, shape, is_clan: is_clan_built(unit.tech_base.as_deref()), diff --git a/crates/helm-bv/src/offensive.rs b/crates/helm-bv/src/offensive.rs index c0bac9a..3be1ac3 100644 --- a/crates/helm-bv/src/offensive.rs +++ b/crates/helm-bv/src/offensive.rs @@ -16,7 +16,7 @@ use std::collections::BTreeMap; use helm_core::EquipmentEntry; use crate::equipment; -use crate::machine::Machine; +use crate::machine::{Chassis, Machine}; /// What the offensive rating is built from. #[derive(Debug, Clone, Default, PartialEq)] @@ -55,6 +55,13 @@ struct Fitted<'a> { bv: f64, /// Mounted backwards, as the file marks it. rear: bool, + /// Counts toward the front arc when deciding which way the machine faces. + /// Not simply `!rear`: a vehicle's flanks have no say either way. + front: bool, + /// Counts toward the rear arc in that same decision. Not simply `rear` + /// either: on a superheavy tank the rear is a location that has a say in + /// the decision and is then never halved by it. + rear_arc: bool, /// In an arm or a turret, and so takes no part in deciding which way the /// design faces. turns: bool, @@ -74,7 +81,14 @@ struct Armed<'a> { /// Work out the offensive rating. pub fn offensive(machine: &Machine<'_>) -> Result { let armed = arm(machine); - let budget = heat_budget(machine); + // A vehicle has no heat budget to run out of. MegaMek's report for a + // combat vehicle has no heat efficiency line and halves nothing for + // overheating - a ten-launcher SRM Carrier is paid for all ten - so the + // budget is the one no weapon can exceed. + let budget = match machine.kind { + Chassis::CombatVehicle => i64::MAX, + Chassis::Mek => heat_budget(machine), + }; let (weapons, heat_used) = weapons_within_heat(&armed, budget); let ammo = ammunition(machine, &armed); let weight = weight(machine); @@ -88,10 +102,17 @@ pub fn offensive(machine: &Machine<'_>) -> Result }; let subtotal = (weapons + equipment + ammo + weight) * fire_control; // Whichever of jumping and swimming carries the design further. - let factor = speed_factor(speed_factor_mp( - machine.run_mp, - machine.jump_mp.max(machine.umu_mp), - )); + // + // A vehicle that cannot move at all is paid as though it could move one + // hex: `offensiveSpeedFactorMP` reads a run of zero as one, which is a + // trailer's whole difference - a Gun Trailer is worth 0.54 of what it + // carries rather than 0.44. Only the speed factor sees this; the movement + // factor on the defensive side still knows the thing is parked. + let run_mp = match machine.kind { + Chassis::CombatVehicle if machine.run_mp == 0 => 1, + _ => machine.run_mp, + }; + let factor = speed_factor(speed_factor_mp(run_mp, machine.jump_mp.max(machine.umu_mp))); Ok(Offensive { weapons, equipment, @@ -236,14 +257,35 @@ fn arm<'a>(machine: &Machine<'a>) -> Vec> { if targeting_computer && mounting.entry.has_flag("F_DIRECT_FIRE") { bv *= 1.25; } + // An arm turns and so does a turret, which is the one thing that + // matters here: either way the weapon is not stuck pointing where + // the body points. + let turns = mounting.arm || mounting.turret; armed.push(Fitted { entry: mounting.entry, bv, rear: mounting.rear, - // An arm turns and so does a turret, which is the one thing - // that matters here: either way the weapon is not stuck - // pointing where the body points. - turns: mounting.arm || mounting.turret, + // Whether this weapon has a say in which way the machine + // nominally faces. A Mek's front arc is everything not mounted + // backwards; a vehicle's is the front hull alone, so a Maxim + // Flanker with its launchers on the flanks and one machine gun + // at the back has a front arc worth nothing and fights + // backwards. + front: match machine.kind { + // Anything fixed to the body and pointing forwards. + Chassis::Mek => !mounting.rear && !turns, + // The front hull alone. Not the flanks, which have a say + // in neither arc, and not the turret. + Chassis::CombatVehicle => mounting + .locations + .iter() + .any(|l| l.eq_ignore_ascii_case("front")), + }, + rear_arc: match machine.kind { + Chassis::Mek => mounting.rear && !turns, + Chassis::CombatVehicle => mounting.rear, + }, + turns, capacitor_fitted, risc_module, }); @@ -257,14 +299,9 @@ fn arm<'a>(machine: &Machine<'a>) -> Vec> { // flamer becomes the front rather than being halved. A Quickdraw QKD-8X // does the same with a turret: its PPC is the only thing pointing forwards // and, being in a turret, it has no say either. - let arc = |rear: bool| -> f64 { - armed - .iter() - .filter(|w| !w.turns && w.rear == rear) - .map(|w| w.bv) - .sum() - }; - let switch_arcs = arc(false) < arc(true); + let front_arc: f64 = armed.iter().filter(|w| w.front).map(|w| w.bv).sum(); + let rear_arc: f64 = armed.iter().filter(|w| w.rear_arc).map(|w| w.bv).sum(); + let switch_arcs = front_arc < rear_arc; armed .into_iter() @@ -417,11 +454,17 @@ fn ammunition(machine: &Machine<'_>, armed: &[Armed<'_>]) -> f64 { .sum() } -/// The Mek itself, as something to hit people with. +/// The machine itself, as something to hit people with. /// /// Triple-strength myomer makes it a better club, and actuator enhancements /// make it a better one again. fn weight(machine: &Machine<'_>) -> f64 { + // A vehicle is worth half its tonnage rather than all of it, and none of + // what follows: it has no myomer to strengthen and no actuators to + // enhance, because it does not hit anybody with itself. + if machine.kind == Chassis::CombatVehicle { + return machine.tons / 2.0; + } // Actuator enhancement makes the Mek itself a better weapon before triple // strength myomer has its say on top. let tons = machine.tons diff --git a/crates/helm-bv/src/vehicle.rs b/crates/helm-bv/src/vehicle.rs new file mode 100644 index 0000000..34cba79 --- /dev/null +++ b/crates/helm-bv/src/vehicle.rs @@ -0,0 +1,243 @@ +//! Reading a combat vehicle into the same values a Mek is read into. +//! +//! MegaMek scores a tank with `CombatVehicleBVCalculator`, which is +//! `BVCalculator` with five things changed: the plate is worth less for being +//! on a vehicle, the frame comes from the tonnage rather than a table, the +//! tonnage counts for half what a Mek's does, there is no heat budget to spend, +//! and a weapon on the wrong side counts half. Everything else - what a point +//! of armour is worth, what a magazine costs, how movement makes a machine +//! harder to hit - is the calculation that already exists. +//! +//! So this is a reader and not a second calculation. It fills [`Machine`] the +//! way [`crate::machine`] fills it for a Mek, and the handful of places the +//! rules differ ask [`Chassis`]. + +use std::collections::BTreeMap; + +use helm_core::{ArmorKind, Catalogue, StructureKind, Unit}; + +use crate::loadout::Loadout; +use crate::machine::{Chassis, Machine}; +use crate::{Condition, Unsupported}; + +/// Read a vehicle, or say what stopped us. +pub fn read_in<'a>( + unit: &Unit, + catalogue: &'a Catalogue, + condition: &Condition, +) -> Result, Unsupported> { + let tons = unit.mass.ok_or(Unsupported::Missing("mass"))?; + let armor_kind = armor_kind(unit.armor.as_deref())?; + + // A `.blk` writes armour as a bare list of numbers whose length says what + // the layout is, and the parser has already named them. Body is not in + // that list: it wears no plate. + let armor: Vec<(String, i64, ArmorKind)> = unit + .armor_locations + .iter() + .map(|(location, points)| { + let left = condition + .armor + .get(location) + .copied() + .unwrap_or(*points) + .max(0); + (location.clone(), left, armor_kind.clone()) + }) + .collect(); + + // Every location that wears plate also carries frame, and every one of + // them carries the same: a tenth of the tonnage, rounded up. MegaMek's + // `Tank.autoSetInternal` starts at location 1, so the body has none. + let per_location = (tons / 10.0).ceil() as i64; + let structure_points: i64 = armor + .iter() + .map(|(location, _, _)| { + condition + .structure + .get(location) + .copied() + .unwrap_or(per_location) + .max(0) + }) + .sum(); + + let cruise = unit.walk_mp.ok_or(Unsupported::Missing("cruise mp"))?; + let loadout = Loadout::read_vehicle(unit, catalogue); + + Ok(Machine { + kind: Chassis::CombatVehicle, + type_modifier: type_modifier(unit, &loadout), + airborne: matches!( + unit.motion_type + .as_deref() + .unwrap_or_default() + .to_ascii_lowercase() + .as_str(), + "vtol" | "wige" + ), + tons, + // A vehicle has no configuration in the sense a Mek does. Biped is the + // shape nothing here reads: the frame is worked out above rather than + // from the table this would index. + shape: helm_core::Shape::Biped, + is_clan: unit + .tech_base + .as_deref() + .is_some_and(|t| t.to_ascii_lowercase().starts_with("clan")), + structure_points, + // The `` block, where a vehicle declares one at all. + structure_kind: StructureKind::Standard, + armor, + armor_bonus: BTreeMap::new(), + armor_kind, + // A vehicle's engine fills no side torso, so the frame is worth what + // it is worth. Zero is the value that multiplies by one. + engine_side_slots: 0, + gyro: helm_core::GyroKind::Standard, + run_mp: run_mp(cruise, &loadout), + jump_mp: jump_mp(unit, &loadout), + umu_mp: 0, + // No heat budget: a vehicle that cannot fire everything is built + // wrong rather than scored down, and MegaMek's report for a tank + // carries no heat efficiency line at all. + heat_capacity: 0, + heat_sink_count: 0, + move_heat: 0, + engine_hits: 0, + has_tsm: false, + has_industrial_tsm: false, + torso_cockpit_armor: 0, + // A drone-operated vehicle is worth 0.95 of the whole, the way a Mek + // flown by one is, and its weapons are worth 0.8 apiece besides. + cockpit_modifier: if loadout.has_flag("F_DRONE_OPERATING_SYSTEM") { + 0.95 + } else { + 1.0 + }, + is_drone: loadout.has_flag("F_DRONE_OPERATING_SYSTEM"), + has_interface_cockpit: false, + has_risc_heat_sink_kit: false, + has_advanced_fire_control: true, + loadout, + }) +} + +/// How fast the vehicle is when it is trying. +/// +/// Flank is one and a half times cruise, rounded up, and sprint gear raises +/// the multiplier the way it does on a Mek - a Patton Tank with a supercharger +/// runs 8 rather than 6, which is a step of movement modifier and a fifth of +/// its offensive rating. +/// +/// Hardened plate costs a Mek a point of walking and costs a vehicle nothing: +/// a Heavy NLRM Carrier wears it and still runs 5. The `cruiseMP` a `.blk` +/// declares is what the design does with the plate already on it. +fn run_mp(cruise: i64, loadout: &Loadout<'_>) -> i64 { + let supercharger = loadout.has_flag("S_SUPERCHARGER"); + let masc = loadout.has_flag_without("F_MASC", "S_SUPERCHARGER"); + let sprint = match (masc, supercharger) { + (true, true) => 2.5, + (true, false) | (false, true) => 2.0, + (false, false) => 1.5, + }; + ((cruise.max(0) as f64) * sprint).ceil() as i64 +} + +/// How far the vehicle jumps. +/// +/// A `.blk` rarely declares it. Where a Mek writes `jump mp:`, a vehicle +/// mounts jump jets in its body and the figure is how many - which is what +/// MegaMek counts. Without this a Kanga Medium Hovertank looks like it cannot +/// jump, and the speed factor it is paid by drops from 2.3 to 1.89. +fn jump_mp(unit: &Unit, loadout: &Loadout<'_>) -> i64 { + match unit.jump_mp { + Some(mp) if mp > 0 => mp, + _ => loadout + .counts() + .iter() + .filter(|m| m.entry.has_flag("F_JUMP_JET")) + .map(|m| m.count as i64) + .sum(), + } +} + +/// What the design is worth for being the kind of vehicle it is. +/// +/// Tracks are worth nine tenths, wheels eight, anything that hovers or flies +/// seven, and anything that floats six. A hull that can cross water, drive over +/// dunes or shut itself against vacuum earns some of it back. +fn type_modifier(unit: &Unit, loadout: &Loadout<'_>) -> f64 { + let base = match unit + .motion_type + .as_deref() + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "tracked" => 0.9, + "wheeled" => 0.8, + "hover" | "vtol" | "wige" => 0.7, + _ => 0.6, + }; + let mut modifier = base; + for item in loadout.items() { + let per = if item.entry.has_flag("F_FULLY_AMPHIBIOUS") { + 0.2 + } else if [ + "F_LIMITED_AMPHIBIOUS", + "F_DUNE_BUGGY", + "F_FLOTATION_HULL", + "F_ENVIRONMENTAL_SEALING", + "F_ARMORED_MOTIVE_SYSTEM", + ] + .iter() + .any(|flag| item.entry.has_flag(flag)) + { + 0.1 + } else { + 0.0 + }; + modifier += per; + } + modifier +} + +/// The armour a `.blk` declares, which is a number rather than a name. +fn armor_kind(declared: Option<&str>) -> Result { + let code: i64 = declared + .and_then(|d| d.trim().parse().ok()) + .ok_or(Unsupported::Missing("armor type"))?; + Ok(match code { + 0 => ArmorKind::Standard, + 1 | 9 => ArmorKind::FerroFibrous, + 2 => ArmorKind::Reactive, + 3 => ArmorKind::Reflective, + 4 => ArmorKind::Hardened, + 5 => ArmorKind::LightFerroFibrous, + 6 => ArmorKind::HeavyFerroFibrous, + 7 => ArmorKind::Patchwork, + 8 | 22 => ArmorKind::Stealth, + 10 => ArmorKind::Commercial, + 14 => ArmorKind::Industrial, + 15 => ArmorKind::HeavyIndustrial, + 16 => ArmorKind::FerroLamellor, + 17 => ArmorKind::Primitive, + 23 => ArmorKind::AntiPenetrativeAblation, + 24 => ArmorKind::HeatDissipating, + 25 => ArmorKind::ImpactResistant, + 26 => ArmorKind::BallisticReinforced, + other => return Err(Unsupported::NotImplemented(armor_code_name(other))), + }) +} + +/// A name for an armour code this does not read, so a refusal says which. +fn armor_code_name(code: i64) -> &'static str { + match code { + 11..=13 => "large craft armour", + 18 => "electric discharge armour", + 19..=21 => "aerospace aluminium armour", + 27..=40 => "battle armour or ProtoMek armour", + _ => "this armour type", + } +} diff --git a/crates/helm-bv/tests/conformance.rs b/crates/helm-bv/tests/conformance.rs index 22e1715..3461daa 100644 --- a/crates/helm-bv/tests/conformance.rs +++ b/crates/helm-bv/tests/conformance.rs @@ -47,10 +47,10 @@ fn inputs() -> Option { let catalogue = helm_bridge::read_catalogue(&bridge.join("equipment.jsonl")).expect("equipment.jsonl"); - // Two sources, deliberately. units.jsonl covers the whole library, so the - // report can still say how much of it is a type this crate does not score - // yet; bv.jsonl covers Meks only but carries the working of the - // calculation, which is what lets each rating be checked on its own. + // Two kinds of source, deliberately. units.jsonl covers the whole library, + // so the report can still say how much of it is a type this crate does not + // score yet; the bv dumps cover what it does score and carry the working of + // the calculation, which is what lets each rating be checked on its own. let mut megamek: BTreeMap = helm_bridge::read_units(&bridge.join("units.jsonl")) .expect("units.jsonl") @@ -61,10 +61,16 @@ fn inputs() -> Option { Some((s.name, b)) }) .collect(); - let bv = bridge.join("bv.jsonl"); - if bv.is_file() { - for b in helm_bridge::read_bv(&bv).expect("bv.jsonl") { - megamek.insert(b.name.clone(), b); + // Meks from one file and vehicles from the other, both carrying the + // working rather than only the total. Without the second, every vehicle + // would be checked on its battle value alone and the two ratings would + // read as unmeasured for a fifth of what this crate scores. + for file in ["bv.jsonl", "bv-vehicle.jsonl"] { + let path = bridge.join(file); + if path.is_file() { + for b in helm_bridge::read_bv(&path).expect("a bv dump") { + megamek.insert(b.name.clone(), b); + } } } diff --git a/crates/helm-cli/src/main.rs b/crates/helm-cli/src/main.rs index d231b27..6b224f5 100644 --- a/crates/helm-cli/src/main.rs +++ b/crates/helm-cli/src/main.rs @@ -432,17 +432,24 @@ fn bv_report(args: &[String]) -> Result<(), String> { Some((s.name, b)) }) .collect(); - // bv.jsonl is Meks only but carries the working of MegaMek's calculation - // and not only its answer, so each rating can be checked on its own. A - // bridge dump made before it existed still checks the total. - let bv = bridge.join("bv.jsonl"); - if bv.is_file() { - for b in helm_bridge::read_bv(&bv).map_err(|e| e.to_string())? { + // The bv dumps carry the working of MegaMek's calculation and not only its + // answer, so each rating can be checked on its own - Meks in one file and + // vehicles in the other. A bridge dump made before they existed still + // checks the total. + let mut found = false; + for file in ["bv.jsonl", "bv-vehicle.jsonl"] { + let path = bridge.join(file); + if !path.is_file() { + continue; + } + found = true; + for b in helm_bridge::read_bv(&path).map_err(|e| e.to_string())? { megamek_bv.insert(b.name.clone(), b); } - } else { + } + if !found { eprintln!( - "no bv.jsonl in {}: only the total is checkable", + "no bv dump in {}: only the total is checkable", bridge.display() ); } @@ -928,7 +935,7 @@ fn print_clusters( }; let verdict = match helm_bv::battle_value(unit, catalogue) { Ok(ours) => Some(ours == theirs), - // Declining because it is not a Mek is out of scope, not a gap. + // Declining by unit type is out of scope, not a gap. Err(helm_bv::Unsupported::UnitType(_)) => continue, Err(_) => None, }; -- 2.51.2