F1 (correctness): LateralDraw clamped the drawn lateral to the closed [Lo, Hi], not the spec's half-open [Lo, Hi). Soul enumerated all 2^24 NextFloat outputs against TurningArmourIntoTheShotTakesItOnTheArmour's own geometry and found 3 that draw exactly Hi, which Lane's own half-open filter then admits no cell for -- a hit that passed its roll landing nowhere (R3). Fixed the clamp; Lane's silent int2.zero fallback on an empty lane is now a thrown InvalidOperationException, since an empty lane is provably impossible after the fix. Committed Soul's edge probe as LateralDrawIsHalfOpenAtTheShadowsTopEdge. F6/F8: HitProbability, Inspect and Fire each assembled bearing -> Silhouette -> PSpread (or recomputed range) in their own copy; a shooter-frame bearing bug in HitProbability's copy survived while the identical bug in Inspect's copy was caught. Collapsed into one Forecast function and one PFire(out range) overload; both callers, and Fire, read the one computation. F2: FireControlCut12Tests' zone names carried a Guid, breaking every other cut's fixed-seed convention (FireControlCut6Tests.cs:62-63) and reseeding the die every run -- the actual source of the flake in TurningArmourIntoTheShotTakesItOnTheArmour. Fixed to a fixed zone name. F3/F4/F5/F7: added a two-prong hull fixture (LateralDraw's walk past the first interval, and a wrong-sigma placement, were unreachable when every fixture projects to one interval), a real-roll empirical test for CommitProbability (the old one called the pure function twice and compared it to itself), enough Penetration in TurningAfterCommitChangesNothing for ApplyHit to actually read the bearing, and a 2x2 item fixture for ResolveAimPoint's average (every "Marker" elsewhere is 1x1, so sum/length and sum*length agree). F9: Lane's own comment claimed no allocation while its sort used Comparer<T>.Create over a closure; LaneCell now carries its own precomputed projection and Lane sorts with a stateless singleton comparer. Corrected an overclaiming comment in FireAuthorityTests (SigmaFloor makes the aimed-lane draw ~68% per shot, not deterministic; the fixture's fixed zone name is what actually makes it reproduce). Cost: Silhouette takes an optional caller-rented Interval[] buffer; the forecast path (HitProbability/Inspect, via Forecast) rents from ArrayPool<Interval>.Shared and returns it before returning, since neither reads sil.Intervals after the call. Commit/CommitProbability still get an owned array, since LateralDraw needs it alive past the call. Measured on LonginusX (66 cells, Release): unaimed 672 B -> 184 B/call (1512-1637ns before, 1482-1560ns after); aimed 1160 B -> 672 B/call (2123-2140ns before, 2094-2162ns after) -- the remaining ~488B/call gap between aimed and unaimed is ResolveAimPoint's own CellsOf allocation (a List<int2>+ToArray per call), not in this batch's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
997 lines
55 KiB
C#
997 lines
55 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 System.Reflection;
|
|
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, Rarity = 0, Color = new float3(1, 1, 1) } },
|
|
QualityPriceModifier = new ExponentialLerp(),
|
|
TargetDetectionInfoThreshold = .1f,
|
|
TargetArmorInfoThreshold = .2f,
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// 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)
|
|
{
|
|
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 TestCatalogGlobal { Name = "Temperament" });
|
|
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),
|
|
DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }
|
|
} }
|
|
});
|
|
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(100000f)
|
|
} }
|
|
});
|
|
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
|
|
});
|
|
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(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 = "Cut12", 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
|
|
}
|
|
|
|
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.Entities.Add(shooter);
|
|
zone.Entities.Add(target);
|
|
shooter.Activate();
|
|
target.Activate();
|
|
|
|
// 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.Target.Value = 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
|
|
};
|
|
}
|
|
|
|
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.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");
|
|
}
|
|
|
|
// 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);
|
|
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}");
|
|
}
|
|
|
|
// 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}");
|
|
}
|
|
|
|
// F1 (Soul's fix batch, 2026-09-24): LateralDraw's clamp used to close the top of the shadow interval
|
|
// ([Lo, Hi] instead of the spec's half-open [Lo, Hi)). Soul enumerated all 2^24 NextFloat outputs against
|
|
// this exact geometry (TurningArmourIntoTheShotTakesItOnTheArmour's own 3x11 hull, bearing (0,-1),
|
|
// Precision .4 -> interval [-0.5, 2.5]) and found 3 values of u that drew s == Hi == 2.5 exactly, at which
|
|
// Lane's own half-open filter (`s >= centre + h` continues, :731) then found no candidate cell at all,
|
|
// returning an empty lane for a shot that had already passed its roll (R3 violation) -- the reflection
|
|
// call below reproduces that exact draw. u=0 is the analogous exact-Lo case (the first NextFloat output).
|
|
[Fact]
|
|
public void LateralDrawIsHalfOpenAtTheShadowsTopEdge()
|
|
{
|
|
var hull = new HullData { Name = "H", HullType = HullType.Ship, Shape = SolidShape(3, 11) };
|
|
var bearing = float2(0, -1);
|
|
const float precision = .4f;
|
|
var sil = FireControl.Silhouette(null, hull, null, bearing, precision);
|
|
Assert.Equal(1, sil.Count); // fixture precondition: one interval, matching Soul's own probe
|
|
Assert.Equal(-0.5f, sil.Intervals[0].Lo, 3);
|
|
Assert.Equal(2.5f, sil.Intervals[0].Hi, 3);
|
|
|
|
var lateralDraw = (Func<Silhouette, float, float>) typeof(FireControl)
|
|
.GetMethod("LateralDraw", BindingFlags.NonPublic | BindingFlags.Static)
|
|
.CreateDelegate(typeof(Func<Silhouette, float, float>));
|
|
|
|
void AssertLandsOnMetal(float u, string label)
|
|
{
|
|
var s = lateralDraw(sil, u);
|
|
var buffer = new LaneCell[hull.Shape.Coordinates.Length];
|
|
var count = FireControl.Lane(hull, bearing, s, buffer);
|
|
Assert.True(count > 0, $"{label}: u={u:R} drew s={s:R} (interval [{sil.Intervals[0].Lo},{sil.Intervals[0].Hi}]) -- Lane found no occupied cell for a hit that already passed its roll");
|
|
Assert.True(hull.Shape[buffer[0].Cell], $"{label}: Lane's own first cell {buffer[0].Cell} is not on the hull's schematic");
|
|
}
|
|
|
|
AssertLandsOnMetal(0.9999998f, "at the top edge (u drives s to exactly Hi)");
|
|
AssertLandsOnMetal(0f, "at the bottom edge (u drives s to exactly Lo)");
|
|
}
|
|
|
|
// Entity.ApplyHit's own penetration march, replicated independently (not by calling ApplyHit or reading
|
|
// its source beyond the documented convention 12.2 explicitly leaves untouched: cell centres at
|
|
// +.5, .5-unit steps, hullData.Shape[int2(point)] as the stop condition). Used to compute, from a frozen
|
|
// Bearing, the full set of cells a march would consume -- independent of which bearing (committed or
|
|
// live) the test feeds it.
|
|
private static HashSet<int2> ExpectedMarchCells(HullData hull, int2 cell, float2 bearing, float penetration)
|
|
{
|
|
var cells = new HashSet<int2> { cell };
|
|
if (penetration <= .5f) return cells;
|
|
var vector = normalize(bearing);
|
|
var point = (float2) cell + float2(.5f);
|
|
var distance = 0f;
|
|
while (distance < penetration && hull.Shape[int2(point)])
|
|
{
|
|
distance += .5f;
|
|
cells.Add(int2(point));
|
|
point += vector * .5f;
|
|
}
|
|
return cells;
|
|
}
|
|
|
|
// 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 ApplyHit's march
|
|
// (Entity.cs, gated at `penetration > .5f`) 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 marches several cells deep, so the full
|
|
// damaged-cell set is asserted against the committed Bearing/Lateral, not the live one.
|
|
[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
|
|
|
|
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 = ExpectedMarchCells(e.HullData, committed.Cell, committed.Bearing, 3f);
|
|
|
|
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 = ExpectedMarchCells(e.HullData, committed.Cell, wrongBearing, 3f);
|
|
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 march 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 });
|
|
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}");
|
|
|
|
// Within-prong shape, not just the aggregate share: prong A is x in {0,1}, projected lateral centres
|
|
// 0 and -1 respectively (ell=(-1,0)); a=-3 sits closer to x=1's centre (-1) than x=0's (0), so a real
|
|
// Gaussian shape (not a coin flip within the prong, and not "first cell only") puts strictly more mass
|
|
// on x=1 than x=0.
|
|
var atNearColumn = hits.Count(o => o.Cell.x == 1);
|
|
var atFarColumn = hits.Count(o => o.Cell.x == 0);
|
|
Assert.True(atNearColumn > atFarColumn,
|
|
$"expected the column nearer the aim point (x=1, {atNearColumn} hits) to outweigh the far one (x=0, {atFarColumn} hits)");
|
|
}
|
|
|
|
// 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 });
|
|
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];
|
|
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)).
|
|
[Fact]
|
|
public void TheHudEstimateIsTheCommitPrice()
|
|
{
|
|
var e = Build(TestSettings(), SolidShape(5, 7), precision: .5f, velocity: 40, spread: 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);
|
|
// The fixture's own Targeting system freezes Tracking = 100000 (full-strength isolation, Build's
|
|
// own convention) -- against that, a jink small enough to keep the target on this 5x7 hull barely
|
|
// registers (pDeviation ~= .99996). Lower the frozen Tracking after the fact, the same way
|
|
// CommitProbabilityIsTheExactProductOfItsFourFactors overrides a frozen field, so the jink below
|
|
// produces a pDeviation strictly between 0 and 1.
|
|
var index = e.Zone.PendingShots.FindIndex(s => s.ShotId == shotId);
|
|
var shot = e.Zone.PendingShots[index];
|
|
shot.Tracking = 6f;
|
|
e.Zone.PendingShots[index] = shot;
|
|
pendingSnapshots.Add(shot);
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|