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