From 91fd7d5b42593984dca52df91666116a124a8dc4 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sun, 30 Aug 2026 03:03:11 -0400 Subject: [PATCH] feat(unit-types)!: price a vehicle's mission kill on both motive rolls A flagged hit is the entry to motive damage, not the event: MegaMek then rolls 2d6 plus the arc modifier and 12 or more immobilises. On one test volley the entry reads 0.80 and the immobilisation 0.04. The column is now six times larger from the flank than head-on, which the flat version could not express at any scale. Adds the probe for what each vehicle critical costs, and dumps `los::side_table` so the arc analysis reads the bot's own answer. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: Ie8199f0c88ddd65df1bfeebb81696ee8d2ecf269 --- bridge/sds/SdsVehicleCriticals.java | 127 +++++++++++++++++++++++++ crates/sds-core/examples/sidetable.rs | 52 ++++++++++ crates/sds-core/src/features/firing.rs | 105 +++++++++++++++++--- crates/sds-core/src/hitloc.rs | 104 +++++++++++++++----- crates/sds-core/src/volley.rs | 2 +- 5 files changed, 350 insertions(+), 40 deletions(-) create mode 100644 bridge/sds/SdsVehicleCriticals.java create mode 100644 crates/sds-core/examples/sidetable.rs diff --git a/bridge/sds/SdsVehicleCriticals.java b/bridge/sds/SdsVehicleCriticals.java new file mode 100644 index 0000000..f40dc1f --- /dev/null +++ b/bridge/sds/SdsVehicleCriticals.java @@ -0,0 +1,127 @@ +package sds; + +import java.io.File; +import java.io.PrintStream; + +import megamek.common.CriticalSlot; +import megamek.common.game.Game; +import megamek.common.game.IGame; +import megamek.common.scenario.ScenarioLoader; +import megamek.common.units.Entity; +import megamek.common.units.Tank; +import megamek.server.totalWarfare.TWGameManager; + +/** + * What a combat vehicle actually has to lose, asked of MegaMek. + * + *

The evidence a scale decision needs. `value_destroyed` divides by + * {@link megamek.common.units.Entity} location contents, and a vehicle has + * none: its criticals are unit-level results, not slots. So "what is a vehicle + * location worth" cannot be answered by translating the Mek reader, and this + * asks the prior question instead - what does MegaMek take away when each + * vehicle critical lands, in units something could be priced in. + * + *

A fresh entity per critical, because criticals accumulate and one trial + * would bias the next - the same hazard {@link SdsMotiveDamage} guards against. + * A crewed, deployed entity from a real scenario, because a bare `new Tank()` + * answers nothing: that has now been the answer twice. + * + *

+ * crit <name> <applied> walk <before>-><after> run .. weapons .. bv .. flags
+ * 
+ */ +public final class SdsVehicleCriticals { + + private static final String[] NAMES = { + "DRIVER", "WEAPON_JAM", "WEAPON_DESTROYED", "STABILIZER", "SENSOR", + "COMMANDER", "CREW_KILLED", "CREW_STUNNED", "CARGO", "ENGINE", + "FUEL_TANK", "AMMO", "TURRET_JAM", "TURRET_LOCK", "TURRET_DESTROYED", + }; + private static final int[] CRITS = { + Tank.CRIT_DRIVER, Tank.CRIT_WEAPON_JAM, Tank.CRIT_WEAPON_DESTROYED, + Tank.CRIT_STABILIZER, Tank.CRIT_SENSOR, Tank.CRIT_COMMANDER, + Tank.CRIT_CREW_KILLED, Tank.CRIT_CREW_STUNNED, Tank.CRIT_CARGO, + Tank.CRIT_ENGINE, Tank.CRIT_FUEL_TANK, Tank.CRIT_AMMO, + Tank.CRIT_TURRET_JAM, Tank.CRIT_TURRET_LOCK, Tank.CRIT_TURRET_DESTROYED, + }; + + public static void main(String[] argv) throws Exception { + String path = null; + for (int i = 0; i < argv.length; i++) { + if ("--scenario".equals(argv[i])) { + path = argv[++i]; + } + } + PrintStream out = java.lang.System.out; + out.printf("# megamek %s%n", megamek.SuiteConstants.VERSION); + + Tank sample = tank(load(path)); + out.printf( + "baseline %s walk=%d run=%d weapons=%d bv=%d tons=%.0f crew=%s deployed=%s%n", + sample.getShortName(), sample.getWalkMP(), sample.getRunMP(), + usable(sample), sample.calculateBattleValue(), sample.getWeight(), + sample.getCrew() != null, sample.isDeployed()); + + for (int i = 0; i < CRITS.length; i++) { + Game game = load(path); + Tank t = tank(game); + t.setDeployed(true); + TWGameManager manager = new TWGameManager(); + manager.setGame(game); + + int walk0 = t.getWalkMP(); + int run0 = t.getRunMP(); + int weapons0 = usable(t); + int bv0 = t.calculateBattleValue(); + + String applied = "ok"; + try { + manager.applyCriticalHit( + t, Tank.LOC_FRONT, + new CriticalSlot(CriticalSlot.TYPE_SYSTEM, CRITS[i]), + true, 0, false); + } catch (Throwable problem) { + applied = "THREW(" + problem.getClass().getSimpleName() + ")"; + } + + out.printf( + "crit %-17s %-22s walk %d->%d run %d->%d weapons %d->%d bv %d->%d%n", + NAMES[i], applied, walk0, t.getWalkMP(), run0, t.getRunMP(), + weapons0, usable(t), bv0, t.calculateBattleValue()); + out.printf( + " flags engineHit=%-5s sensors=%-2d turretLocked=%-5s crewHits=%-2d crewDead=%-5s immobile=%-5s destroyed=%s%n", + t.isEngineHit(), t.getSensorHits(), t.isTurretLocked(Tank.LOC_TURRET), + t.getCrew() == null ? -1 : t.getCrew().getHits(), + t.getCrew() != null && t.getCrew().isDead(), + t.isImmobile(), t.isDestroyed()); + } + } + + /** Weapons the unit could still fire. */ + private static int usable(Entity e) { + int n = 0; + for (megamek.common.equipment.WeaponMounted w : e.getWeaponList()) { + if (!w.isDestroyed() && !w.isJammed() && !w.isBreached()) { + n++; + } + } + return n; + } + + private static Tank tank(Game game) { + for (Entity e : game.getEntitiesVector()) { + if (e instanceof Tank t) { + return t; + } + } + throw new IllegalStateException("no Tank in the scenario"); + } + + private static Game load(String path) throws Exception { + IGame game = new ScenarioLoader(new File(path)).load().createGame(); + if (!(game instanceof Game tw)) { + throw new IllegalStateException(path + " is not a Total Warfare scenario"); + } + return tw; + } +} diff --git a/crates/sds-core/examples/sidetable.rs b/crates/sds-core/examples/sidetable.rs new file mode 100644 index 0000000..8537319 --- /dev/null +++ b/crates/sds-core/examples/sidetable.rs @@ -0,0 +1,52 @@ +//! Dump `los::side_table` as a lookup table, for offline analysis. +//! +//! **So that nothing reimplements it.** Which arc a shot arrives in decides +//! how a vehicle's motive damage is priced, so a measurement of "did the bot +//! shoot from the flank" has to use the same answer the bot used. Porting the +//! geometry into an analysis script would be a second instrument, and the two +//! agreeing would prove nothing about either - the failure this repository has +//! already paid for more than once. +//! +//! So the analysis reads a table instead, and this writes it from the function +//! the bot itself calls. Hex geometry depends on the column's parity, not only +//! on the offset, so the key carries it. +//! +//! `cargo run -p sds-core --example sidetable > sides.txt` +//! +//! Lines are `dx dy target_x_parity facing side`. +use sds_core::los::{side_table, Side}; +use sds_core::wire::Coord; + +fn main() { + const REACH: i32 = 40; + println!("# dx dy parity facing side"); + // A fixed origin far enough from the edges that both parities are reachable. + for parity in 0..2 { + let tx = 100 + parity; + let ty = 100; + for dx in -REACH..=REACH { + for dy in -REACH..=REACH { + if dx == 0 && dy == 0 { + continue; + } + for facing in 0..6 { + let side = side_table( + Coord { x: tx, y: ty }, + facing, + Coord { + x: tx + dx, + y: ty + dy, + }, + ); + let name = match side { + Side::Front => "front", + Side::Left => "left", + Side::Right => "right", + Side::Rear => "rear", + }; + println!("{dx} {dy} {parity} {facing} {name}"); + } + } + } + } +} diff --git a/crates/sds-core/src/features/firing.rs b/crates/sds-core/src/features/firing.rs index 0ebcc9d..adef115 100644 --- a/crates/sds-core/src/features/firing.rs +++ b/crates/sds-core/src/features/firing.rs @@ -250,11 +250,12 @@ impl<'a> Volley<'a> { p_mission_kill: match self.locations { Some(locations) if locations.body().is_vehicle() => { // A vehicle is not crippled through a location. It is - // crippled through its motive system, and the hit table - // flags which rolls reach one - see - // [`hitloc::LocationDamage::motive`] for what this does and - // does not claim. - locations.motive().unwrap_or(0.0) + // crippled through its motive system: a hit lands on a + // flagged roll 13 times in 36, and MegaMek then rolls + // `2d6 + arc` where 12 or more immobilises. Both rolls, not + // just the first - see + // [`hitloc::LocationDamage::motive_immobilised`]. + locations.motive_immobilised().unwrap_or(0.0) } Some(locations) => locations.any_destroyed(&Self::CRIPPLING), // No legs to model and no motive system to ask about. Zero @@ -2160,6 +2161,66 @@ mod tests { ); } + #[test] + fn a_vehicle_is_easier_to_cripple_from_the_flank() { + // **The capability the calibration adds**, and the reason it is not + // merely a smaller number. Motive damage takes +0 from the front, +1 + // from the rear and +2 from either side, so the same volley is six + // times likelier to immobilise from the flank than head-on. The flat + // version could not express that at any scale: it read the same value + // from every arc. + let (shooter, target) = pair(); + let tank = tank(&target); + let shots: Vec = (0..4).map(|_| shot(1, 2, 4, 10.0, 3)).collect(); + let mut cache = EvCache::new(); + + let mut odds = Vec::new(); + for side in [Side::Front, Side::Rear, Side::Left] { + let fired = Fired::new(&mut cache, &shooter, &shots, &tank, side); + let volley = fired.volley(&shooter, &tank, &shots, side); + odds.push(volley.projected().p_mission_kill); + } + let (front, rear, flank) = (odds[0], odds[1], odds[2]); + assert!(front > 0.0, "the front arc still reaches the motive system"); + assert!(rear > front, "rear {rear} should beat front {front}"); + assert!(flank > rear, "flank {flank} should beat rear {rear}"); + // One in thirty-six against six in thirty-six per flagged hit, so the + // gap is large rather than a rounding. + assert!(flank > front * 3.0, "flank {flank} vs front {front}"); + } + + #[test] + fn the_calibrated_column_is_on_the_same_scale_as_a_meks() { + // The failure this replaces: `p_mission_kill` read a mean of 0.688 + // against a vehicle and 0.0004 against a Mek, so the column meant two + // different things depending on what was being shot at. It cannot be + // asserted that they are equal - they are different mechanisms - but a + // vehicle must no longer be two orders of magnitude above a Mek on the + // same volley. + let (shooter, target) = pair(); + let tank = tank(&target); + let shots: Vec = (0..4).map(|_| shot(1, 2, 4, 10.0, 3)).collect(); + let mut cache = EvCache::new(); + + let on_mek = Fired::new(&mut cache, &shooter, &shots, &target, Side::Front); + let mek = on_mek + .volley(&shooter, &target, &shots, Side::Front) + .projected(); + let on_tank = Fired::new(&mut cache, &shooter, &shots, &tank, Side::Front); + let vehicle = on_tank + .volley(&shooter, &tank, &shots, Side::Front) + .projected(); + + // **A regression bound, not a rule.** The two are different mechanisms + // and cannot be asserted equal; what can be asserted is that a vehicle + // no longer sits two orders of magnitude above a Mek on one volley, + // which is what the flat column did. 0.2 is a ceiling chosen to catch + // that failure returning, and it is a fixture-dependent number - a big + // enough volley would exceed it honestly. + assert!(vehicle.p_mission_kill < 0.2, "{}", vehicle.p_mission_kill); + assert!(mek.p_mission_kill < 0.2, "{}", mek.p_mission_kill); + } + #[test] fn a_kick_against_a_vehicle_prices_through_the_same_table() { // On the path to the acceptance test: a Mek kicks a tank to death. @@ -2227,14 +2288,32 @@ mod tests { let on_tank = Fired::new(&mut cache, &shooter, &shots, &tank, Side::Front); let vehicle = on_tank.volley(&shooter, &tank, &shots, Side::Front); - let motive = vehicle - .locations - .expect("a vehicle") - .motive() - .expect("a motive system"); - assert!(motive > 0.0 && motive < 1.0, "{motive}"); - assert_eq!(vehicle.projected().p_mission_kill, motive); - assert_ne!(vehicle.projected().p_kill, motive); + let locations = vehicle.locations.expect("a vehicle"); + let entry = locations.motive().expect("a motive system"); + let immobilised = locations.motive_immobilised().expect("a motive system"); + assert!(entry > 0.0 && entry < 1.0, "{entry}"); + // **The second roll is the whole calibration.** A hit reaching the + // motive system is common and breaking it is not, and reporting the + // first as though it were the second is what made this column + // near-constant across every vehicle target. + // + // The gap is checked against the *arc's own* odds rather than a round + // number. A fixed ratio here would have been true of this fixture and + // not of the rule: from the front a major result needs 12 on 2d6 and + // the two figures are about 36 apart, but from a flank it needs 10 and + // they are about 6 apart - so `immobilised < entry / 10` passes here + // and fails on a side shot, for no reason the reader could see. + let major = hitloc::motive_major_odds(hitloc::Body::Vehicle, Side::Front); + assert!( + immobilised < entry, + "breaking the motive system cannot beat reaching it: {immobilised} vs {entry}" + ); + assert!( + immobilised < entry * major * 4.0, + "{immobilised} is too close to {entry} for a front arc at {major}" + ); + assert_eq!(vehicle.projected().p_mission_kill, immobilised); + assert_ne!(vehicle.projected().p_kill, immobilised); } #[test] diff --git a/crates/sds-core/src/hitloc.rs b/crates/sds-core/src/hitloc.rs index 9db7005..2358171 100644 --- a/crates/sds-core/src/hitloc.rs +++ b/crates/sds-core/src/hitloc.rs @@ -352,6 +352,42 @@ const ROLL_WAYS: [u32; 11] = [1, 2, 3, 4, 5, 6, 5, 4, 3, 2, 1]; /// The total the ways sum to. Every share in this module is out of this. pub const WAYS: u32 = 36; +/// The modifier a motive damage roll takes for the arc the hit arrived from, +/// by `row(side)`, from `Entity.getMotiveSideMod`. +/// +/// **This is what makes a vehicle's vulnerability directional.** A Mek's +/// crippling locations are its legs and the hit tables do not much favour a leg +/// from one arc over another; a vehicle's motive system is six times more +/// likely to fail from the flank than head-on, and that difference is a whole +/// modifier rather than a rounding. +#[rustfmt::skip] +const MOTIVE_SIDE_MOD: [i32; 4] = [ + /* front */ 0, + /* left */ 2, + /* right */ 2, + /* rear */ 1, +]; + +/// The 2d6 total a motive damage roll must reach for *major* damage, which is +/// the result that immobilises the vehicle. +/// +/// From `sds motive-damage`, recorded in `docs/livefire/motive-damage.txt`: the +/// outcome is a pure function of `2d6 + modifier`, verified across all 44 cells +/// of a four-modifier sweep, and 12 or more is "Major damage, vehicle +/// immobile". Below it are heavy, moderate, minor and nothing. +const MOTIVE_MAJOR_TOTAL: i32 = 12; + +/// `P(one motive-flagged hit from this arc immobilises the vehicle)`. +/// +/// One in thirty-six from the front, three from the rear, six from either +/// flank. Zero for a body plan with no motive system. +pub fn motive_major_odds(body: Body, side: Side) -> f32 { + if !body.is_vehicle() { + return 0.0; + } + crate::wire::hit_chance(MOTIVE_MAJOR_TOTAL - MOTIVE_SIDE_MOD[row(side)]) +} + /// The table this body plan rolls on. fn table(body: Body) -> &'static [[usize; 11]; 4] { match body { @@ -1100,6 +1136,9 @@ tank-noturret rear 12 RR critical"; #[derive(Debug, Clone)] pub struct LocationDamage { damage: [DamagePmf; COUNT], + /// The arc the shots arrived in. Kept because motive damage is priced by + /// arc and the profile has already folded the facing into its thresholds. + side: Side, /// Damage arriving on rolls flagged for motive damage. `None` on a body /// plan that has no motive system, which is not the same as a plan whose /// motive system this volley happens to miss - the second is @@ -1259,6 +1298,7 @@ impl LocationDamage { Self { damage, + side, motive, profile: *profile, } @@ -1269,38 +1309,50 @@ impl LocationDamage { self.profile.body } - /// `P(this volley lands a hit that rolls for motive damage)`, or `None` on a - /// body plan with no motive system. + /// `P(this volley immobilises the vehicle through motive damage)`, or + /// `None` on a body plan with no motive system. + /// + /// **The vehicle's answer to a Mek losing a leg**, which is what + /// `p_mission_kill` is named for: both are the unit finished as a + /// manoeuvring machine without necessarily being dead. /// - /// **It is not `P(immobilised)` and must not be read as one.** MegaMek - /// flags the roll and then rolls again on a result table this repository has - /// never dumped, where minor, moderate, heavy and major are four different - /// outcomes and only the last two stop the vehicle manoeuvring. What is - /// measured here is the flagged hit arriving, which is an upper bound on - /// the crippling half of it and an honest one. + /// Two rolls, not one. A hit lands on a motive-flagged result 13 times in + /// 36, and MegaMek then rolls again: `2d6 + arc modifier`, where 12 or more + /// is major damage and immobilises. So the flagged hit is the *entry* to + /// motive damage and not the event itself, and the difference is large - + /// this used to report the entry, which read a mean of 0.688 over 9,249 + /// real firing candidates and was non-zero on 99.9% of them. Near-constant + /// across vehicle targets is worse than merely overstated: a column that + /// does not vary within a decision teaches a difference-framed fit nothing. /// - /// **The result table is now dumped** - `sds motive-damage`, recorded in - /// `docs/livefire/motive-damage.txt` - so the multiplier this wants is - /// known: the outcome is a pure function of `2d6 + arc modifier`, major - /// damage immobilises at 12 or more, and the arc is +0 from the front, +1 - /// from the rear and +2 from either side. That makes P(immobilised) per - /// flagged hit 1/36, 3/36 and 6/36. Applying it is a behaviour change to a - /// weighted column and belongs in its own branch with its own measurement; - /// see `plan/unit-types.md`. + /// **Where the approximation is.** The number of flagged packets is a + /// distribution and this raises `1 - q` to its *mean*. `(1-q)^n` is convex + /// in `n`, so by Jensen the true value is slightly *below* what this + /// returns - it errs high, unlike [`Self::any_destroyed`], which errs low. + /// Both errors are small at the probabilities that matter and both are + /// stated rather than hidden. + pub fn motive_immobilised(&self) -> Option { + let pmf = self.motive.as_ref()?; + let major = motive_major_odds(self.profile.body, self.side); + let packets = pmf.expected_packets().max(0.0); + Some((1.0 - (1.0 - major).powf(packets)).clamp(0.0, 1.0)) + } + + /// `P(this volley lands a hit that rolls for motive damage)`, or `None` on a + /// body plan with no motive system. /// - /// **How loose the bound is, measured.** Over 9,249 firing candidates in a - /// real run this reads a mean of 0.688 against a vehicle and `p_mission_kill` - /// reads 0.0004 against a Mek, non-zero on 99.9% of vehicle candidates - /// against 13.3% of Mek ones. Both belong in that feature - a Mek losing a - /// leg and a vehicle losing its motive system are the same event to the - /// thing choosing a target - but they are not yet on the same scale, and a - /// weight fitted over a vehicle suite would be fitting the gap. See - /// `plan/unit-types.md`. + /// **The entry to motive damage, not the event.** A flagged hit means + /// MegaMek rolls again; [`Self::motive_immobilised`] is the number a + /// feature should read, and this is the half of it that comes off the hit + /// table. Kept because the two answer different questions and a diagnostic + /// that could not tell "no hit reached the motive system" from "one did and + /// the second roll was kind" would be the same conflation this module was + /// rebuilt to remove. /// /// `None` rather than `0.0` for a Mek. A Mek has no motive system, so no /// roll against one can land - and a reader that cannot tell that apart - /// from a vehicle this volley missed is the failure this module was rebuilt - /// to remove. + /// from a vehicle this volley missed is the failure this module exists to + /// prevent. pub fn motive(&self) -> Option { self.motive.as_ref().map(|pmf| pmf.at_least(1)) } diff --git a/crates/sds-core/src/volley.rs b/crates/sds-core/src/volley.rs index d3f28ee..5c4c8e2 100644 --- a/crates/sds-core/src/volley.rs +++ b/crates/sds-core/src/volley.rs @@ -1150,7 +1150,7 @@ pub fn solve(ev: &mut EvCache, key: &VolleyKey) -> VolleyOutcome { // this path at all. p_kill: locations.any_lethal(), p_mission_kill: if locations.body().is_vehicle() { - locations.motive().unwrap_or(0.0) + locations.motive_immobilised().unwrap_or(0.0) } else { locations.any_destroyed(&[hitloc::LL, hitloc::RL]) }, -- 2.51.2