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));