From efd5a241d17283cfd2e08421dd025e3b84f536f5 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 28 Aug 2026 21:09:07 -0400 Subject: [PATCH] feat(ev): internal damage and mean packet, the two halves of a critical `waste_above` with a location's armour as the threshold is the damage that gets inside, and `DamagePmf::mean_packet` is what one hit carries; the critical table is 22/36 per internal hit. Nothing measures off them yet. --- crates/sds-core/src/ev.rs | 69 +++++++++++++++++++-- crates/sds-core/src/hitloc.rs | 112 ++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 5 deletions(-) diff --git a/crates/sds-core/src/ev.rs b/crates/sds-core/src/ev.rs index b454ab5..2050e05 100644 --- a/crates/sds-core/src/ev.rs +++ b/crates/sds-core/src/ev.rs @@ -490,11 +490,13 @@ impl DamagePmf { /// A distribution that usually falls short still wastes damage on the rolls /// where it does not, and that is the waste worth counting. /// - /// **Nothing in the basis reads this now.** `overkill` was the one caller - /// and it is a probability rather than a share of damage - see - /// [`crate::features::firing::Overkill`]. Kept because the argument above - /// is the expensive half of rebuilding a waste feature, not because - /// anything is measured off it. + /// **What reads it.** + /// [`crate::hitloc::LocationDamage::internal_damage`], with the location's + /// armour as the threshold: damage past the armour is not waste at all, it + /// is what rolls for criticals, and it is the same arithmetic seen from the + /// other side. `overkill` was the original caller and no longer is - it is + /// a probability rather than a share of damage, see + /// [`crate::features::firing::Overkill`]. pub fn waste_above(&self, threshold: i32) -> f32 { if threshold >= MAX_DAMAGE { return 0.0; @@ -552,6 +554,27 @@ impl DamagePmf { pub fn expected_packets(&self) -> f32 { self.expected_packets } + + /// What one landing packet of this allocation carries, on average. + /// + /// [`Self::mean`] over [`Self::expected_packets`]. Both sums run over the + /// same shots, so this is the packet-weighted mean packet size: a rack of + /// ten one-point missiles counts ten times and the autocannon beside it + /// once. A single-weapon allocation gets that weapon's packet exactly. + /// + /// The other half of the punch-against-sandblast pair, and the half nothing + /// had needed until a question was asked *per hit* rather than per point - + /// see [`crate::hitloc::LocationDamage::expected_criticals`], which is that + /// question and which names the approximation a mixed volley makes this. + /// + /// Zero when nothing is expected to land, which is a refusal to divide and + /// not a claim that the packets are small. + pub fn mean_packet(&self) -> f32 { + if self.expected_packets <= 0.0 { + return 0.0; + } + self.mean() / self.expected_packets + } } /// How much work the cache saved. Counters, not timings - nothing here may @@ -1037,6 +1060,42 @@ mod waste_tests { at(&[(damage, 1.0)]) } + /// One weapon type, so the mean packet is that weapon's packet exactly. + #[test] + fn one_weapon_gets_its_own_packet_back() { + let mut cache = EvCache::new(); + let ac20 = ShotKey::single(4, 20.0).expect("a real shot"); + let alone = cache.allocation(&[ac20]); + assert!((alone.mean_packet() - 20.0).abs() < 1e-4, "{alone:?}"); + // Four of them do not change what one packet carries. + let four = cache.allocation(&[ac20, ac20, ac20, ac20]); + assert!((four.mean_packet() - 20.0).abs() < 1e-4); + } + + /// Weighted by packets thrown, not by weapons fired. + /// + /// An LRM-20 lands about twelve one-point missiles for a gunnery-4 shooter + /// and the AC/20 beside it lands one twenty-point slug, so the mean packet + /// sits far nearer the missile than the midpoint of 10.5 that counting + /// weapons would give. + #[test] + fn a_mixed_volley_is_weighted_by_packets_and_not_by_weapons() { + let mut cache = EvCache::new(); + let ac20 = ShotKey::single(4, 20.0).expect("a real shot"); + let lrm = ShotKey::cluster(4, 20, 1.0).expect("a real rack"); + let mixed = cache.allocation(&[ac20, lrm]); + let packet = mixed.mean_packet(); + assert!(packet > 1.0 && packet < 10.5, "{packet}"); + // And it is the ratio it claims to be. + assert!((packet - mixed.mean() / mixed.expected_packets()).abs() < 1e-5); + } + + /// Nothing landing is a refusal to divide, not a small packet. + #[test] + fn a_volley_that_lands_nothing_has_no_packet() { + assert_eq!(DamagePmf::nothing().mean_packet(), 0.0); + } + #[test] fn nothing_is_wasted_below_the_threshold() { assert!(certain(5).waste_above(10).abs() < 1e-6); diff --git a/crates/sds-core/src/hitloc.rs b/crates/sds-core/src/hitloc.rs index 356e394..b7caf34 100644 --- a/crates/sds-core/src/hitloc.rs +++ b/crates/sds-core/src/hitloc.rs @@ -53,6 +53,20 @@ pub const RL: usize = 6; /// Index of the left leg. pub const LL: usize = 7; +/// Criticals expected from one hit into internal structure. +/// +/// A hit past a location's armour rolls 2d6 on the critical hits table: 2-7 is +/// nothing, 8-9 is one critical, 10-11 is two, and 12 is three or the limb +/// comes off. That is `(9x1 + 5x2 + 1x3) / 36`, and the chance of *any* is +/// 15/36 - the expectation is what this is, because a count is what the basis +/// wants and a probability of at least one would throw the tail away. +/// +/// Normal hits only. Punch, kick and floating criticals roll on their own +/// tables, and neither armour-piercing nor tandem-charge rounds obey this one +/// at all - see [`LocationDamage::expected_criticals`] for the whole exclusion +/// list. +pub const CRITICALS_PER_INTERNAL_HIT: f32 = 22.0 / 36.0; + /// A location index for an abbreviation, or `None` for one no Mek has. pub fn index_of(abbreviation: &str) -> Option { LOCATIONS.iter().position(|name| *name == abbreviation) @@ -450,6 +464,21 @@ rear 12 HD"; assert!((odds[CT] - 7.0 / 36.0).abs() < 1e-6); assert!((odds.iter().sum::() - 1.0).abs() < 1e-5); } + + /// The critical table, counted off 2d6 the way a player would. + /// + /// Nine ways to roll 8 or 9 for one critical, five to roll 10 or 11 for + /// two, and one to roll 12 for three. If this constant moves, the rule + /// moved. + #[test] + fn the_critical_table_is_twenty_two_thirty_sixths() { + let ways = 9.0 * 1.0 + 5.0 * 2.0 + 1.0 * 3.0; + assert!((CRITICALS_PER_INTERNAL_HIT - ways / 36.0).abs() < 1e-6); + // And it is the expectation rather than the 15-in-36 chance of any, + // which is the confusion this constant is easiest to make. + let any = (9.0 + 5.0 + 1.0) / 36.0; + assert!((CRITICALS_PER_INTERNAL_HIT - any).abs() > 0.19); + } } /// What a volley does to each of a target's locations. @@ -597,6 +626,89 @@ impl LocationDamage { self.damage[location].at_least(self.profile.to_bare[location]) } + /// Expected damage into one location's internal structure. + /// + /// `E[max(0, damage - to_bare)]`, off that location's own distribution + /// rather than off its mean: a volley that usually falls short of the + /// armour still gets inside on the rolls where it does not, and + /// `max(0, mean - to_bare)` throws exactly those away. + /// [`DamagePmf::waste_above`] is that arithmetic and had no caller. + pub fn internal_damage(&self, location: usize) -> f32 { + if !self.profile.live[location] { + return 0.0; + } + self.damage[location].waste_above(self.profile.to_bare[location]) + } + + /// Expected damage into internal structure, over the whole target. + /// + /// Summed in a fixed order over the eight marginals, the same shape + /// [`Self::any_overflowed`] and `expected_breaches` take. + pub fn internal_total(&self) -> f32 { + (0..COUNT).map(|at| self.internal_damage(at)).sum() + } + + /// How many criticals this volley is expected to roll. + /// + /// [`Self::internal_total`] divided by what one packet carries, times + /// [`CRITICALS_PER_INTERNAL_HIT`]. `mean_packet` is + /// [`DamagePmf::mean_packet`] of the same allocation; zero or less reads + /// zero rather than dividing. + /// + /// # The one approximation, stated rather than hidden + /// + /// **The roll is per hit, not per point of damage**, and a volley of mixed + /// weapons has no single packet size. Dividing internal damage by the + /// packet-weighted mean packet is exact for a volley of one weapon type and + /// an approximation for any other, and it is the same figure per location + /// as it is over the target: each packet picks its location independently, + /// so the share cancels out of the weighting. + /// + /// It errs low for a volley that mixes a big gun with a rack, because the + /// big gun's damage is charged against a packet larger than the rack's and + /// smaller than its own. + /// + /// # What is deliberately not in it + /// + /// - **Through-armour criticals.** A natural 2 on the location table is a + /// critical without breaching, but it takes a confirmation roll to stand, + /// so it is worth less than the raw 1/36 the table suggests. How much less + /// is not guessed at here; it is left out and named. + /// - **Armour-piercing and tandem-charge rounds**, which change the rule + /// rather than the numbers. + /// - **What a critical hits.** An engine slot and a hand actuator are not + /// the same event. [`crate::wire::Location::value`] is the obvious + /// weighting and the wrong one: its own documentation says the figures are + /// round numbers chosen to rank locations and that nothing downstream + /// should read them as calibrated. This is a count, and the fitting + /// decides what a count is worth. + /// + /// Damage past what a location can hold is counted here, though in the + /// rules it transfers inward and rolls its criticals in the next location + /// along. Over a whole target that moves where a critical lands and not how + /// many there are, and this is a count over the whole target. + /// + /// # One inherited assumption, and where it bites + /// + /// A packet is this module's unit of a hit-location roll, and it is + /// therefore the unit of a critical roll too. Whether a rack of missiles + /// rolls one location per missile or one per group of them is MegaMek's + /// answer to give and is not settled anywhere in this repository; every + /// feature reading [`LocationDamage`] already takes the per-packet reading, + /// and this one takes it too rather than deciding the question on its own. + /// + /// It is worth naming here because this is the first place the assumption + /// is a *multiplier* rather than a nuance: `p_breach` asks only whether a + /// plate came off, and a count of criticals scales directly with how many + /// packets the model thinks a rack throws. A missile boat's reading moves + /// with the answer; a direct-fire volley's does not move at all. + pub fn expected_criticals(&self, mean_packet: f32) -> f32 { + if mean_packet <= 0.0 { + return 0.0; + } + self.internal_total() / mean_packet * CRITICALS_PER_INTERNAL_HIT + } + /// `P(at least one of these locations is destroyed)`, taking them to fall /// independently. /// -- 2.54.0