Aetheria/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs
soul cc0c164574 determinism-ledger: every ItemManager states its seed; scatter on the zone's dice
- ItemManager(uint seed, ...) refuses seed 0 and seeds Random from it.
- RunStart.ItemSeed(galaxy, step) is public (formula unchanged), refuses a
  zero result; named steps ArenaStep, StageStep, ContinueStep and NoRunSeed.
- Continue seeds the item stream from the saved run's seed (ContinueStep);
  a scene with no galaxy uses NoRunSeed.
- Zone's unpack scatter draws SimulationDice.For(CombatSeed, ScatterStream,
  1-based pack ordinal), never the item stream.
- Loadout's scratch manager and AetherDb's catalog command take fixed seeds;
  every test and Editor construction gains 1u (mechanical rewrite).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:01:41 +02:00

1392 lines
82 KiB
C#

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using CultMath;
using GameCult.Caching;
using UniRx;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
using int2 = CultMath.int2;
// Cut 12.2 (docs/fire-control-cut.md, "Where a hit lands"): a hit's placement is priced and drawn from one
// silhouette, read at the bearing the target's facing implies AT COMMIT, not at fire. Builds its own
// fixtures, the convention every cut's test file follows. Fixtures follow 11.3: the shooter sits off the
// origin, fire times are nonzero, facings include ones where |fx| > |fy|, and weapon spread is nonzero
// wherever PSpread is asserted.
public sealed class FireControlCut12Tests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut12-" + Guid.NewGuid().ToString("N"));
private string Catalog => Path.Combine(_root, "Aetheria.cc");
public FireControlCut12Tests() => Directory.CreateDirectory(_root);
private readonly List<CultCache> _openCaches = new List<CultCache>();
public void Dispose()
{
foreach (var c in _openCaches) c.Dispose();
Directory.Delete(_root, true);
}
private static GameplaySettings TestSettings() => new GameplaySettings
{
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = .1f,
TargetGearInfoThreshold = .8f,
FiringArc = 170,
CommitHorizon = .5f,
SchematicCellSize = 1f,
UnaidedAccuracy = .05f,
UnaidedTracking = 10f,
UnaidedPrecision = .3f,
AgentMinHitProbability = 0f,
BeamResolveInterval = .1f
};
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
private static Shape SolidShape(int w, int h)
{
var shape = new Shape(w, h);
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
return shape;
}
// S1 fix batch: a non-convex hull for the empty-lane sweep -- a solid square with its centre cell punched
// out, the same "holed7" shape Soul's own EdgeSweep probe used.
private static Shape HoledShape(int w, int h)
{
var shape = SolidShape(w, h);
shape[new int2(w / 2, h / 2)] = false;
return shape;
}
// A unit direction at `d` degrees, matching Soul's own probe convention (SoulProbe122b.Deg) -- used here to
// name an exact facing/bearing for a crash repro.
private static float2 Deg(double d) => float2((float) Math.Sin(d * Math.PI / 180), (float) Math.Cos(d * Math.PI / 180));
private sealed class Engagement
{
public ItemManager Items;
public Zone Zone;
public Ship Shooter;
public Ship Target;
public EquippedItem WeaponItem;
public InstantWeapon Weapon;
public HullData HullData;
public EquippedItem[] Markers;
public EquippedItem BigMarker;
public int2[] BigMarkerCells;
public EquippedItem LMarker;
public int2[] LMarkerCells;
}
// A shooter off the origin (Cut 11.3), a target over a caller-supplied hull shape, a full-strength
// targeting system (Accuracy 1, Resolution huge, Tracking huge) so only the geometry under test moves the
// number, and an optional shield. markerCells equips the catalog's bare "Marker" Tool stub at each given
// interior cell -- returned in the same order -- for tests that need to name a specific cell (bow, stern).
private Engagement Build(
GameplaySettings settings, Shape hullShape, float precision,
float velocity = 0, float spread = 0, float2? targetFacing = null,
bool equipShield = false, float shieldCapacity = 30,
int2[] markerCells = null, float accuracy = 1f, float penetration = 0, bool equipBigMarker = false,
// S1 fix batch (Hands, 2026-09-25): a caller-chosen zone name -- Cut 6.1's own convention is one fixed
// name so a fixture is replayable byte-for-byte, but Soul's own crash repros (Zone.CombatSeed is
// StableHash(name)) each need their OWN exact name to reproduce a specific die roll.
string zoneName = "Cut12",
// S7 fix batch (Hands, 2026-09-25): Tracking authored through Build, like Precision and Accuracy already
// are, instead of a caller patching PendingShot.Tracking after Fire (TheHudEstimateIsTheCommitPrice's own
// former shape) -- one authoring path for every frozen shooter stat.
float tracking = 100000f,
// S8 fix batch (Hands, 2026-09-25): an L-shaped (3-cell, not 2x2) Tool item -- its true centroid differs
// from both any single cell and the bounding-box centre a mutant could substitute (N11), which a
// rectangular BigMarker cannot distinguish.
bool equipLMarker = false)
{
var hullData = new HullData
{
Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 100000, Mass = 1000, Armor = 1000,
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } }
};
// The shooter's own hull is a fixed, always-roomy shape -- distinct from the target's caller-supplied
// hullShape, which some tests (BroadsideIsEasierThanHeadOn's 2-wide hull, TheSigmaFloorHoldsAtHalfACell's
// 1-wide lane) deliberately make too narrow to have any interior at all, so a Tool item (the shooter's
// own Targeting system) could never fit on it.
var shooterHullData = new HullData
{
Name = "ShooterHull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 100000, Mass = 1000,
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } }
};
var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true);
_openCaches.Add(cache);
cache.Upsert(new VerseGrammar { Revision = 1 });
cache.Upsert(hullData);
cache.Upsert(shooterHullData);
cache.Upsert(new GearData
{
Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new InstantWeaponData
{
Damage = Constant(5), Range = Constant(1000), MinRange = Constant(0),
Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(0),
Penetration = Constant(penetration), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000),
} }
});
cache.Upsert(new GearData
{
Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new TargetingSystemData
{
Accuracy = Constant(accuracy), Resolution = Constant(1000f), Precision = Constant(precision), Tracking = Constant(tracking)
} }
});
cache.Upsert(new GearData
{
Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } }
});
cache.Upsert(new GearData
{
Name = "Shield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(shieldCapacity), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) } }
});
cache.Upsert(new GearData
{
Name = "Marker", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1000000,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000
});
// F7 (Soul's fix batch, 2026-09-24): a 2x2 Tool item -- every "Marker" fixture elsewhere is the
// catalog's 1x1 stub, where ResolveAimPoint's average (sum of cells / cell count) can't be
// distinguished from summing then multiplying, or from just reading the first cell.
var bigMarkerShape = new Shape(2, 2);
foreach (var c in bigMarkerShape.AllCoordinates) bigMarkerShape[c] = true;
cache.Upsert(new GearData
{
Name = "BigMarker", Hardpoint = HardpointType.Tool, Shape = bigMarkerShape, Durability = 1000000,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000
});
// S8 fix batch: an L-tromino (3 of a 2x2's 4 cells) -- its centroid ((0,0)+(1,0)+(0,1))/3 = (1/3,1/3) is
// neither any one of its own cells nor the bounding box's centre (.5,.5), so a fixture built from it can
// tell a true centroid apart from both.
var lMarkerShape = new Shape(2, 2) { [new int2(0, 0)] = true, [new int2(1, 0)] = true, [new int2(0, 1)] = true };
cache.Upsert(new GearData
{
Name = "LMarker", Hardpoint = HardpointType.Tool, Shape = lMarkerShape, Durability = 1000000,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000
});
cache.FlushAsync().Wait();
var ledger = new ProvenanceLedger();
for (var i = 1; i <= 24; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
var items = new ItemManager(1u, cache, ledger, settings, _ => { });
// Cut 6.1's own convention (FireControlCut6Tests.cs:62-63): every engagement shares one fixed
// GalaxyZone name, so every Zone this fixture builds shares one CombatSeed and every statistical test
// is replayable byte-for-byte across runs. A GUID-suffixed name here (this file's own earlier defect)
// reseeded the die on every run, which is exactly what made TurningArmourIntoTheShotTakesItOnTheArmour
// flaky (F2, Soul's fix batch, 2026-09-24).
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = zoneName, Owner = null }, null);
EquippableItem Make(string name, int lot, float durability) => new EquippableItem
{
Data = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>(name)), Durability = durability, Lot = lot
};
var lot = 1;
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Hull"));
var shooterHullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("ShooterHull"));
var shooter = new Ship(items, zone, new EquippableItem { Data = shooterHullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings());
var gun = Make("Gun", lot++, 1);
Assert.True(shooter.TryEquip(gun, new int2(0, 0)));
var weaponItem = shooter.Equipment.Single(x => x.EquippableItem == gun);
var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon);
var targeting = Make("Targeting", lot++, 1);
Assert.True(shooter.TryFindSpace(targeting, out var tpos));
Assert.True(shooter.TryEquip(targeting, tpos));
var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings());
// A bystander item on the Hardpoint slot every hull shape has (position (0,0) needs no Shrink-interior,
// unlike a Tool) -- Entity.Update's `foreach (var item in _orderedEquipment)` NREs if an entity was
// never equipped with anything at all (_orderedEquipment stays null until TryEquip first assigns it),
// the same reason FireControlCut9Tests.BuildLockScenario equips its own bystander with a token gun.
var targetGun = Make("Gun", lot++, 1);
Assert.True(target.TryEquip(targetGun, new int2(0, 0)));
var markers = markerCells?.Select(cell =>
{
var m = Make("Marker", lot++, 1000000);
Assert.True(target.TryEquip(m, cell), $"marker must fit at {cell}");
return target.Equipment.Single(x => x.EquippableItem == m);
}).ToArray();
EquippedItem bigMarker = null;
int2[] bigMarkerCells = null;
if (equipBigMarker)
{
var bm = Make("BigMarker", lot++, 1000000);
Assert.True(target.TryFindSpace(bm, out var bmPos));
Assert.True(target.TryEquip(bm, bmPos));
bigMarker = target.Equipment.Single(x => x.EquippableItem == bm);
bigMarkerCells = hullShape.Coordinates.Where(v => target.GearOccupancy[v.x, v.y] == bigMarker).ToArray();
Assert.Equal(4, bigMarkerCells.Length); // fixture precondition: the full 2x2 footprint landed
}
EquippedItem lMarker = null;
int2[] lMarkerCells = null;
if (equipLMarker)
{
var lm = Make("LMarker", lot++, 1000000);
Assert.True(target.TryFindSpace(lm, out var lmPos));
Assert.True(target.TryEquip(lm, lmPos));
lMarker = target.Equipment.Single(x => x.EquippableItem == lm);
lMarkerCells = hullShape.Coordinates.Where(v => target.GearOccupancy[v.x, v.y] == lMarker).ToArray();
Assert.Equal(3, lMarkerCells.Length); // fixture precondition: the full 3-cell L footprint landed
}
if (equipShield)
{
var reactor = Make("Reactor", lot++, 10);
Assert.True(target.TryFindSpace(reactor, out var rpos));
Assert.True(target.TryEquip(reactor, rpos));
var shield = Make("Shield", lot++, 10);
Assert.True(target.TryFindSpace(shield, out var spos));
Assert.True(target.TryEquip(shield, spos));
}
zone.Admit(shooter, piloted: false);
zone.Admit(target, piloted: false);
// Cut 11.3: the shooter sits off the origin -- a shooter at float3.zero makes `target - source` and
// `target + source` the same vector, hiding a whole class of sign mutants.
shooter.Position = float3(37, 0, -11);
target.Position = float3(37, 0, 89); // 100 units straight ahead of the shooter
if (targetFacing != null) target.Direction = targetFacing.Value;
shooter.SetTarget(target);
shooter.EntityInfoGathered[target] = 1f;
shooter.SetIff(target, true);
if (equipShield)
{
target.Shield.Item.Enabled.Value = true;
for (var i = 0; i < 20; i++) zone.Update(.1f); // charge from empty (Cut 11's own Charge() convention)
}
// Cut 11.3: fire times are nonzero -- warm up past a bare zone.Update(0f) so FireTime is never 0.
zone.Update(1f);
return new Engagement
{
Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon,
HullData = hullData, Markers = markers, BigMarker = bigMarker, BigMarkerCells = bigMarkerCells,
LMarker = lMarker, LMarkerCells = lMarkerCells
};
}
private static List<ShotOutcome> FireMany(Engagement e, int count)
{
var outcomes = new List<ShotOutcome>();
e.Zone.ShotResolved.Subscribe(o => outcomes.Add(o));
for (var i = 0; i < count; i++)
{
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(.01f);
}
return outcomes;
}
// Independent reimplementation of FireControl.Lane's slab test, from the spec's own formulas, over the
// hull's public Shape.Coordinates -- used to check production's placement against an outcome's own frozen
// Bearing/Lateral without calling FireControl.Lane itself.
private static int2 NearestOccupiedCellAtLateral(HullData hull, float2 bearing, float lateral)
{
var ell = float2(-bearing.y, bearing.x);
var h = (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f;
int2? best = null;
var bestEntry = float.PositiveInfinity;
foreach (var c in hull.Shape.Coordinates)
{
var centre = dot((float2) c, ell);
if (lateral < centre - h || lateral >= centre + h) continue;
var cf = (float2) c;
var entry = float.NegativeInfinity;
var exit = float.PositiveInfinity;
var valid = true;
foreach (var axis in new[] { 0, 1 })
{
var bAxis = axis == 0 ? bearing.x : bearing.y;
var cAxis = axis == 0 ? cf.x : cf.y;
var val = axis == 0 ? lateral * ell.x : lateral * ell.y;
var lo = cAxis - .5f;
var hi = cAxis + .5f;
if (Math.Abs(bAxis) < 1e-9f)
{
if (val < lo || val > hi) valid = false;
continue;
}
var t1 = (lo - val) / bAxis;
var t2 = (hi - val) / bAxis;
entry = Math.Max(entry, Math.Min(t1, t2));
exit = Math.Min(exit, Math.Max(t1, t2));
}
if (!valid || entry > exit) continue;
if (entry < bestEntry)
{
bestEntry = entry;
best = c;
}
}
Assert.True(best != null, "fixture: no occupied cell reached at this lateral offset");
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.SetTarget(TargetRef.None);
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);
}
// :~483 fix batch (Hands, 2026-09-25): `if (p <= 0f) return 0f;` in CommitProbability is a guard against
// building a silhouette for a shot that cannot hit -- Stryker's own `p < 0` boundary mutant (excluding only
// the single value p == 0 exactly) survived because nothing exercised the guard at exactly 0.
// 12.3 fix batch correction: the previous version of this test set `shot.PFire = 0f` on a pending shot by
// hand, then asserted `sil.Intervals == null` -- a shape assertion on CommitProbability's own struct
// default, not a behaviour a bug could actually violate (any caller reading `sil` after a real miss reads
// the same default regardless of whether a Silhouette was built and discarded). Made behavioural per the
// 12.2 postmortem: PFire == 0 is now produced through the real gate (closing the shooter's own
// VisibleEntities, the same way S4's InvisibleTargetStillResolvesAtItsRealFlightTime does), and the claim
// "no Silhouette was ever built" is checked the only way that is actually true of a function's own
// behaviour: it allocates nothing. The `p < 0` mutant lets p == 0 fall through to build a real Silhouette
// (Array.Sort, ResolveAimPoint, an owned Interval[]), which is not zero-allocation -- this test kills it
// the same way S3's own zero-allocation pins do, not by re-reading a field the mutant never touches.
[Fact]
public void ZeroProbabilityShotCommitsAMissWithoutBuildingASilhouette()
{
var e = Build(TestSettings(), SolidShape(5, 5), precision: 1f);
e.Shooter.VisibleEntities.Remove(e.Target); // closes the real gate -- PFire == 0 through the actual path
var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var shot = e.Zone.PendingShots.Single(s => s.ShotId == shotId);
Assert.Equal(0f, shot.PFire); // fixture precondition: the gate is actually closed
for (var i = 0; i < 50; i++) FireControl.CommitProbability(shot, e.Zone.Time, out _); // JIT warm-up
var before = GC.GetAllocatedBytesForCurrentThread();
var p = FireControl.CommitProbability(shot, e.Zone.Time, out var sil);
var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0f, p);
Assert.Equal(0, sil.Count);
Assert.Equal(0, allocated); // no Silhouette was built: Array.Sort/ResolveAimPoint never ran
ShotOutcome outcome = null;
using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o);
e.Zone.Update(.01f);
Assert.NotNull(outcome);
Assert.False(outcome.Hit, "fixture: PFire 0 must commit as a clean miss");
}
// S4 fix batch (Hands, 2026-09-25): F8 (Soul's earlier fix batch, 2026-09-24) moved range/flightTime
// computation ahead of the visibility gate, so Fire's own flight time reflects the real distance even when
// PFire prices the shot at 0 -- but nothing behavioural pinned that. N4 (Soul's mutation set: "range after
// visibility gate", reverting F8) survived every existing test. A shot at a target the shooter cannot
// currently see must still resolve, as a miss (PFire gates it to 0), at the arrival time the real
// range/velocity imply -- not immediately (range 0) and not never (flight time 0 means instant resolve).
[Fact]
public void InvisibleTargetStillResolvesAtItsRealFlightTime()
{
var e = Build(TestSettings(), SolidShape(5, 5), precision: 1f, velocity: 20); // range 100 -> 5s flight
e.Shooter.VisibleEntities.Remove(e.Target);
var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var pending = e.Zone.PendingShots.Single(s => s.ShotId == shotId);
Assert.Equal(0f, pending.PFire); // fixture precondition: the visibility gate is actually closed
var expectedArrival = pending.FireTime + 100f / 20f; // the real range (100) over the real velocity (20)
Assert.Equal(expectedArrival, pending.ArrivalTime, 3);
ShotOutcome outcome = null;
using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o);
e.Zone.Update(4.9f);
Assert.Null(outcome); // not yet: N4's bug (range 0) would have resolved this instantly
e.Zone.Update(.2f); // past the real 5s arrival
Assert.NotNull(outcome);
Assert.False(outcome.Hit, "fixture: PFire 0 (not visible) must resolve as a miss");
}
// 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];
e.Shooter.RecordReveal(e.Target, aimed, SignatureChannel.LaunchFlash, aimed.Data);
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");
}
// F7 (Soul's fix batch, 2026-09-24): ResolveAimPoint's average was only ever exercised with a 1x1 item
// (every other fixture's "Marker"), where sum/length and sum*length agree and "first cell" and "average"
// are the same cell. A 2x2 "BigMarker" gives a genuine 4-cell footprint: its true centroid is checked two
// ways -- pOnHull does not collapse (the aim point is on the hull, not scaled off it by sum*length), and,
// from broadside, the empirical mean lateral offset of hits centres on the real centroid's own lane, not
// on any single one of its four cells.
[Fact]
public void AimingAtAnItemBiggerThanOneCellCentresOnItsCentroid()
{
var e = Build(TestSettings(), SolidShape(9, 9), precision: 1f, targetFacing: float2(1, 0), equipBigMarker: true);
e.Shooter.RecordReveal(e.Target, e.BigMarker, SignatureChannel.LaunchFlash, e.BigMarker.Data);
Assert.True(e.Shooter.TrySelectTargetItem(e.BigMarker));
var travelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target);
var bearing = normalize(e.Target.ToSchematic(travelDirection));
var ell = float2(-bearing.y, bearing.x);
var centroid = e.BigMarkerCells.Aggregate(float2.zero, (t, c) => t + (float2) c) / e.BigMarkerCells.Length;
var expectedA = dot(centroid, ell);
// pOnHull must not collapse -- a sum*length aim point would be scaled several cells off the hull
// entirely, driving pOnHull toward zero even at Precision 1 (sigma 1, comfortably inside a 9x9 hull).
var hud = FireControl.Inspect(e.Weapon, e.Shooter, e.Target);
Assert.True(hud.POnHull > .3f, $"pOnHull ({hud.POnHull}) collapsed -- the aim point is likely off the hull");
var outcomes = FireMany(e, 3000);
var hits = outcomes.Where(o => o.Hit).ToList();
Assert.True(hits.Count > 300, $"expected a healthy number of hits, got {hits.Count}");
var empiricalMeanLateral = hits.Average(o => (double) o.Lateral);
// Sigma is 1 cell; a "first cell only" aim point sits at least .5 cell off the true centroid on a 2x2
// footprint, well outside this tolerance for a few-hundred-shot mean.
Assert.True(Math.Abs(empiricalMeanLateral - expectedA) < .25,
$"empirical mean lateral offset {empiricalMeanLateral:F3} does not track the centroid's own lane {expectedA:F3}");
}
// S8 fix batch (Hands, 2026-09-25): the BigMarker test above only ever exercises a 2x2 (square) footprint,
// whose true centroid and bounding-box centre are the same point -- N11 (aim point = bounding-box centre
// instead of the cell average) survived because nothing could tell the two apart. An L-tromino (3 of a
// 2x2's 4 cells) makes them different points: this fixture pins the aim point against the exact centroid
// the test computes itself, and separately checks it against the wrong (bounding-box) candidate to prove
// the fixture actually distinguishes them.
[Fact]
public void AimingAtAnLShapedItemCentresOnItsCentroidNotItsBoundingBox()
{
// A diagonal facing (not axis-aligned) puts the L-shape's actual asymmetry -- its centroid sits off
// the bounding-box centre along the diagonal, not along either grid axis -- fully onto the lateral
// axis, instead of losing most of it to the axis a purely axis-aligned facing would leave unmeasured.
var e = Build(TestSettings(), SolidShape(9, 9), precision: 1f, targetFacing: normalize(float2(1, 1)), equipLMarker: true);
e.Shooter.RecordReveal(e.Target, e.LMarker, SignatureChannel.LaunchFlash, e.LMarker.Data);
Assert.True(e.Shooter.TrySelectTargetItem(e.LMarker));
var travelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target);
var bearing = normalize(e.Target.ToSchematic(travelDirection));
var ell = float2(-bearing.y, bearing.x);
var centroid = e.LMarkerCells.Aggregate(float2.zero, (t, c) => t + (float2) c) / e.LMarkerCells.Length;
var boundingBoxCentre = (float2(e.LMarkerCells.Min(c => c.x), e.LMarkerCells.Min(c => c.y))
+ float2(e.LMarkerCells.Max(c => c.x), e.LMarkerCells.Max(c => c.y))) / 2f;
Assert.True(lengthsq(centroid - boundingBoxCentre) > .01f, "fixture: the L-shape's centroid and bounding-box centre must actually differ");
var expectedA = dot(centroid, ell);
var wrongA = dot(boundingBoxCentre, ell);
Assert.True(Math.Abs(expectedA - wrongA) > .15f, "fixture: the two candidate aim points must project to distinguishably different lateral values");
var outcomes = FireMany(e, 3000);
var hits = outcomes.Where(o => o.Hit).ToList();
Assert.True(hits.Count > 300, $"expected a healthy number of hits, got {hits.Count}");
var empiricalMeanLateral = hits.Average(o => (double) o.Lateral);
Assert.True(Math.Abs(empiricalMeanLateral - expectedA) < .1,
$"empirical mean lateral offset {empiricalMeanLateral:F3} (bounding-box centre would predict {wrongA:F3}) does not track the true centroid's own lane {expectedA:F3}");
}
// 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]
public void BroadsideIsEasierThanHeadOn()
{
float PAt(float2 facing)
{
var e = Build(TestSettings(), SolidShape(2, 12), precision: .5f, spread: 4, targetFacing: facing);
return FireControl.Inspect(e.Weapon, e.Shooter, e.Target).PSpread;
}
float POnHullAt(float2 facing)
{
var e = Build(TestSettings(), SolidShape(2, 12), precision: .5f, targetFacing: facing);
return FireControl.Inspect(e.Weapon, e.Shooter, e.Target).POnHull;
}
// The shooter fires from world +z toward the target (TravelDirection ~ world +z, since the target sits
// straight ahead). Facing (0,1) (bow toward the shooter, forward parallel to travel) puts the ship's
// SHORT axis (width 2) across the bearing -- head-on. Facing (1,0) puts its LONG axis (height 12)
// across the bearing -- broadside.
var headOnPSpread = PAt(float2(0, 1));
var broadsidePSpread = PAt(float2(1, 0));
Assert.True(broadsidePSpread > headOnPSpread,
$"broadside PSpread ({broadsidePSpread}) must exceed head-on PSpread ({headOnPSpread})");
var headOnPOnHull = POnHullAt(float2(0, 1));
var broadsidePOnHull = POnHullAt(float2(1, 0));
Assert.True(broadsidePOnHull > headOnPOnHull,
$"broadside pOnHull ({broadsidePOnHull}) must exceed head-on pOnHull ({headOnPOnHull})");
}
// TurningArmourIntoTheShotTakesItOnTheArmour (the ruling's own case): a slow shot at a target whose stern
// is exposed at Fire. The target turns its armoured bow to face the incoming shot (present the bow toward
// where the shot is travelling FROM) before the shot commits. Every hit must land on the bow row --
// Bearing timing reads the facing live, at Commit, not frozen at Fire.
[Fact]
public void TurningArmourIntoTheShotTakesItOnTheArmour()
{
var settings = TestSettings();
// Flight time well above CommitHorizon (.5s): range 100 at velocity 20 -> 5s flight, commits at 4.5s.
var e = Build(settings, SolidShape(3, 11), precision: .4f, velocity: 20, targetFacing: null);
var outcomes = new List<ShotOutcome>();
using var r = e.Zone.ShotResolved.Subscribe(outcomes.Add);
// Repeat the same fire-then-turn sequence several times (the roll can still miss outright) and check
// the set of hits, not one shot.
for (var i = 0; i < 60; i++)
{
var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var pending = e.Zone.PendingShots.Single(s => s.ShotId == shotId);
var travelDirection = pending.TravelDirection;
// At Fire: bow faces the same way the shot travels -- the stern is exposed to the incoming shot.
e.Target.Direction = travelDirection;
e.Zone.Update(1f); // short of commit (4.5s)
// Before commit: turn the bow to face the incoming shot head-on.
e.Target.Direction = -travelDirection;
e.Zone.Update(4f); // past commit (4.5s), short of arrival (5s)
e.Zone.Update(2f); // past arrival
}
var hits = outcomes.Where(o => o.Hit).ToList();
Assert.True(hits.Count > 10, $"expected a healthy number of hits to check, got {hits.Count}");
foreach (var outcome in hits)
Assert.True(outcome.Cell.y >= 5.5f, $"expected every hit to land on the bow row (y>=5.5), got cell {outcome.Cell}");
}
// S1 (Soul's second pass, 2026-09-25): at a non-axis-aligned bearing, a lateral draw exactly at an
// interval's Lo used to touch only a cell's corner -- Lane's own shadow prefilter admitted the cell but the
// independent SlabAlongB slab test rejected it on float rounding, Lane returned zero cells, and Commit's
// own guard threw InvalidOperationException out of Zone.Update (R3 violation: a hit that already passed
// its roll must always land on metal). Soul reproduced this live, not through reflection into a private
// method: zone name Cut12-edge-68442709 (StableHash seeds Zone.CombatSeed, so this exact name reproduces
// the exact die roll), a 7x9 hull, Precision 2, target facing 1 degree, shot 1. Fixed by deleting the
// second, disagreeing gate (this fix batch's FireControl.cs change) -- this pins the exact repro against a
// real Commit, not a reflection call, and checks the placement lands where the roll priced it.
[Fact]
public void ShotAtBearingLoTouchingOnlyACellCornerHitsMetal()
{
var e = Build(TestSettings(), SolidShape(7, 9), precision: 2f, targetFacing: Deg(1), zoneName: "Cut12-edge-68442709");
ShotOutcome outcome = null;
using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(.01f);
Assert.NotNull(outcome);
Assert.True(outcome.Hit, "fixture: this exact seed must roll a hit (this is the repro, not a general property)");
Assert.True(e.HullData.Shape[outcome.Cell], $"hit landed on {outcome.Cell}, not on the hull's own schematic");
var ell = float2(-outcome.Bearing.y, outcome.Bearing.x);
var h = (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f;
var centre = dot((float2) outcome.Cell, ell);
Assert.True(outcome.Lateral >= centre - h && outcome.Lateral < centre + h,
$"hit cell {outcome.Cell}'s own projected lateral interval [{centre - h},{centre + h}) does not contain the drawn Lateral {outcome.Lateral}");
}
// S1: the same class of empty-lane crash on the ruling's own TurningArmourIntoTheShotTakesItOnTheArmour
// geometry (3x11 hull, velocity 20, Precision .4, the fire-then-turn sequence that fixture uses) --
// Soul's search found a zone name (Cut12-edge-64078070) whose draw lands on an interval edge after the
// target turns its bow into the shot.
[Fact]
public void ShotAtBearingLoOnTurningArmourHitsMetal()
{
var e = Build(TestSettings(), SolidShape(3, 11), precision: .4f, velocity: 20, zoneName: "Cut12-edge-64078070");
var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var pending = e.Zone.PendingShots.Single(s => s.ShotId == shotId);
var travelDirection = pending.TravelDirection;
// At Fire: bow faces the same way the shot travels (stern exposed). Before commit: turn the bow to
// face the incoming shot head-on, TurningArmourIntoTheShotTakesItOnTheArmour's own sequence.
e.Target.Direction = travelDirection;
e.Zone.Update(1f);
e.Target.Direction = -travelDirection;
ShotOutcome outcome = null;
using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o);
e.Zone.Update(4f); // past commit (4.5s), short of arrival (5s)
e.Zone.Update(2f); // past arrival
Assert.NotNull(outcome);
Assert.True(outcome.Hit, "fixture: this exact seed/sequence must roll a hit (this is the repro, not a general property)");
Assert.True(e.HullData.Shape[outcome.Cell], $"hit landed on {outcome.Cell}, not on the hull's own schematic");
Assert.True(outcome.Cell.y >= 5.5f, $"expected the hit to land on the bow row (y>=5.5), got cell {outcome.Cell}");
var ell = float2(-outcome.Bearing.y, outcome.Bearing.x);
var h = (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f;
var centre = dot((float2) outcome.Cell, ell);
Assert.True(outcome.Lateral >= centre - h && outcome.Lateral < centre + h,
$"hit cell {outcome.Cell}'s own projected lateral interval [{centre - h},{centre + h}) does not contain the drawn Lateral {outcome.Lateral}");
}
// S1 sweep: across many bearings and precisions, on hulls including a non-convex (holed) one, Lane must
// find at least one occupied cell for every lateral offset at an interval's own Lo, and just below its Hi
// -- the exact edges LateralDraw's own half-open clamp can produce (u=0 draws Lo; u near 1 draws just
// below Hi). An empty lane at either edge is precisely the class of crash S1 fixed; this is the general
// property the two named-seed repros above are specific instances of.
[Fact]
public void LaneNeverEmptiesAtAnIntervalEdge()
{
var shapes = new[] { SolidShape(7, 9), SolidShape(3, 11), HoledShape(7, 7) };
var precisions = new[] { .3f, .5f, 1f, 2f };
var failures = new List<string>();
foreach (var shape in shapes)
{
var hull = new HullData { Name = "Sweep", HullType = HullType.Ship, Shape = shape };
var buffer = new LaneCell[shape.Coordinates.Length];
for (var deg = 0; deg < 360; deg += 3)
{
var b = Deg(deg);
foreach (var precision in precisions)
{
var sil = FireControl.Silhouette(null, hull, null, b, precision);
for (var k = 0; k < sil.Count; k++)
{
var iv = sil.Intervals[k];
if (FireControl.Lane(hull, b, iv.Lo, buffer) == 0)
failures.Add($"{shape.Width}x{shape.Height} deg={deg} p={precision} k={k} at Lo={iv.Lo:R}");
var justBelowHi = Math.Max(iv.Lo, iv.Hi - 1e-4f - 1e-5f);
if (FireControl.Lane(hull, b, justBelowHi, buffer) == 0)
failures.Add($"{shape.Width}x{shape.Height} deg={deg} p={precision} k={k} just below Hi={iv.Hi:R}");
}
}
}
}
Assert.True(failures.Count == 0, $"{failures.Count} empty lanes at an interval edge, e.g.: {string.Join("; ", failures.Take(5))}");
}
// Carried into 12.3's brief (12.2 Soul pass 3): pins Lane's own seam contract directly, against a 2x1 hull
// -- the smallest fixture where a seam actually falls between two occupied cells. At b=(0,1), ell=(-1,0),
// so cell (0,0)'s shadow is [-.5,.5) and cell (1,0)'s is [.5,1.5): s=-0.5 must land only in (0,0)'s column,
// and s=0.5 (the shared seam, exclusive on (0,0)'s side and where (1,0)'s own interval starts) must find
// NOTHING here, because this fixture's hull is only 1 cell wide at y=0 -- there is no (1,0). Mutation: the
// `s >= lateral.Hi` admission test at FireControl.cs:~817 flipped to `s > lateral.Hi` would let s=0.5
// through as still touching (0,0) (0.5 is not > 0.5), which on a hull that DID have a second column would
// interleave the two columns' lanes at the seam -- reproduced directly here as "an empty lane's cell count
// is wrong", not by requiring the second column to exist.
[Fact]
public void LaneSeamIsHalfOpen()
{
var shape = new Shape(1, 1) { [new int2(0, 0)] = true };
var hull = new HullData { Name = "Seam", HullType = HullType.Ship, Shape = shape };
var b = float2(0, 1);
var buffer = new LaneCell[shape.Coordinates.Length];
var atLo = FireControl.Lane(hull, b, -0.5f, buffer);
Assert.Equal(1, atLo);
Assert.Equal(new int2(0, 0), buffer[0].Cell);
var atHi = FireControl.Lane(hull, b, 0.5f, buffer);
Assert.Equal(0, atHi); // the seam itself: half-open on the Hi side, so this cell's own shadow excludes it
}
// Carried into 12.3's brief (12.2 Soul pass 3): AlongBearing/SlabAxis had no test of their own VALUES --
// every mutant there survived in 12.2 because only buffer[0]'s Cell was ever read (S1's own shadow
// prefilter, Extent, decided admission; AlongBearing's entry/exit were computed but unchecked). 12.3's own
// walk (FireControl.Apply) reads Entry for penetration and ordering, so a wrong entry/exit here is now a
// wrong damage cell, not dead arithmetic.
// F10 fix batch (Soul, 2026-09-25): the previous, single-bearing version of this test wrote out SlabAxis's
// OWN threshold (`1e-9f`) and clamp a second time, so a mutant that only moved that threshold (C7b, 1e-9 to
// .2) agreed with both copies identically and survived. This version never special-cases a near-zero
// bearing component at all: IEEE division by a signed zero already yields the correctly signed infinity, so
// an axis with no motion is unconstrained without a hand-written epsilon to disagree with SlabAxis's own.
// Checked at several bearings, including one within about 11 degrees of an axis -- inside SlabAxis's
// mutated .2f threshold but nowhere near the real 1e-9f one, so C7b now has to answer for a visibly wrong
// entry/exit instead of an unstressed one.
[Fact]
public void AlongBearingReturnsTheExactSlabIntersectionAtSeveralBearings()
{
var shape = SolidShape(5, 5);
var hull = new HullData { Name = "Diagonal", HullType = HullType.Ship, Shape = shape };
(double Entry, double Exit) ExactSlabIndependent(int2 c, double bx, double by, double ellx, double elly, double s)
{
var valX = s * ellx; var valY = s * elly;
var t1X = (c.x - .5 - valX) / bx; var t2X = (c.x + .5 - valX) / bx;
var t1Y = (c.y - .5 - valY) / by; var t2Y = (c.y + .5 - valY) / by;
var entry = Math.Max(Math.Min(t1X, t2X), Math.Min(t1Y, t2Y));
var exit = Math.Min(Math.Max(t1X, t2X), Math.Max(t1Y, t2Y));
return (entry, exit);
}
void Check(float2 bRaw, float s)
{
var b = normalize(bRaw);
var ell = float2(-b.y, b.x);
var h = (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2.0; // Extent's own half-width, written out independently
var expectedCells = shape.Coordinates
.Select(c => (Cell: c, Centre: (double) dot((float2) c, ell)))
.Where(t => s >= t.Centre - h && s < t.Centre + h) // Extent's own half-open shadow
.Select(t => t.Cell)
.OrderBy(c => dot((float2) c, b))
.ThenBy(c => c.x).ThenBy(c => c.y)
.ToList();
Assert.True(expectedCells.Count >= 3, $"fixture: b={b.x:R},{b.y:R} s={s} must cross several cells, got {expectedCells.Count}");
var buffer = new LaneCell[shape.Coordinates.Length];
var count = FireControl.Lane(hull, b, s, buffer);
var actualCells = Enumerable.Range(0, count).Select(i => buffer[i].Cell).ToList();
Assert.Equal(expectedCells, actualCells); // same cells, same order -- entry ordering and contiguity agree
for (var i = 0; i < count; i++)
{
var (expectedEntry, expectedExit) = ExactSlabIndependent(buffer[i].Cell, b.x, b.y, ell.x, ell.y, s);
Assert.Equal(expectedEntry, buffer[i].Entry, 3);
Assert.Equal(expectedExit, buffer[i].Exit, 3);
}
}
Check(float2(2, 1), 0.13f); // neither axis-aligned nor 45 degrees (the original fixture)
Check(float2(1, .15f), .3f); // ~8.5 degrees off the x-axis -- by sits inside C7b's mutated .2f threshold
Check(float2(.15f, 1), -.4f); // the same, off the y-axis -- bx sits inside C7b's mutated .2f threshold
}
// Cut 12.3: replaces ExpectedMarchCells, which reproduced Entity.ApplyHit's own deleted 0.5-step march --
// that rule is gone with the function. The rule this now pins is the NEW thing FireControl.Apply's lane
// walk adds on top of Lane (Lane's own cell set/order/entry values are independently pinned elsewhere, by
// AlongBearingReturnsTheExactSlabIntersectionAtSeveralBearings and the LaneNeverEmptiesAtAnIntervalEdge sweep): the
// penetration cutoff. So this calls the real, already-verified FireControl.Lane for the walked cells and
// their entry parameters, then applies the cutoff independently -- kept only while within `penetration`
// cells of the first (impact) cell's own entry; the impact cell is always kept, matching the deleted
// `> .5f` threshold's intent at zero cost.
private static List<int2> ExpectedLaneCells(HullData hull, float2 bearing, float lateral, float penetration)
{
var buffer = new LaneCell[hull.Shape.Coordinates.Length];
var walked = FireControl.Lane(hull, bearing, lateral, buffer);
var result = new List<int2>();
if (walked == 0) return result;
var impactEntry = buffer[0].Entry;
for (var i = 0; i < walked; i++)
{
if (i > 0 && buffer[i].Entry - impactEntry >= penetration) break;
result.Add(buffer[i].Cell);
}
return result;
}
// TurningAfterCommitChangesNothing (R4, closes L464/L465): after commit, turning the target and moving the
// shooter must not change where the damage actually lands -- Apply reads no live position or facing.
// F5 (Soul's fix batch, 2026-09-24): the original fixture fired with Penetration 0, so the penetration march
// never ran and never actually read the bearing Apply passed it -- a mutant that fed Apply a live
// (post-turn) bearing instead of the committed one survived (M2a) because nothing downstream of Cell ever
// consumed Bearing. Penetration 3 walks the lane several cells deep (Cut 12.3: FireControl.Apply's own lane
// walk, replacing the deleted Entity.ApplyHit march), so the full damaged-cell set is asserted against the
// committed Bearing/Lateral, not the live one.
// Cut 12.3 fix: this fixture's hull carries the flat 1000 armour every Build() call authors, and the Gun's
// own Damage is a fixed 5 -- fine for the old even-split model (every cell in the march took an
// undetectable-but-nonzero 5/N share), fatal for the new sequential one, where the very first (impact) cell
// alone would absorb the whole 5 and leave nothing for the rest of a several-cell-deep lane to show. Lowered
// to 0.05 per cell here so 5 damage plainly carries the full penetration-3 span.
[Fact]
public void TurningAfterCommitChangesNothing()
{
var settings = TestSettings();
var e = Build(settings, SolidShape(7, 7), precision: .5f, velocity: 20, penetration: 3); // 5s flight, commits at 4.5s
foreach (var v in e.HullData.Shape.Coordinates) { e.Target.Armor[v.x, v.y] = 0.05f; e.Target.MaxArmor[v.x, v.y] = 0.05f; }
ShotOutcome committed = null;
using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o);
// The roll can still miss outright -- retry (a fresh shot each time; the previous miss already
// resolved and left the pending queue empty) until one hits.
for (var attempt = 0; attempt < 30 && (committed == null || !committed.Hit); attempt++)
{
committed = null;
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(4.6f); // past commit (4.5s), short of arrival (5s)
}
Assert.NotNull(committed);
Assert.True(committed.Hit, "precondition: this fixture's shot must hit");
var expectedCell = NearestOccupiedCellAtLateral(e.HullData, committed.Bearing, committed.Lateral);
Assert.Equal(expectedCell, committed.Cell); // sanity: production's own Lane agrees with the independent recompute
var expectedFromCommitted = ExpectedLaneCells(e.HullData, committed.Bearing, committed.Lateral, 3f).ToHashSet();
var before = (float[,]) e.Target.Armor.Clone();
// After commit, before arrival: the target turns 90 degrees and the shooter moves.
var postTurnFacing = float2(1, 0);
e.Target.Direction = postTurnFacing;
e.Shooter.Position += float3(500, 0, 500);
// Fixture precondition: the (wrong) march a live-bearing bug would produce must actually differ from
// the committed one, or this test cannot tell the two apart. "Live" here means exactly what a mutant
// recomputing the bearing fresh at Apply time would read: the current TravelDirection (now stale,
// post-move) folded through the target's NEW facing -- both public FireControl functions, not a
// hand-rolled copy of the frame math.
var liveTravelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target);
var wrongBearing = normalize(e.Target.ToSchematic(liveTravelDirection));
var expectedFromLive = ExpectedLaneCells(e.HullData, wrongBearing, committed.Lateral, 3f).ToHashSet();
Assert.NotEqual(expectedFromCommitted, expectedFromLive);
e.Zone.Update(2f); // past arrival (5s)
foreach (var cell in expectedFromCommitted)
Assert.True(e.Target.Armor[cell.x, cell.y] < before[cell.x, cell.y],
$"cell {cell} is on the committed lane and must have taken damage, regardless of the post-commit turn");
foreach (var cell in expectedFromLive)
if (!expectedFromCommitted.Contains(cell))
Assert.Equal(before[cell.x, cell.y], e.Target.Armor[cell.x, cell.y]);
}
// PlacementAndProbabilityShareOneSilhouette replaces 6d's PlacementAndProbabilityShareOneKernel: pins that
// CommitProbability's number and Commit's own placement are read from the exact same Silhouette. A
// diagonal bearing (|fx| ~ |fy|) makes individual cells' raw shadow intervals overlap heavily before
// merging, so a skipped merge (double-counted mass) or a swapped ell/b would show up here.
[Fact]
public void PlacementAndProbabilityShareOneSilhouette()
{
var settings = TestSettings();
const float precision = .45f;
var hullShape = SolidShape(7, 9);
// Aim off the hull's own centre of mass (near a corner) so the Gaussian's mass is not symmetric
// inside the shadow -- a perturbed sigma or a mis-signed ell moves the analytic and reported numbers
// apart even though both would otherwise report something close to 1.
var aim = new int2(1, 1);
var e = Build(settings, hullShape, precision, spread: 0, targetFacing: normalize(float2(1f, 1f)),
markerCells: new[] { aim });
e.Shooter.RecordReveal(e.Target, e.Markers[0], SignatureChannel.LaunchFlash, e.Markers[0].Data);
Assert.True(e.Shooter.TrySelectTargetItem(e.Markers[0]));
var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var pending = e.Zone.PendingShots.Single(s => s.ShotId == shotId);
Assert.Same(e.Markers[0], pending.Aimed);
var bearing = normalize(e.Target.ToSchematic(pending.TravelDirection));
var ell = float2(-bearing.y, bearing.x);
var sigma = Math.Max(1f / precision, .5f);
var aimPoint = (float2) aim; // ResolveAimPoint on a single-cell marker: that cell itself
var a = dot(aimPoint, ell);
// Independent shadow: raw per-cell intervals from the hull's own public Shape.Coordinates, sorted and
// merged by this test, not by calling FireControl.Silhouette.
var raw = e.HullData.Shape.Coordinates
.Select(c => dot((float2) c, ell))
.Select(centre => (Lo: centre - (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f, Hi: centre + (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f))
.OrderBy(iv => iv.Lo)
.ToList();
var merged = new List<(float Lo, float Hi)>();
foreach (var iv in raw)
{
if (merged.Count > 0 && iv.Lo <= merged[merged.Count - 1].Hi)
merged[merged.Count - 1] = (merged[merged.Count - 1].Lo, Math.Max(merged[merged.Count - 1].Hi, iv.Hi));
else
merged.Add(iv);
}
// Numeric integral of the Gaussian pdf over the merged union -- independent of erf, and of any private
// FireControl helper.
double Mass((float Lo, float Hi) iv)
{
const int steps = 4000;
var width = (iv.Hi - iv.Lo) / steps;
if (width <= 0) return 0;
double sum = 0;
for (var i = 0; i <= steps; i++)
{
var x = iv.Lo + i * width;
var z = (x - a) / sigma;
var pdf = Math.Exp(-z * z / 2.0) / (sigma * Math.Sqrt(2.0 * Math.PI));
sum += pdf * (i == 0 || i == steps ? .5 : 1.0);
}
return sum * width;
}
var expectedPOnHull = merged.Sum(Mass);
Assert.True(expectedPOnHull > .05 && expectedPOnHull < .95, $"fixture: pOnHull ({expectedPOnHull}) must be non-degenerate to exercise the merge, got a saturated or collapsed value");
var commitP = FireControl.CommitProbability(pending, e.Zone.Time, out var sil);
// Fixture isolation: PFire = Accuracy(1) x PSensor(1, full reveal) = 1; pDeviation = 1 (target hasn't
// moved since FireTargetPosition was frozen); PSpread = 1 (Spread 0's own early-out). So the reported
// commit price collapses to exactly pOnHull, the number this test computed independently.
Assert.Equal((float) expectedPOnHull, commitP, 3);
Assert.Equal((float) expectedPOnHull, sil.POnHull, 3);
// Empirical: the hit rate and the per-interval Lateral shares both match the analytic union.
const int shots = 4000;
var outcomes = FireMany(e, shots);
var hits = outcomes.Where(o => o.Hit).ToList();
Assert.True(hits.Count > shots * expectedPOnHull * .5, $"expected roughly {shots * expectedPOnHull:0} hits, got {hits.Count}");
Assert.True(Math.Abs(hits.Count / (double) shots - expectedPOnHull) < .05, $"empirical hit rate {hits.Count / (double) shots:F3} does not track analytic pOnHull {expectedPOnHull:F3}");
foreach (var (lo, hi) in merged)
{
var expectedShare = Mass((lo, hi)) / expectedPOnHull;
if (expectedShare < .05) continue; // too little mass to measure reliably over 4000 shots
var empiricalShare = hits.Count(o => o.Lateral >= lo - 1e-3f && o.Lateral < hi + 1e-3f) / (double) hits.Count;
Assert.True(Math.Abs(expectedShare - empiricalShare) < .1, $"interval [{lo:F2},{hi:F2}): expected share {expectedShare:F3}, empirical {empiricalShare:F3}");
}
}
// F3 (Soul's fix batch, 2026-09-24): every 12.2 fixture, and every shipped hull (Soul's own 360-degree
// sweep), projects to exactly ONE merged interval at its test bearing -- LateralDraw's walk past the
// first interval (the `cumulative += mass` loop continuation) and a Silhouette built with the wrong sigma
// (M12) were both unreachable. This hull is two 2-wide prongs (x in {0,1} and {5,6}, all rows) separated
// by a real gap (x in 2..4, unoccupied) -- a dead-ahead bearing (ell=(-1,0)) gives two disjoint shadow
// intervals, symmetric around the hull's own centre of mass (3,2), which sits in the gap.
[Fact]
public void TwoProngHullSplitsTheScatterAcrossBothIntervals()
{
var shape = new Shape(7, 5);
foreach (var x in new[] { 0, 1, 5, 6 })
for (var y = 0; y < 5; y++)
shape[new int2(x, y)] = true;
const float sigma = 1.5f;
const float precision = 1f / sigma;
var e = Build(TestSettings(), shape, precision); // unaimed: centre of mass (3,2)
// The bearing a shot would freeze, read directly (not through an actual Fire call, which would leave
// a stray shot in the queue for FireMany to double-count).
var travelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target);
var bearing = normalize(e.Target.ToSchematic(travelDirection));
var ell = float2(-bearing.y, bearing.x);
var a = dot(shape.CenterOfMass, ell);
// Independent shadow (raw per-cell intervals, sorted and merged), the same convention
// PlacementAndProbabilityShareOneSilhouette uses.
var raw = shape.Coordinates
.Select(c => dot((float2) c, ell))
.Select(centre => (Lo: centre - (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f, Hi: centre + (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f))
.OrderBy(iv => iv.Lo)
.ToList();
var merged = new List<(float Lo, float Hi)>();
foreach (var iv in raw)
{
if (merged.Count > 0 && iv.Lo <= merged[merged.Count - 1].Hi)
merged[merged.Count - 1] = (merged[merged.Count - 1].Lo, Math.Max(merged[merged.Count - 1].Hi, iv.Hi));
else
merged.Add(iv);
}
Assert.Equal(2, merged.Count); // fixture precondition: a real two-interval shadow
double Mass((float Lo, float Hi) iv)
{
const int steps = 4000;
var width = (iv.Hi - iv.Lo) / steps;
double sum = 0;
for (var i = 0; i <= steps; i++)
{
var x = iv.Lo + i * width;
var z = (x - a) / sigma;
var pdf = Math.Exp(-z * z / 2.0) / (sigma * Math.Sqrt(2.0 * Math.PI));
sum += pdf * (i == 0 || i == steps ? .5 : 1.0);
}
return sum * width;
}
var mass0 = Mass(merged[0]);
var mass1 = Mass(merged[1]);
var totalMass = mass0 + mass1;
Assert.True(totalMass > .05 && totalMass < .95, $"fixture: total pOnHull ({totalMass}) must be non-degenerate");
Assert.True(Math.Abs(mass0 - mass1) / totalMass < .05, "fixture: the two prongs must be near-symmetric for a clean 50/50 check");
var outcomes = FireMany(e, 8000);
var hits = outcomes.Where(o => o.Hit).ToList();
Assert.True(hits.Count > 800, $"expected a healthy number of hits, got {hits.Count}");
var inProngA = hits.Count(o => o.Cell.x <= 1);
var inProngB = hits.Count(o => o.Cell.x >= 5);
Assert.Equal(hits.Count, inProngA + inProngB); // every hit lands in one prong or the other, never the gap
var empiricalShareA = inProngA / (double) hits.Count;
var expectedShareA = mass0 / totalMass;
Assert.True(Math.Abs(empiricalShareA - expectedShareA) < .05,
$"prong A's empirical share {empiricalShareA:F3} does not match its analytic share {expectedShareA:F3}");
// S2 fix batch (Hands, 2026-09-25): within-prong SHAPE, pinned by ratio, not just direction. Prong A is
// x in {0,1}, projected lateral centres 0 and -1 respectively (ell=(-1,0)), a=-3. The old
// "atNearColumn > atFarColumn" check survived M12/M12b (an entire second silhouette built at sigma
// floor .5 or at sigma*sqrt(2)) and the sigma/sqrt(2) Stryker mutants at :~753/:~750, because every one
// of those wrong sigmas still puts more mass on the nearer column -- direction alone can't tell a
// sigma of 1.5 from a sigma of 1.5*sqrt(2). The RATIO can: Soul's own figures are ~2.9 at the true
// sigma against ~1.73 at sigma*sqrt(2), well separated at 8000 shots. The near and far columns' own raw
// (unmerged) intervals are exactly the two ends of `raw` for prong A, reused here rather than
// recomputed.
var nearInterval = raw.First(iv => Math.Abs(iv.Lo - (-1.5f)) < 1e-3f); // x=1: [-1.5,-0.5)
var farInterval = raw.First(iv => Math.Abs(iv.Lo - (-0.5f)) < 1e-3f); // x=0: [-0.5,0.5)
var nearMass = Mass(nearInterval);
var farMass = Mass(farInterval);
var analyticRatio = nearMass / farMass;
Assert.True(analyticRatio > 2.3, $"fixture: analytic near/far ratio ({analyticRatio:F3}) must be well clear of the wrong-sigma ratio (~1.73) to actually distinguish the two");
var atNearColumn = hits.Count(o => o.Cell.x == 1);
var atFarColumn = hits.Count(o => o.Cell.x == 0);
Assert.True(atNearColumn > 0 && atFarColumn > 0, $"expected hits on both columns, got near={atNearColumn} far={atFarColumn}");
var empiricalRatio = atNearColumn / (double) atFarColumn;
Assert.True(Math.Abs(empiricalRatio - analyticRatio) < .6,
$"empirical near/far ratio {empiricalRatio:F3} ({atNearColumn}/{atFarColumn}) does not track the analytic ratio {analyticRatio:F3} -- a sigma perturbed by sqrt(2) would give ~1.73 instead");
}
// HitsLandOnTheFacingEdge supersedes EveryHitLandsOnMetal: on a concave (holed) hull, every hit's Cell is
// occupied, AND nothing occupied precedes it in its own lane -- the test recomputes the lane from the
// outcome's own frozen Bearing/Lateral, independently of FireControl.Lane, and checks production picked
// the nearest cell, not merely an occupied one.
[Fact]
public void HitsLandOnTheFacingEdge()
{
var shape = new Shape(7, 7);
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
shape[new int2(3, 3)] = false; // the hole
var aim = new int2(1, 1);
var e = Build(TestSettings(), shape, precision: .3f, targetFacing: normalize(float2(.8f, .6f)), markerCells: new[] { aim });
e.Shooter.RecordReveal(e.Target, e.Markers[0], SignatureChannel.LaunchFlash, e.Markers[0].Data);
Assert.True(e.Shooter.TrySelectTargetItem(e.Markers[0]));
var outcomes = FireMany(e, 1500);
var hits = outcomes.Where(o => o.Hit).ToList();
Assert.True(hits.Count > 100, $"expected a healthy number of hits to check, got {hits.Count}");
foreach (var outcome in hits)
{
Assert.True(shape[outcome.Cell], $"hit landed on {outcome.Cell}, not part of the hull's own schematic");
var expected = NearestOccupiedCellAtLateral(e.HullData, outcome.Bearing, outcome.Lateral);
Assert.Equal(expected, outcome.Cell);
}
}
// AimingAtAnItemCentresTheScatterOnItsLane closes L585/L587 and replaces AimingAtTheSternHitsTheStern,
// whose fixture fired from the target's own stern and would pass for the wrong reason (any placement rule
// that merely biases toward whatever is nearest the shooter, rather than toward the aim point specifically,
// would also pass a stern-fired, stern-aimed shot). From broadside, hits aimed at the stern land mostly in
// the stern half and hits aimed at the bow mostly in the bow half; from dead astern, hits aimed at the bow
// land on the stern edge (the near edge, since the aim point pulls the group toward the bow but every cell
// between the stern edge and the bow marker is on the lane first).
[Fact]
public void AimingAtAnItemCentresTheScatterOnItsLane()
{
var settings = TestSettings();
var hullShape = SolidShape(3, 11);
var bow = new int2(1, 9); // y=9: firmly in the bow half (y>=5.5)
var stern = new int2(1, 1); // y=1: firmly in the stern half
double SternFraction(float2 facing, int2 aimCell)
{
var e = Build(settings, hullShape, precision: .6f, targetFacing: facing, markerCells: new[] { aimCell });
var aimed = e.Markers[0];
e.Shooter.RecordReveal(e.Target, aimed, SignatureChannel.LaunchFlash, aimed.Data);
Assert.True(e.Shooter.TrySelectTargetItem(aimed));
Assert.Same(aimed, e.Shooter.ResolvedTargetItem);
var outcomes = FireMany(e, 600);
var hits = outcomes.Where(o => o.Hit).ToList();
Assert.True(hits.Count > 100, $"expected a healthy number of hits, got {hits.Count}");
return hits.Count(o => o.Cell.y < 5.5f) / (double) hits.Count;
}
// Broadside: forward perpendicular to the shot's travel direction (world ~+z), so both bow and stern
// are roughly equidistant from the shooter and the aim point, not the entry side, drives the scatter.
var sternAimedBroadside = SternFraction(float2(1, 0), stern);
var bowAimedBroadside = SternFraction(float2(1, 0), bow);
Assert.True(sternAimedBroadside > .7, $"broadside, aimed at the stern: expected mostly stern hits, got sternFraction={sternAimedBroadside}");
Assert.True(bowAimedBroadside < .3, $"broadside, aimed at the bow: expected mostly bow hits, got sternFraction={bowAimedBroadside}");
// Dead astern: forward parallel to the shot's travel direction (bow points away from the shooter), so
// the shot enters through the stern regardless of aim -- aiming at the bow still lands on the near
// (stern) edge, because the stern cells are on the lane first.
var bowAimedAstern = SternFraction(float2(0, 1), bow);
Assert.True(bowAimedAstern > .7, $"dead astern, aimed at the bow: expected the near (stern) edge to still take the hits, got sternFraction={bowAimedAstern}");
}
// TheHudEstimateIsTheCommitPrice: for an in-flight shot, the value the HUD's own estimate (through
// FireControl.CommitProbability, the function ActionGameManager's pending-shot line now calls) and the p
// Commit itself rolls against are the same number at the commit tick.
// F4 (Soul's fix batch, 2026-09-24): the original test called CommitProbability twice and compared the
// two calls to each other -- a self-consistency check that never observed what Commit's own roll actually
// uses. Survivors: Commit applying PSpread twice (M15), and Commit rolling against PFire x pOnHull alone,
// dropping deviation and PSpread entirely (M16) -- both invisible to a test that never rolls anything.
// This version fires many shots against one fixed, non-trivial configuration (nonzero weapon Spread, and
// a target jinked off its fire-time projection so pDeviation is strictly less than 1) and checks the
// EMPIRICAL hit fraction over the real Commit path against the HUD's own forecast, with a tolerance sized
// from the sample (3 sigma of a Bernoulli(p, n)).
// S7 fix batch (Hands, 2026-09-25): Tracking is now authored through Build's own `tracking` parameter (one
// authoring path for every frozen shooter stat, matching Precision/Accuracy) instead of patching
// PendingShot.Tracking after every Fire -- the operator's own rule (2026-09-22) is that a fixture should
// author its state up front, not reach into a frozen field post hoc. An authored Tracking of 6 against this
// 5x7 hull and jink gives pDeviation ~= .33, strictly between 0 and 1, with no patching.
[Fact]
public void TheHudEstimateIsTheCommitPrice()
{
var e = Build(TestSettings(), SolidShape(5, 7), precision: .5f, velocity: 40, spread: 6f, tracking: 6f); // 2.5s flight, commits at 2.0s
var origin = e.Target.Position;
const int shots = 800;
var pendingSnapshots = new List<PendingShot>();
for (var i = 0; i < shots; i++)
{
e.Target.Position = origin; // reset before each Fire so every shot freezes the same FireTargetPosition
var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(.001f);
var index = e.Zone.PendingShots.FindIndex(s => s.ShotId == shotId);
pendingSnapshots.Add(e.Zone.PendingShots[index]);
}
// One jink, after every shot is queued, before any of them commit -- pDeviation < 1 for all of them,
// uniformly (FireTargetVelocity is 0, so DeviationProbability's projection never drifts with elapsed
// time; every shot sees the same deviation regardless of the small firing-order time skew above).
e.Target.Position = origin + float3(4, 0, 0);
var hits = 0;
var commits = 0;
using var c = e.Zone.ShotCommitted.Subscribe(o => { commits++; if (o.Hit) hits++; });
e.Zone.Update(2.1f); // past every shot's 2.0s commit, short of the 2.5s arrival
Assert.Equal(shots, commits);
var expected = FireControl.CommitProbability(pendingSnapshots[0], e.Zone.Time, out var sil);
Assert.True(sil.POnHull > .02f && sil.POnHull < .98f, $"fixture: pOnHull ({sil.POnHull}) must be non-degenerate");
var pDeviation = FireControl.DeviationProbability(pendingSnapshots[0], e.Zone.Time, out var deviation);
Assert.True(deviation > 0f, "fixture: the jink must actually move the target off its projection");
Assert.True(pDeviation > .02f && pDeviation < .98f, $"fixture: pDeviation ({pDeviation}) must be strictly between 0 and 1");
Assert.True(expected > .02f && expected < .98f, $"fixture: the commit price ({expected}) must be strictly between 0 and 1");
var empirical = hits / (double) shots;
// 3 standard deviations of a Bernoulli(expected, shots) draw, so this fails on the real bug (a
// dropped or duplicated factor) far more often than on sampling noise.
var tolerance = 3.0 * Math.Sqrt(expected * (1 - expected) / shots);
Assert.True(Math.Abs(empirical - expected) < tolerance,
$"empirical hit rate {empirical:F4} over {shots} shots is not within {tolerance:F4} of the HUD's forecast {expected:F4}");
}
// TheSigmaFloorHoldsAtHalfACell: pOnHull at Precision 1000 equals pOnHull at Precision 2 on a one-cell
// lane. Cut 12.2's exact integral no longer collapses on its own (POnHullNeverCollapsesAboveThePrecisionCliff
// no longer catches a deleted floor), so this is the test that kills "delete SigmaFloor" -- without the
// floor, sigma at Precision 1000 would be 0.001, and the Gaussian would collapse almost entirely inside a
// single cell's own width rather than sharing the whole one-cell lane the way Precision 2 (sigma .5,
// already at the floor) does.
[Fact]
public void TheSigmaFloorHoldsAtHalfACell()
{
// A 1-wide, 9-tall hull: every occupied cell's shadow along a dead-ahead bearing collapses onto the
// same single-cell-wide lane (ell has one axis zero, so h and the per-cell interval width degenerate
// to the schematic's own cell width) -- "a one-cell lane" per the spec's own name for this test. A
// 1-wide hull has no interior cell at all (Shrink needs a full ring), so this fires unaimed -- the
// hull's own centre of mass, (0,4), is already the middle of the lane.
var shape = SolidShape(1, 9);
float POnHullAt(float precision)
{
var e = Build(TestSettings(), shape, precision);
return FireControl.Inspect(e.Weapon, e.Shooter, e.Target).POnHull;
}
var at2 = POnHullAt(2f);
var at1000 = POnHullAt(1000f);
Assert.Equal(at2, at1000, 4);
}
// ShieldIsOmnidirectional: an active shield absorbs the same shot identically from bow, stern and beam --
// the shield gate in Commit reads only DamageType/Damage, never the bearing this cut introduces.
[Fact]
public void ShieldIsOmnidirectional()
{
var settings = TestSettings();
bool AbsorbsFrom(float2 facing)
{
var e = Build(settings, SolidShape(5, 5), precision: 1f, targetFacing: facing, equipShield: true, shieldCapacity: 1000);
var before = e.Target.Hull.Durability;
var outcomes = FireMany(e, 50);
var hits = outcomes.Where(o => o.Hit).ToList();
Assert.True(hits.Count > 5, $"expected a healthy number of hits, got {hits.Count}");
Assert.True(hits.All(o => o.Shielded), "every hit must be absorbed by the shield regardless of facing");
Assert.Equal(before, e.Target.Hull.Durability);
return true;
}
Assert.True(AbsorbsFrom(float2(0, 1))); // bow toward the shot
Assert.True(AbsorbsFrom(float2(0, -1))); // stern toward the shot
Assert.True(AbsorbsFrom(float2(1, 0))); // beam
}
// S3 fix batch (Hands, 2026-09-25): Lane's own comment ("never allocates on its own") and Silhouette's
// pooled path are pinned directly, instead of trusting the comment. Measured (Release, Longinus-shaped
// fixture unavailable to this test file -- a 7x9 solid hull is used instead): Lane and Silhouette (given a
// caller-supplied buffer) allocate exactly 0 bytes over 1000 calls, confirming the S3 change itself
// (Array.Sort(T[], int, int) resolving Interval/LaneCell's own IComparable<T> at JIT time, not the
// (T[], int, int, IComparer<T>) overload that wrapped even a cached singleton comparer in a fresh delegate
// per call). A full gated-in HitProbability call is NOT zero-allocation (measured ~120 B/call, Release) --
// 12.3 fix batch correction: that cost is exactly three GetBehavior<TargetingSystem> calls (Accuracy,
// Resolution, Precision, ~40 B each), each walking Entity.Equipment (a ReactiveCollection<T>, heap-allocated
// enumerator per foreach) -- NOT `Behaviors` (a plain array, the earlier wording here was wrong; see the
// corrected comment on Forecast). It predates this cut and is out of its scope. The loose `< 500_000` bound
// below is a regression trip-wire, not a proof: a reintroduced per-call IComparer<T> wrapper (~184 B/call)
// would pass it easily. The two zero-allocation asserts just above are what actually catch that regression
// -- Lane and Silhouette are exactly where such a wrapper would reappear -- so the bound here only needs to
// catch something far larger, such as a reintroduced unpooled Interval[]/LaneCell[] on this path.
[Fact]
public void LaneAndSilhouetteAllocateNothingGivenAPooledBuffer()
{
var e = Build(TestSettings(), SolidShape(7, 9), precision: .5f);
var hull = e.HullData;
var travelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target);
var bearing = normalize(e.Target.ToSchematic(travelDirection));
var sil = FireControl.Silhouette(e.Target, hull, null, bearing, .5f);
var laneBuffer = new LaneCell[hull.Shape.Coordinates.Length];
var intervalBuffer = new Interval[hull.Shape.Coordinates.Length];
for (var i = 0; i < 100; i++) FireControl.Lane(hull, bearing, sil.A, laneBuffer); // JIT warm-up
for (var i = 0; i < 100; i++) FireControl.Silhouette(e.Target, hull, null, bearing, .5f, intervalBuffer);
// Steady state, not first-use: under a whole-suite run the runtime's background tier-up is delayed by the
// other tests' JIT activity, so a one-off allocation (measured: 1232 bytes over 1000 calls, in 1 of 12
// full-suite runs and 0 of 100 isolated runs) can land inside a single window. That is a cost paid once.
// A per-call allocation, the regression this test guards, lands in EVERY round, so the smallest round
// over a bounded number of rounds is the steady-state allocation.
long SmallestRoundAllocation(Action round)
{
var smallest = long.MaxValue;
for (var r = 0; r < 20 && smallest != 0; r++)
{
var start = GC.GetAllocatedBytesForCurrentThread();
round();
smallest = Math.Min(smallest, GC.GetAllocatedBytesForCurrentThread() - start);
}
return smallest;
}
var laneAllocated = SmallestRoundAllocation(() => { for (var i = 0; i < 1000; i++) FireControl.Lane(hull, bearing, sil.A, laneBuffer); });
Assert.True(laneAllocated == 0, $"1000 Lane calls allocated {laneAllocated} bytes in every round; \"never allocates\" is not true");
var silhouetteAllocated = SmallestRoundAllocation(() => { for (var i = 0; i < 1000; i++) FireControl.Silhouette(e.Target, hull, null, bearing, .5f, intervalBuffer); });
Assert.True(silhouetteAllocated == 0, $"1000 buffered Silhouette calls allocated {silhouetteAllocated} bytes in every round");
// Recorded, not asserted zero: the full gated-in HitProbability call still allocates, from Entity's own
// collection enumeration (GetBehavior<T> over ReactiveCollection<T>), not from anything this fix batch
// touches. A loose upper bound catches a real regression (e.g. a reintroduced comparer wrapper) without
// pretending Entity's own cost is Lane's or Silhouette's to fix here.
for (var i = 0; i < 100; i++) FireControl.HitProbability(e.Weapon, e.Shooter, e.Target);
var before = GC.GetAllocatedBytesForCurrentThread();
var sum = 0f;
for (var i = 0; i < 1000; i++) sum += FireControl.HitProbability(e.Weapon, e.Shooter, e.Target);
var hitProbabilityAllocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.True(sum > 0f, "fixture: this must be a real, gated-in call, not a gated-out one");
Assert.True(hitProbabilityAllocated < 500_000, $"1000 gated-in HitProbability calls allocated {hitProbabilityAllocated} bytes -- well above Entity's own ~120 B/call baseline; likely a reintroduced Lane/Silhouette allocation");
}
}