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:
parent
7dbb3bcf7a
commit
efe4f7c954
2 changed files with 123 additions and 6 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue