Cut 12.2 Stryker fix batch: one range, five real gaps

The first Stryker pass (375 mutants, 69 survivors on this cut's own changed
lines) found one real split-authority bug and several untested edges:

- HitProbability recomputed target.Position - source.Position a second time
  for PSpread's range, a second place the same subtraction could flip sign.
  PFire now returns its own gate-computed range by out parameter; HitProbability
  reads that one value instead of a second copy.
- TravelDirection's point-blank fallback and Fire's null-target guard around it
  were only ever exercised at shooter/target pairs where the real direction
  happened to equal the (0,1) fallback by coincidence.
- diagnostic.PFire (presentation) was only ever checked at PSensor == 1, where
  Accuracy * PSensor and Accuracy / PSensor agree.
- CommitProbability's four-factor product was only ever checked with
  pDeviation == 1 (no fixture moved a target between fire and commit), where
  * and / by 1 agree.
- ResolveAimPoint's zero-cells fallback (an aimed item that no longer occupies
  any cell) was never reached; every fixture's marker survives to be hit.

New tests: TravelDirectionMatchesThePredictedInterceptAndFallsBackAtPointBlank,
DiagnosticPFireIsAccuracyTimesPSensor, CommitProbabilityIsTheExactProductOfItsFourFactors,
AimPointFallsBackWhenTheAimedItemNoLongerOccupiesAnyCell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-24 21:25:15 +02:00
commit efe4f7c954
2 changed files with 123 additions and 6 deletions

View file

