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])
},