From 69037aee95b02269aef2d6f425018384b160db55 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 4 Sep 2026 01:01:27 -0400 Subject: [PATCH] feat(ev): a missile shot at an AMS carrier takes four off its cluster roll MegaMek's own figure - `bipush -4`, three times, in `MissileWeaponHandler.getAMSHitsMod` - applied where the target mounts one and the shot is one that handler would resolve. Applied at both places a `ShotKey` is built. `volley::gather` scores the twist options and `Volley::keys` feeds the location features, and a modifier on only one of them moves half the basis with nothing failing; patching the first alone changed no figure in a match, which is how the second was found. On `ams-asymmetric.mms`, one missile boat against an Atlas that mounts an AMS and one that does not, in the same firing decision: value 3.864 against 3.178, `p_breach` 0.000320 against 0.000089, `target_breach` 0.3472 against 0.1157. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: Ie07ebea2453cd57c6f29ba7e5ce811b76dcd8425 --- crates/sds-bot/src/main.rs | 10 +++++++- crates/sds-bot/src/unit.rs | 2 +- crates/sds-core/examples/vignettes/firing.rs | 2 +- crates/sds-core/examples/vignettes/scene.rs | 2 +- crates/sds-core/src/ev.rs | 10 ++++++++ crates/sds-core/src/features/firing.rs | 22 ++++++++++++---- crates/sds-core/src/features/fixture.rs | 2 +- crates/sds-core/src/features/target.rs | 2 +- crates/sds-core/src/volley.rs | 27 ++++++++++++++++++++ crates/sds-core/tests/stands_frontier.rs | 2 +- plan/ev.md | 8 ++++-- scenarios/tactics/ams-asymmetric.mms | 25 ++++++++++++++++++ 12 files changed, 100 insertions(+), 14 deletions(-) create mode 100644 scenarios/tactics/ams-asymmetric.mms diff --git a/crates/sds-bot/src/main.rs b/crates/sds-bot/src/main.rs index 80858a2e..05bfa9d9 100644 --- a/crates/sds-bot/src/main.rs +++ b/crates/sds-bot/src/main.rs @@ -1745,7 +1745,15 @@ impl Bot { let mut cache = EvCache::new(); let mut damages = Vec::with_capacity(allocations.len()); for allocation in &allocations { - damages.push(Volley::pmf(&mut cache, me, &allocation.shots)); + // The target, because its Anti-Missile Systems change the + // distribution. Falling back to the shooter when the enemy is not + // described keeps the old answer rather than inventing one. + let at = observation + .units + .iter() + .find(|unit| unit.id == allocation.target) + .unwrap_or(me); + damages.push(Volley::pmf(&mut cache, me, &allocation.shots, at)); } let pmf_nanos = pmf_clock.elapsed().as_nanos() as u64; let spread: Vec = damages.iter().map(|pmf| pmf.mean()).collect(); diff --git a/crates/sds-bot/src/unit.rs b/crates/sds-bot/src/unit.rs index ffe1622c..d0deda48 100644 --- a/crates/sds-bot/src/unit.rs +++ b/crates/sds-bot/src/unit.rs @@ -933,7 +933,7 @@ fn measure( let level_at = |at: Coord| los.board().hex(at).floor(); let elevation = level_at(offer.end) - level_at(Coord::new(target.x, target.y)); let shots = projected_shots(me, target, range, elevation); - let damage = Volley::pmf(&mut cache, me, &shots); + let damage = Volley::pmf(&mut cache, me, &shots, target); let side = side_table(Coord::new(target.x, target.y), target.facing, offer.end); let locations = Volley::location_damage(&mut cache, me, &shots, target, side); projected.push(Some(Projection { diff --git a/crates/sds-core/examples/vignettes/firing.rs b/crates/sds-core/examples/vignettes/firing.rs index 601ab23f..ab8ee01e 100644 --- a/crates/sds-core/examples/vignettes/firing.rs +++ b/crates/sds-core/examples/vignettes/firing.rs @@ -882,7 +882,7 @@ mod platoon_scene { )) .expect("the shot parses")]; let mut cache = EvCache::default(); - let damage = Volley::pmf(&mut cache, &us, &shots); + let damage = Volley::pmf(&mut cache, &us, &shots, &them); let at = Platoon::of(&them, !in_open).expect("a platoon"); let volley = Volley { shooter: &us, diff --git a/crates/sds-core/examples/vignettes/scene.rs b/crates/sds-core/examples/vignettes/scene.rs index aa1d079f..b56c67c1 100644 --- a/crates/sds-core/examples/vignettes/scene.rs +++ b/crates/sds-core/examples/vignettes/scene.rs @@ -569,7 +569,7 @@ impl Fired { ) -> Self { let shots = shots_at(shooter, target, range); Self { - damage: Volley::pmf(cache, shooter, &shots), + damage: Volley::pmf(cache, shooter, &shots, target), locations: Volley::location_damage(cache, shooter, &shots, target, Side::Front), shots, } diff --git a/crates/sds-core/src/ev.rs b/crates/sds-core/src/ev.rs index 17f07218..394f2da2 100644 --- a/crates/sds-core/src/ev.rs +++ b/crates/sds-core/src/ev.rs @@ -161,6 +161,16 @@ pub struct ShotKey { } impl ShotKey { + /// The same key with a cluster-roll modifier applied. + /// + /// Separate from the constructors because it is a property of the *target* + /// rather than of the weapon: the same launcher fired at two machines has + /// two keys, and only one of them is facing an Anti-Missile System. + pub fn with_cluster_mod(mut self, modifier: i32) -> Self { + self.cluster_mod = modifier; + self + } + /// A weapon that puts all its damage in one location. /// /// `None` for damage that is not positive, which catches an out-of-range diff --git a/crates/sds-core/src/features/firing.rs b/crates/sds-core/src/features/firing.rs index 639978d7..8fab0d27 100644 --- a/crates/sds-core/src/features/firing.rs +++ b/crates/sds-core/src/features/firing.rs @@ -77,8 +77,8 @@ impl<'a> Volley<'a> { /// Rack sizes come from the shooter's own weapons, which is where the wire /// keeps them. A shot whose weapon is not on the shooter is dropped rather /// than guessed at. - pub fn pmf(cache: &mut EvCache, shooter: &Unit, shots: &[Shot]) -> DamagePmf { - cache.allocation(&Self::keys(shooter, shots)) + pub fn pmf(cache: &mut EvCache, shooter: &Unit, shots: &[Shot], target: &Unit) -> DamagePmf { + cache.allocation(&Self::keys(shooter, shots, target)) } /// The same shots, resolved into each of the target's locations. @@ -89,7 +89,7 @@ impl<'a> Volley<'a> { target: &Unit, side: Side, ) -> Option { - LocationDamage::new(cache, &Self::keys(shooter, shots), target, side) + LocationDamage::new(cache, &Self::keys(shooter, shots, target), target, side) } /// Memo keys for a set of shots. @@ -100,12 +100,24 @@ impl<'a> Volley<'a> { /// roll against the same number. The wire carries one shot per weapon /// because that is what gets declared; the count comes off the shooter's /// own weapon list, the same place the rack size does. - fn keys(shooter: &Unit, shots: &[Shot]) -> Vec { + /// + /// **The target is here for its Anti-Missile Systems**, which take four off + /// a missile shot's cluster roll. It is applied at both places a `ShotKey` + /// is built - here and in `volley::gather` - and the two must agree: this + /// is the pair the *location* features read, and that the one the twist + /// options are scored from. A modifier applied to only one of them moves + /// half the basis and nothing fails. + fn keys(shooter: &Unit, shots: &[Shot], target: &Unit) -> Vec { let mut out = Vec::with_capacity(shots.len()); for shot in shots { let mounted = shooter.weapons.iter().find(|w| w.id == shot.weapon); let rack = mounted.map(|w| w.rack_size).unwrap_or(0); - let Some(key) = ShotKey::from_wire(shot, rack) else { + let ams = if target.ams > 0 && mounted.is_some_and(|w| w.countered_by_ams) { + crate::volley::AMS_CLUSTER_MODIFIER + } else { + 0 + }; + let Some(key) = ShotKey::from_wire(shot, rack).map(|k| k.with_cluster_mod(ams)) else { continue; }; let shots = mounted diff --git a/crates/sds-core/src/features/fixture.rs b/crates/sds-core/src/features/fixture.rs index 392c2bc4..8e27c108 100644 --- a/crates/sds-core/src/features/fixture.rs +++ b/crates/sds-core/src/features/fixture.rs @@ -171,7 +171,7 @@ impl Fired { side: crate::los::Side, ) -> Self { Self { - damage: crate::features::firing::Volley::pmf(cache, shooter, shots), + damage: crate::features::firing::Volley::pmf(cache, shooter, shots, target), locations: crate::features::firing::Volley::location_damage( cache, shooter, shots, target, side, ), diff --git a/crates/sds-core/src/features/target.rs b/crates/sds-core/src/features/target.rs index ea2503fa..126ea90f 100644 --- a/crates/sds-core/src/features/target.rs +++ b/crates/sds-core/src/features/target.rs @@ -381,7 +381,7 @@ mod tests { // whenever it hits. let shots = vec![shot(1, 2, 4, 10.0, 3)]; let mut cache = EvCache::new(); - let damage = Volley::pmf(&mut cache, &shooter, &shots); + let damage = Volley::pmf(&mut cache, &shooter, &shots, &shooter); let wet = TargetView { incoming: Some(&damage), ..dry diff --git a/crates/sds-core/src/volley.rs b/crates/sds-core/src/volley.rs index eb528575..7d9689e3 100644 --- a/crates/sds-core/src/volley.rs +++ b/crates/sds-core/src/volley.rs @@ -215,6 +215,8 @@ pub struct MountedWeapon { /// [`crate::wire::Weapon::packets_per_location`]. pub packets_per_location: i32, pub usable: bool, + /// Whether an Anti-Missile System on the target would engage this shot. + pub countered_by_ams: bool, /// Hexes at or inside which the shot takes `minimum - distance + 1`, from /// [`crate::wire::Weapon::minimum`]. Zero is no minimum range. pub minimum: i32, @@ -268,6 +270,7 @@ impl MountedWeapon { damage_per_packet: weapon.damage_per_packet, packets_per_location: weapon.packets_per_location, usable: weapon.usable, + countered_by_ams: weapon.countered_by_ams, minimum: weapon.minimum, to_hit_modifier: weapon.to_hit_modifier, fire_mode: weapon.preferred_mode().cloned(), @@ -1088,6 +1091,9 @@ impl Gathered { /// /// Heat is summed inside the loop that already walks arcs, brackets and the /// to-hit, over exactly the weapons that survive all three. No second walk. +/// What one Anti-Missile System takes off a missile shot's cluster roll. +pub const AMS_CLUSTER_MODIFIER: i32 = -4; + pub fn gather( los_cache: &LosCache, state: &Firer, @@ -1266,6 +1272,26 @@ pub fn gather_within( if to_hit > MAX_TO_HIT { continue; } + // **An Anti-Missile System on the target subtracts four from the + // cluster roll**, which is MegaMek's own figure - `bipush -4`, three + // times, in `MissileWeaponHandler.getAMSHitsMod`. + // + // Missile shots only. AMS lives on that handler and does nothing to an + // LB-X autocannon, which is a cluster weapon and not a missile one; + // `countered_by_ams` is MegaMek's answer to which is which rather than + // ours. + // + // **Correct for the options these matches run and not in general.** + // `tacops_ams` is false and `auto_ams` true by default, which is the + // mode where AMS applies a modifier; under `tacops_ams` it shoots + // missiles down individually instead and this figure would be wrong. + // `multiuse_ams` is false, so a second system does not engage a second + // attack and the count is read as "any". + let ams = if weapon.countered_by_ams && target.unit.ams > 0 { + AMS_CLUSTER_MODIFIER + } else { + 0 + }; let key = if mode.rack_size >= 2 { ShotKey::cluster( to_hit, @@ -1273,6 +1299,7 @@ pub fn gather_within( mode.damage_per_packet, mode.packets_per_location, ) + .map(|key| key.with_cluster_mod(ams)) } else { ShotKey::single(to_hit, mode.damage) }; diff --git a/crates/sds-core/tests/stands_frontier.rs b/crates/sds-core/tests/stands_frontier.rs index d8cf9997..d1ca5637 100644 --- a/crates/sds-core/tests/stands_frontier.rs +++ b/crates/sds-core/tests/stands_frontier.rs @@ -319,7 +319,7 @@ fn a_movement_proposal_carries_every_firing_feature_but_the_named_ones() { let target = &fight.enemy_units[0]; let shots = vec![shot(1, target.id)]; let mut ev = EvCache::new(); - let damage = Volley::pmf(&mut ev, shooter, &shots); + let damage = Volley::pmf(&mut ev, shooter, &shots, target); let locations = Volley::location_damage(&mut ev, shooter, &shots, target, Side::Front); let firing = Volley { shooter, diff --git a/plan/ev.md b/plan/ev.md index 64b5f8e2..80d0aa66 100644 --- a/plan/ev.md +++ b/plan/ev.md @@ -30,8 +30,12 @@ The calculator lives in `crates/sds-core/src/ev.rs`. Nothing calls it yet. apply it to. `EvCache::location_allocation` carries the per-location compound distribution, and `hitloc` supplies the shares. See `plan/features.md` for the size of the error this removed -- [ ] Feed AMS into the cluster modifier. The key has the field and the roll - applies it; nothing on the wire says a target mounts AMS +- [x] Feed AMS into the cluster modifier, for **missile** shots. AMS applies its + -4 from `MissileWeaponHandler.getAMSHitsMod` and does nothing to an LB-X + autocannon, which is a cluster weapon and not a missile one; the wire now + carries the target's count and whether MegaMek would resolve the shot + through that handler. Correct for `tacops_ams` off, which is our default + and the mode where AMS is a modifier rather than shooting missiles down - [ ] Force-level greedy allocation against the concave value curve. Waits on force-level assignment machinery, which does not exist yet. Greedy on a submodular objective is within 1-1/e; any search over subsets is exponential diff --git a/scenarios/tactics/ams-asymmetric.mms b/scenarios/tactics/ams-asymmetric.mms new file mode 100644 index 00000000..51a3efc5 --- /dev/null +++ b/scenarios/tactics/ams-asymmetric.mms @@ -0,0 +1,25 @@ +# An Anti-Missile System on one side only, and two targets to compare. +# +# North is a missile boat with no AMS of its own. South fields an Atlas AS7-K, +# which mounts one, beside an Atlas AS7-D, which does not - the same chassis at +# the same weight, so a missile shot at each differs in the system and in +# nothing else. +# +# That is what makes the effect readable inside a single match: the two +# candidates sit in the same firing decision, at the same gunnery, and the one +# aimed at the AS7-K carries four fewer on its cluster roll. +# +# MegaMek Data (C) 2025 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# https://creativecommons.org/licenses/by-nc-sa/4.0/ +MMSVersion=1 +Name=ams-asymmetric +Description=1v2, a missile boat against one Atlas with AMS and one without. +BoardWidth=1 +BoardHeight=1 +Maps=Map Set 5/16x17 Deep Canyon 1 +Factions=North,South +Location_North=N +Location_South=S +Unit_North_1=Catapult CPLT-C1,North 1,4,5 +Unit_South_1=Atlas AS7-K,South 1,4,5 +Unit_South_2=Atlas AS7-D,South 2,4,5 -- knotmirror 0.0.1