@ -152,13 +152,20 @@ public static class FireControl
// freezes (R10), gated exactly as HitProbability gates. Nothing about the target's facing or silhouette
// enters here; that is Silhouette's and PSpread's job, priced fresh by HitProbability below and again,
// live, by Commit at the commit tick. 0 the moment any gate closes.
private static float PFire(Weapon weapon, Entity source, Entity target)
private static float PFire(Weapon weapon, Entity source, Entity target) => PFire(weapon, source, target, out _);
// The `out range` overload is HitProbability's own gate call -- one computation of `target.Position -
// source.Position`, not two. HitProbability used to recompute its own copy for PSpread, which is exactly
// the kind of split authority Cut 3 named as the risk (two places computing the same vector, free to
// drift or to have their subtraction order flipped in only one of them).
private static float PFire(Weapon weapon, Entity source, Entity target, out float range)
{
range = 0f;
if (target == null) return 0f;
if (!source.VisibleEntities.Contains(target)) return 0f;
var toTarget = target.Position - source.Position;
var range = length(toTarget);
range = length(toTarget);
if (range < weapon.MinRange || range > weapon.Range) return 0f;
if (weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked) return 0f;
if (!InArc(weapon.Item, toTarget)) return 0f;
@ -170,10 +177,9 @@ public static class FireControl
public static float HitProbability(Weapon weapon, Entity source, Entity target)
{
var pFire = PFire(weapon, source, target);
var pFire = PFire(weapon, source, target, out var range);
if (pFire <= 0f) return 0f;
var range = length(target.Position - source.Position);
var targetHull = source.ItemManager.GetData(target.Hull) as HullData;
var bearing = Bearing(target, TravelDirection(weapon, source, target));
var sil = Silhouette(target, targetHull, source.ResolvedTargetItem, bearing, Precision(source));

View file

@ -82,7 +82,7 @@ public sealed class FireControlCut12Tests : IDisposable
GameplaySettings settings, Shape hullShape, float precision,
float velocity = 0, float spread = 0, float2? targetFacing = null,
bool equipShield = false, float shieldCapacity = 30,
int2[] markerCells = null)
int2[] markerCells = null, float accuracy = 1f)
{
var hullData = new HullData
{
@ -122,7 +122,7 @@ public sealed class FireControlCut12Tests : IDisposable
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new TargetingSystemData
{
Accuracy = Constant(1f), Resolution = Constant(1000f), Precision = Constant(precision), Tracking = Constant(100000f)
Accuracy = Constant(accuracy), Resolution = Constant(1000f), Precision = Constant(precision), Tracking = Constant(100000f)
} }
});
cache.Upsert(new GearData
@ -282,6 +282,117 @@ public sealed class FireControlCut12Tests : IDisposable
return best.Value;
}
// Stryker survivors on this cut's own changed lines (2026-09-24 run): TravelDirection's point-blank
// ternary (both directions), and Fire's target-null guard around it, were never actually exercised at a
// shooter/target pair where the real direction could not coincidentally equal the (0,1) fallback. Kills:
// forcing either ternary branch; skipping the null-target guard (which would NRE on PredictedIntercept).
[Fact]
public void TravelDirectionMatchesThePredictedInterceptAndFallsBackAtPointBlank()
{
var e = Build(TestSettings(), SolidShape(5, 5), precision: 1f);
// A diagonal placement -- neither axis-aligned with the fallback (0,1), so a mutant that always
// returns the fallback (or always skips the null-target guard) cannot pass by coincidence.
e.Shooter.Position = float3(10, 0, -5);
e.Target.Position = float3(40, 0, 65);
var expected = normalize((e.Target.Position - e.Shooter.Position).xz);
var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var pending = e.Zone.PendingShots.Single(s => s.ShotId == shotId);
Assert.Equal(expected.x, pending.TravelDirection.x, 4);
Assert.Equal(expected.y, pending.TravelDirection.y, 4);
// Point-blank: shooter and target at the exact same position (weapon Velocity 0, so PredictedIntercept
// returns target.Position exactly) -- TravelDirection must fall back rather than normalize a zero
// vector into NaN.
e.Target.Position = e.Shooter.Position;
var pointBlankId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var pointBlank = e.Zone.PendingShots.Single(s => s.ShotId == pointBlankId);
Assert.Equal(0f, pointBlank.TravelDirection.x);
Assert.Equal(1f, pointBlank.TravelDirection.y);
// No target: Fire's own null-target branch must use the fallback directly, not call TravelDirection
// (which reads target.Position and would NRE).
e.Shooter.Target.Value = null;
var noTargetId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var noTarget = e.Zone.PendingShots.Single(s => s.ShotId == noTargetId);
Assert.Equal(0f, noTarget.TravelDirection.x);
Assert.Equal(1f, noTarget.TravelDirection.y);
}
// Stryker survivor: diagnostic.PFire (Accuracy * PSensor, an optional presentation field) was never
// checked at a PSensor strictly between 0 and 1, so Accuracy * PSensor and Accuracy / PSensor (the
// mutant) were indistinguishable at PSensor == 1 (the fixture's usual full-reveal convention). Resolution
// 1000 gives an razor-thin demand window (ceiling ~= .1009 against a .1 threshold); info exactly halfway
// through it gives PSensor exactly .5.
[Fact]
public void DiagnosticPFireIsAccuracyTimesPSensor()
{
var e = Build(TestSettings(), SolidShape(6, 12), precision: .6f);
e.Shooter.EntityInfoGathered[e.Target] = .10045f; // halfway through the [.1, .1009] demand window
var d = FireControl.Inspect(e.Weapon, e.Shooter, e.Target);
Assert.True(d.PSensor > .05f && d.PSensor < .95f, $"fixture: PSensor ({d.PSensor}) must be strictly between 0 and 1");
Assert.Equal(d.Accuracy * d.PSensor, d.PFire, 4);
}
// Stryker survivors: CommitProbability's own product (PFire * pDeviation * PSpread * pOnHull) was only
// ever exercised with pDeviation exactly 1 (a stationary target relative to its fire-time projection),
// which makes "* pDeviation" and "/ pDeviation" (and boundary flips around 0) indistinguishable. A
// synthetic PendingShot (bypassing Fire, the same convention FireControlCut9Tests.FireSynthetic uses)
// pins every factor to a distinct, non-1 value and checks the exact product against an independent
// recomputation.
[Fact]
public void CommitProbabilityIsTheExactProductOfItsFourFactors()
{
var e = Build(TestSettings(), SolidShape(7, 9), precision: .5f, spread: 6f);
var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var pending = e.Zone.PendingShots.Single(s => s.ShotId == shotId);
// A jink between fire and commit gives pDeviation a real, non-1 value distinct from PFire and PSpread.
pending.FireTime = e.Zone.Time - 1f;
pending.PFire = .8f;
pending.Tracking = 20f;
e.Zone.PendingShots[e.Zone.PendingShots.FindIndex(s => s.ShotId == shotId)] = pending;
e.Target.Position += float3(6, 0, 0); // jink after the (frozen) fire-time projection
var p = FireControl.CommitProbability(pending, e.Zone.Time, out var sil);
var pDeviation = FireControl.DeviationProbability(pending, e.Zone.Time, out var deviation);
Assert.True(deviation > 0f, "fixture: the jink must actually move the target off its projection");
Assert.True(pDeviation > .05f && pDeviation < .95f, $"fixture: pDeviation ({pDeviation}) must be strictly between 0 and 1");
Assert.True(sil.POnHull > .05f && sil.POnHull < .95f, $"fixture: pOnHull ({sil.POnHull}) must be strictly between 0 and 1");
var pSpread = e.HullData.Shape.Width > 0
? Math.Min(1f, (float) (180.0 / Math.PI * Math.Atan(.5 * sil.Span / pending.FireRange)) / (pending.Spread / 2f))
: 1f;
var expected = pending.PFire * pDeviation * pSpread * sil.POnHull;
Assert.Equal(expected, p, 4);
}
// Stryker survivors: ResolveAimPoint's fallback (aimed but currently occupying zero cells -> hull centre
// of mass) and its multi-cell average were both untested -- every existing marker fixture uses the
// catalog's 1x1 "Marker" stub, where sum/length and sum*length agree at length 1, and no fixture ever
// destroys or unequips an aimed item before firing.
[Fact]
public void AimPointFallsBackWhenTheAimedItemNoLongerOccupiesAnyCell()
{
var aim = new int2(2, 2);
var e = Build(TestSettings(), SolidShape(6, 6), precision: 1000f, markerCells: new[] { aim });
var aimed = e.Markers[0];
Assert.True(e.Shooter.TrySelectTargetItem(aimed));
// The aimed item is still equipped (Entity.TryUnequip refuses on an active entity, and reveal/aim
// selection reads Equipment, not GearOccupancy) but no longer occupies any hull cell -- the shape a
// destroyed or relocated item's footprint would leave. CellsOf then returns an empty (non-null) array,
// and ResolveAimPoint must fall back to the hull's own centre of mass rather than average zero cells
// (0/0 = NaN).
e.Target.GearOccupancy[aim.x, aim.y] = null;
var outcomes = FireMany(e, 400);
var hits = outcomes.Where(o => o.Hit).ToList();
Assert.True(hits.Count > 50, $"expected a healthy number of hits, got {hits.Count}");
foreach (var outcome in hits)
Assert.True(e.HullData.Shape[outcome.Cell], $"hit landed on {outcome.Cell}, off the hull's own schematic -- the aim point fallback produced NaN");
}
// BroadsideIsEasierThanHeadOn: kills the bearing being ignored and the old max(W,H) bound. A 2x12 hull is
// much easier to hit broadside (its long axis facing the shot) than head-on (its short axis facing it).
[Fact]