Conflicts: Behaviors.cs keeps Union(48, PickupData) and Union(49, LaunchSuppressorData); Zone.cs keeps the departure record on _entities with master's Visibility (emissions-heard); ContactRevealTests.cs keeps master's removal of the emission-departure test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
611 lines
34 KiB
C#
611 lines
34 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 float3 = CultMath.float3;
|
|
using int2 = CultMath.int2;
|
|
using Random = CultMath.Random;
|
|
|
|
// Cut 9 (docs/fire-control-cut.md, "the die is not a die"): pins 9.1 (the hit roll must be uniform on
|
|
// [0,1), not a step function fixed by CombatSeed alone -- Cut 6b's own prescription, shipped through three
|
|
// cuts, never once measured), 9.2 (pOnHull must stay monotonic in Precision over the whole domain instead of
|
|
// collapsing to zero past a cliff no shipped hull's non-integral centre of mass can avoid) and 9.3 (a Target
|
|
// surviving a dock/die/undock cycle must not crash LockWeapon). Builds its own fixtures, the same convention
|
|
// every earlier cut's test file establishes.
|
|
public sealed class FireControlCut9Tests : IDisposable
|
|
{
|
|
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut9-" + Guid.NewGuid().ToString("N"));
|
|
private string Catalog => Path.Combine(_root, "Aetheria.cc");
|
|
|
|
public FireControlCut9Tests() => 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,
|
|
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;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------------
|
|
// 9.1: the die itself. No weapon or targeting gear -- these tests drive FireControl.Commit directly
|
|
// through synthetic PendingShots (FireSynthetic below), bypassing Fire() entirely, so PBase and ShotId
|
|
// are exact test inputs rather than products of another cut's own Accuracy/Resolution/Spread/pOnHull
|
|
// arithmetic.
|
|
// ---------------------------------------------------------------------------------------------------
|
|
|
|
private sealed class DieEngagement
|
|
{
|
|
public ItemManager Items;
|
|
public Zone Zone;
|
|
public Ship Shooter;
|
|
public Ship Target;
|
|
}
|
|
|
|
// zoneName seeds Zone.CombatSeed (StableHash of the GalaxyZone's Name) -- distinct names give distinct
|
|
// zone seeds, the way TheDieIsUniform sweeps several of them.
|
|
private DieEngagement BuildDieEngagement(GameplaySettings settings, string zoneName)
|
|
{
|
|
var hullData = new HullData
|
|
{
|
|
Name = "Hull", 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);
|
|
// A bare, inert item -- not read by anything this test cares about, but Entity.Update's
|
|
// `foreach (var item in _orderedEquipment)` NREs if an entity was never equipped with anything at
|
|
// all (_orderedEquipment stays null until TryEquip/RemoveEquip first assign it), the same reason
|
|
// FireControlCut8Tests.BuildLockScenario equips its own bystander with a token gun.
|
|
cache.Upsert(new GearData
|
|
{
|
|
Name = "Marker", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1000000,
|
|
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000
|
|
});
|
|
cache.FlushAsync().Wait();
|
|
|
|
var ledger = new ProvenanceLedger();
|
|
ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 };
|
|
ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 };
|
|
ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 };
|
|
ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 };
|
|
var items = new ItemManager(cache, ledger, settings, _ => { });
|
|
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = zoneName, Owner = null }, null);
|
|
|
|
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Hull"));
|
|
var markerRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Marker"));
|
|
|
|
// Durability high enough to survive the thousands of direct hits TheDieIsUniform's bisection fires
|
|
// at it -- an entity whose hull dies partway through would stop taking further hits and bias
|
|
// whatever's left of the sample.
|
|
var shooterHull = new EquippableItem { Data = hullRef, Durability = 1000000, Lot = 1 };
|
|
var shooter = new Ship(items, zone, shooterHull, new EntitySettings());
|
|
var shooterMarker = new EquippableItem { Data = markerRef, Durability = 1000000, Lot = 3 };
|
|
Assert.True(shooter.TryFindSpace(shooterMarker, out var shooterMarkerPos));
|
|
Assert.True(shooter.TryEquip(shooterMarker, shooterMarkerPos));
|
|
|
|
var targetHull = new EquippableItem { Data = hullRef, Durability = 1000000, Lot = 2 };
|
|
var target = new Ship(items, zone, targetHull, new EntitySettings());
|
|
var targetMarker = new EquippableItem { Data = markerRef, Durability = 1000000, Lot = 4 };
|
|
Assert.True(target.TryFindSpace(targetMarker, out var targetMarkerPos));
|
|
Assert.True(target.TryEquip(targetMarker, targetMarkerPos));
|
|
|
|
zone.Admit(shooter, piloted: false);
|
|
zone.Admit(target, piloted: false);
|
|
|
|
shooter.Position = float3.zero;
|
|
target.Position = float3(0, 0, 100);
|
|
|
|
return new DieEngagement { Items = items, Zone = zone, Shooter = shooter, Target = target };
|
|
}
|
|
|
|
// Fires one synthetic shot with an exact ShotId and PFire, bypassing FireControl.Fire's own Accuracy/
|
|
// Resolution/Spread/pOnHull arithmetic -- 9.1's tests own the roll, not the factors that feed it.
|
|
// FireTargetPosition/Velocity match the target's actual (stationary) position and FireTime equals the
|
|
// zone's own time, so DeviationProbability commits to exactly 1. Spread stays 0 (PSpread's own early-out,
|
|
// neutral regardless of silhouette) and TravelDirection points straight at the target through a
|
|
// full-strength, unaimed silhouette (Aimed unset, so ResolveAimPoint falls back to the hull's own centre
|
|
// of mass; SolidShape(5,5) at this cut's floored sigma puts every real shadow interval many sigma from
|
|
// the hull's edge), so pOnHull sits indistinguishable from 1 -- PFire is then the one number Commit's die
|
|
// reads, same as PBase was before 12.2. CommitTime/ArrivalTime are set in the past so the shot commits and
|
|
// resolves inside the very next zone.Update call, synchronously, before this method returns.
|
|
private bool FireSynthetic(DieEngagement e, int shotId, float pFire, float precision = 1000f)
|
|
{
|
|
bool? hit = null;
|
|
using var sub = e.Zone.ShotResolved.Subscribe(o =>
|
|
{
|
|
if (o.ShotId == shotId) hit = o.Hit;
|
|
});
|
|
var shot = new PendingShot
|
|
{
|
|
ShotId = shotId,
|
|
Source = e.Shooter,
|
|
Target = e.Target,
|
|
Damage = 1f,
|
|
Penetration = 0f,
|
|
DamageSpread = 0f,
|
|
DamageType = DamageType.Kinetic,
|
|
PFire = pFire,
|
|
Tracking = 1e6f,
|
|
Precision = precision,
|
|
TravelDirection = normalize((e.Target.Position - e.Shooter.Position).xz),
|
|
Spread = 0f,
|
|
FireRange = length((e.Target.Position - e.Shooter.Position).xz),
|
|
FireTargetPosition = e.Target.Position,
|
|
FireTargetVelocity = float3.zero,
|
|
FireTime = e.Zone.Time,
|
|
CommitTime = e.Zone.Time - 1f,
|
|
ArrivalTime = e.Zone.Time - 1f
|
|
};
|
|
e.Zone.PendingShots.Add(shot);
|
|
e.Zone.Update(0.001f);
|
|
if (hit == null) throw new InvalidOperationException($"synthetic shot {shotId} did not resolve");
|
|
return hit.Value;
|
|
}
|
|
|
|
// Bisects the exact roll (the p at which Hit flips from false to true) for one shot id: since Commit's
|
|
// die is `hit = random.NextFloat() < p` with random seeded purely from (zone, shotId), the threshold p
|
|
// this converges to *is* that shot's draw, recovered through the public Commit/ShotResolved surface
|
|
// rather than by reaching into FireControl's private RNG plumbing.
|
|
private float BisectRoll(DieEngagement e, int shotId)
|
|
{
|
|
float lo = 0f, hi = 1f;
|
|
for (var i = 0; i < 24; i++)
|
|
{
|
|
var mid = (lo + hi) / 2f;
|
|
if (FireSynthetic(e, shotId, mid)) hi = mid; else lo = mid;
|
|
}
|
|
return (lo + hi) / 2f;
|
|
}
|
|
|
|
// 9.1 (Soul C1, blocking): the defect this whole cut exists for. A single xorshift round does not
|
|
// diffuse a seed that differs from its neighbours only in its low bits (shot.ShotId), so every zone's
|
|
// first draw was a near-constant function of ShotId, span ~0.125 -- a shot was a step function of p,
|
|
// fixed at zone creation, not a roll. Mutation: delete the fmix32 finalizer FireControl.Commit applies
|
|
// before constructing its Random. Must die on both assertions below (a mixed-in seed produces both a
|
|
// wide roll span and a hit fraction that tracks p; the pre-fix code fails both).
|
|
[Fact]
|
|
public void TheDieIsUniform()
|
|
{
|
|
var settings = TestSettings();
|
|
// Several distinct zone seeds (Zone.CombatSeed = GalaxyZone.Name.StableHash()) -- 9.1's defect is a
|
|
// property of the diffusion, not of any one unlucky seed, so a single zone passing would prove
|
|
// nothing about the fix.
|
|
var zoneNames = new[] { "Cut9-Alpha", "Cut9-Beta", "Cut9-Gamma", "Cut9-Delta", "Cut9-Epsilon" };
|
|
var intermediatePs = new[] { .3f, .6f };
|
|
|
|
foreach (var zoneName in zoneNames)
|
|
{
|
|
var e = BuildDieEngagement(settings, zoneName);
|
|
|
|
// Roll span across a dense run of *consecutive* shot ids -- 9.1's defect is specifically that
|
|
// ShotId only ever perturbs the seed's low bits, so this is the range that actually exercises
|
|
// it (a step of 877 up to 20000 flips high bits of ShotId itself and gets a wide span even
|
|
// pre-fix, proving nothing about the diffusion).
|
|
var rolls = new List<float>();
|
|
for (var shotId = 0; shotId < 300; shotId++)
|
|
rolls.Add(BisectRoll(e, shotId));
|
|
var span = rolls.Max() - rolls.Min();
|
|
Console.WriteLine($"[TheDieIsUniform] zone={zoneName} roll span={span:F4} min={rolls.Min():F4} max={rolls.Max():F4} n={rolls.Count}");
|
|
Assert.True(span > 0.2f,
|
|
$"zone {zoneName}: roll span {span:F4} across {rolls.Count} shot ids is not substantially more than 0.2 -- the die is still a step function fixed at zone creation");
|
|
|
|
// Hit fraction at an intermediate p must track p, not sit near 0 or 1 (9.1's step-function
|
|
// symptom: a zone either always hits or never hits once p crosses its one fixed threshold).
|
|
foreach (var p in intermediatePs)
|
|
{
|
|
const int n = 500;
|
|
var hits = 0;
|
|
for (var shotId = 0; shotId < n; shotId++)
|
|
if (FireSynthetic(e, 1_000_000 + shotId, p)) hits++;
|
|
var fraction = hits / (float) n;
|
|
Console.WriteLine($"[TheDieIsUniform] zone={zoneName} p={p} hit fraction={fraction:F3} ({hits}/{n})");
|
|
Assert.True(Math.Abs(fraction - p) < 0.08f,
|
|
$"zone {zoneName}, p={p}: observed hit fraction {fraction:F3} over {n} shots is not near p -- die is not uniform");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Commit's seed is ((CombatSeed * K) ^ ShotId) | 1 before the fmix32: the `| 1` is part of the shot dice that
|
|
// 9.1 shipped, so two shot ids that differ only in bit 0 share a seed and so a draw. Mutation: drop the
|
|
// `| 1` -- the pairs below then draw different rolls.
|
|
[Fact]
|
|
public void ShotIdsDifferingOnlyInTheLowBitShareTheirDraw()
|
|
{
|
|
var e = BuildDieEngagement(TestSettings(), "Cut9-LowBit");
|
|
var four = BisectRoll(e, 4);
|
|
Assert.Equal(four, BisectRoll(e, 5), 5);
|
|
var six = BisectRoll(e, 6);
|
|
Assert.Equal(six, BisectRoll(e, 7), 5);
|
|
Assert.NotEqual(four, six, 3);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------------
|
|
// 9.2: pOnHull must stay monotonic in Precision over the whole domain. A neutral shooter/target pair
|
|
// (Accuracy 1, huge Resolution/Tracking, zero weapon Spread), the same isolation convention
|
|
// FireControlCut6dTests.Build uses, so HitProbability's PBase collapses to exactly pOnHull.
|
|
// ---------------------------------------------------------------------------------------------------
|
|
|
|
private sealed class KernelEngagement
|
|
{
|
|
public ItemManager Items;
|
|
public Zone Zone;
|
|
public Ship Shooter;
|
|
public Ship Target;
|
|
public Weapon Weapon;
|
|
}
|
|
|
|
private KernelEngagement BuildKernelEngagement(GameplaySettings settings, Shape hullShape, float precision, float spread = 0)
|
|
{
|
|
var hullData = new HullData
|
|
{
|
|
Name = "Hull", HullType = HullType.Ship, Shape = hullShape, 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(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(10), Range = Constant(1000), MinRange = Constant(0),
|
|
Velocity = Constant(0), Spread = Constant(spread), DamageSpread = Constant(0),
|
|
Penetration = Constant(0), 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
|
|
{
|
|
// Cut 11: a finite Resolution, so pSensor actually varies with info. At 1000 it saturated to exactly 1
|
|
// for any info above the detection threshold, which made `x * pSensor` and `x / pSensor` the same
|
|
// number and let a mutant of Inspect's product survive HitProbabilityMatchesInspect.
|
|
Accuracy = Constant(1f), Resolution = Constant(2f), Precision = Constant(precision), Tracking = Constant(100000f)
|
|
} }
|
|
});
|
|
cache.FlushAsync().Wait();
|
|
|
|
var ledger = new ProvenanceLedger();
|
|
for (var i = 1; i <= 5; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
|
|
var items = new ItemManager(cache, ledger, settings, _ => { });
|
|
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut9-Kernel", Owner = null }, null);
|
|
|
|
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Hull"));
|
|
var shooterHull = new EquippableItem { Data = hullRef, Durability = 1000000, Lot = 1 };
|
|
var shooter = new Ship(items, zone, shooterHull, new EntitySettings());
|
|
|
|
var gunRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Gun"));
|
|
var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 };
|
|
Assert.True(shooter.TryEquip(gun, new int2(0, 0)));
|
|
var weaponItem = shooter.Equipment.Single(x => x.EquippableItem == gun);
|
|
var weapon = (Weapon) weaponItem.Behaviors.Single(b => b is Weapon);
|
|
|
|
var targetingRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Targeting"));
|
|
var targeting = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 3 };
|
|
Assert.True(shooter.TryFindSpace(targeting, out var tpos));
|
|
Assert.True(shooter.TryEquip(targeting, tpos));
|
|
|
|
var targetHull = new EquippableItem { Data = hullRef, Durability = 1000000, Lot = 4 };
|
|
var target = new Ship(items, zone, targetHull, new EntitySettings());
|
|
var targetGunRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Gun"));
|
|
var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 5 };
|
|
Assert.True(target.TryEquip(targetGun, new int2(0, 0)));
|
|
|
|
zone.Admit(shooter, piloted: false);
|
|
zone.Admit(target, piloted: false);
|
|
|
|
shooter.Position = float3.zero;
|
|
target.Position = float3(0, 0, 100);
|
|
shooter.SetTarget(target);
|
|
shooter.SetIff(target, true);
|
|
shooter.EntityInfoGathered[target] = 1f; // full reveal: pSensor = 1
|
|
|
|
zone.Update(0f); // warm-up: resolves weapon/targeting stats before Inspect reads them
|
|
|
|
return new KernelEngagement { Items = items, Zone = zone, Shooter = shooter, Target = target, Weapon = weapon };
|
|
}
|
|
|
|
// 9.2 (Soul C2, blocking): pOnHull collapsed to zero once sigma (1/Precision) fell below about half a
|
|
// cell, because the discrete kernel sum badly undershoots the continuous 2*pi*sigma^2 denominator
|
|
// against an aim point that is not exactly on a cell centre -- true of every shipped hull (no centre of
|
|
// mass is integral). A 7x13 solid hull reproduces the same off-integer-centre shape without needing the
|
|
// real catalog open in this project.
|
|
// Cut 12.2 (docs/fire-control-cut.md): Silhouette replaces the discrete sum with an exact 1D Gaussian
|
|
// integral over the hull's lateral shadow, which does not undershoot at any sigma -- so deleting
|
|
// SigmaFloor no longer reproduces the collapse this test was written to catch (TheSigmaFloorHoldsAtHalfACell
|
|
// is what now kills that mutant). This test is kept anyway as a plain regression pin: pOnHull must stay
|
|
// well clear of zero, and non-collapsing, across the whole Precision domain, whichever formula computes it.
|
|
[Fact]
|
|
public void POnHullNeverCollapsesAboveThePrecisionCliff()
|
|
{
|
|
var settings = TestSettings();
|
|
var precisions = new[] { .1f, .3f, .6f, 1f, 1.19f, 2f, 3f, 4f, 5f, 6f, 8f, 10f, 15f, 25f, 50f, 100f, 250f, 500f, 1000f };
|
|
// Even extents: CenterOfMass is the raw mean of integer cell coordinates (ItemData.cs), with no
|
|
// +0.5 cell-centre offset -- an odd extent's mean lands exactly on an occupied cell (no cliff to
|
|
// reproduce), while an even one lands on the half-cell boundary between two cells, the same
|
|
// off-integer shape every shipped hull's real (irregular) footprint produces (Longinus 2.5/6.697,
|
|
// Zenith 5.5/5.5, Turret 3.5/3.5).
|
|
var hullShape = SolidShape(6, 12);
|
|
|
|
var samples = new List<(float Precision, float POnHull)>();
|
|
foreach (var precision in precisions)
|
|
{
|
|
var e = BuildKernelEngagement(settings, hullShape, precision);
|
|
var diagnostic = FireControl.Inspect(e.Weapon, e.Shooter, e.Target);
|
|
samples.Add((precision, diagnostic.POnHull));
|
|
}
|
|
|
|
Console.WriteLine("[POnHullNeverCollapsesAboveThePrecisionCliff] " +
|
|
string.Join(", ", samples.Select(s => $"P={s.Precision}:{s.POnHull:F4}")));
|
|
|
|
foreach (var (precision, pOnHull) in samples)
|
|
Assert.True(pOnHull > 0.05f, $"Precision {precision}: pOnHull collapsed to {pOnHull:F4} -- a weapon this well-aimed must still be able to hit the hull");
|
|
|
|
// Monotonic non-collapse over the *whole* domain: once precision climbs (sigma shrinks) pOnHull must
|
|
// not fall back toward zero the way the undamped continuous-integral denominator did past the cliff.
|
|
// (Below Precision ~1, pOnHull legitimately rises as the kernel tightens onto the aim point; the
|
|
// regression this pins is the second half of the curve heading back down to nothing.)
|
|
var peakIndex = samples.Select(s => s.POnHull).ToList().IndexOf(samples.Max(s => s.POnHull));
|
|
for (var i = peakIndex + 1; i < samples.Count; i++)
|
|
Assert.True(samples[i].POnHull > samples[peakIndex].POnHull * 0.5f,
|
|
$"pOnHull fell from {samples[peakIndex].POnHull:F4} at Precision {samples[peakIndex].Precision} to {samples[i].POnHull:F4} at Precision {samples[i].Precision} -- the cliff is back");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------------
|
|
// Cut 10 (docs/fire-control-cut.md): one fire-control model, shared at the level of its factors.
|
|
// HitProbability is the hot path -- every AI and turret, per weapon, per tick -- and Inspect is the
|
|
// debug HUD's view. The diagnostics refactor (72c0109c) kept them as one model by routing the hot path
|
|
// THROUGH Inspect, which made every call build the diagnostic struct and run the aim-point and kernel
|
|
// work before checking whether the target was even in range: 9.87 us and 448 B per call, measured by a
|
|
// Soul pass, on paths that used to bail in nanoseconds. Both now call the same factor functions.
|
|
// ---------------------------------------------------------------------------------------------------
|
|
|
|
// The invariant the diagnostics refactor was protecting: the HUD can never report a probability the
|
|
// simulation does not use. Inspect.PBase is HitProbability's own answer, so the gates and the product
|
|
// have one owner; what is left to defend is that the factors the HUD displays are the ones the
|
|
// simulation multiplies, and that a gate the HUD shows closed is one the simulation refuses on. Swept
|
|
// across Precision, info levels and a spread cone that does not fill the silhouette -- every factor
|
|
// strictly between 0 and 1 somewhere, so a factor dropped, doubled or divided in either caller shows.
|
|
[Fact]
|
|
public void TheHudShowsTheFactorsTheSimulationMultiplies()
|
|
{
|
|
var settings = TestSettings();
|
|
var checkedConfigurations = 0;
|
|
var sawPartialSpread = false;
|
|
// F6 (Soul's fix batch, 2026-09-24): a shooter at float3.zero with both entities at Direction's
|
|
// default (0,1) let a mutant that reads the SHOOTER's facing instead of the target's (Bearing(source,
|
|
// ...) instead of Bearing(target, ...)) survive -- the two facings coincided, so the substitution was
|
|
// invisible. An off-origin shooter and a target facing with |fx| > |fy|, distinct from the shooter's
|
|
// own (unused) default facing, make that substitution produce a different, wrong bearing.
|
|
var origin = float3(37, 0, -11);
|
|
foreach (var precision in new[] { .3f, 1.19f, 5f })
|
|
{
|
|
// 6 cells wide at cell size 1 subtends ~3.4 degrees at 100 units, inside an 8-degree cone.
|
|
var e = BuildKernelEngagement(settings, SolidShape(6, 12), precision, spread: 8);
|
|
e.Shooter.Position = origin;
|
|
e.Target.Position = origin + float3(0, 0, 100);
|
|
e.Target.Direction = normalize(float2(.8f, .6f));
|
|
|
|
foreach (var info in new[] { .2f, .5f, 1f })
|
|
{
|
|
e.Shooter.EntityInfoGathered[e.Target] = info;
|
|
var hud = FireControl.Inspect(e.Weapon, e.Shooter, e.Target);
|
|
var hot = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target);
|
|
Assert.True(hud.Designated && hud.InArc, $"info {info}: precondition, every gate open");
|
|
Assert.True(hot > 0f, $"info {info}, Precision {precision}: precondition, a shot that can land");
|
|
Assert.Equal(hot, hud.Accuracy * hud.PSensor * hud.PSpread * hud.POnHull, 5);
|
|
sawPartialSpread |= hud.PSpread < .99f;
|
|
checkedConfigurations++;
|
|
}
|
|
e.Shooter.EntityInfoGathered[e.Target] = 1f;
|
|
|
|
void Closed(string label, Func<FireControlDiagnostic, bool> gate)
|
|
{
|
|
var hud = FireControl.Inspect(e.Weapon, e.Shooter, e.Target);
|
|
Assert.False(gate(hud), $"{label}: the HUD must show this gate closed");
|
|
Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, e.Target));
|
|
checkedConfigurations++;
|
|
}
|
|
|
|
e.Target.Position = origin + float3(0, 0, 5000);
|
|
Closed("out of range", d => d.Designated);
|
|
e.Target.Position = origin + float3(0, 0, -100);
|
|
Closed("behind the mount", d => d.InArc);
|
|
|
|
// A shooter far from the origin: at float3.zero `target - source` and `target + source` are the
|
|
// same vector, so a sign mutant in the bearing only shows here (500 in range vs 1300 out).
|
|
e.Shooter.Position = float3(600, 0, 0);
|
|
e.Target.Position = float3(600, 0, 500);
|
|
var far = FireControl.Inspect(e.Weapon, e.Shooter, e.Target);
|
|
Assert.Equal(500f, far.Range, 3);
|
|
Assert.True(far.Designated && FireControl.HitProbability(e.Weapon, e.Shooter, e.Target) > 0f, "shooter far from the origin");
|
|
checkedConfigurations++;
|
|
e.Shooter.Position = origin;
|
|
e.Target.Position = origin + float3(0, 0, 100);
|
|
|
|
e.Shooter.VisibleEntities.Remove(e.Target);
|
|
Closed("not visible", d => d.Designated);
|
|
|
|
Assert.False(FireControl.Inspect(e.Weapon, e.Shooter, null).HasTarget);
|
|
Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, null));
|
|
}
|
|
Assert.True(sawPartialSpread, "fixture: the spread cone must not fill the silhouette, or PSpread is 1 and proves nothing");
|
|
Assert.Equal(21, checkedConfigurations); // 3 precisions x (3 info levels + out of range + behind + far shooter + not visible)
|
|
}
|
|
|
|
// The cost regression itself. A target out of range is the common case for every AI and turret, and it
|
|
// must bail before any factor is computed. Mutation: route HitProbability back through Inspect; the
|
|
// gated-out call then allocates the aim-point list and the kernel's arrays on every evaluation.
|
|
[Fact]
|
|
public void GatedOutHitProbabilityAllocatesNothing()
|
|
{
|
|
var e = BuildKernelEngagement(TestSettings(), SolidShape(6, 12), 1.19f);
|
|
e.Target.Position = float3(0, 0, 5000); // out of range, still visible and in arc
|
|
|
|
for (var i = 0; i < 100; i++) FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); // JIT warm-up
|
|
|
|
var before = GC.GetAllocatedBytesForCurrentThread();
|
|
var sum = 0f;
|
|
for (var i = 0; i < 1000; i++) sum += FireControl.HitProbability(e.Weapon, e.Shooter, e.Target);
|
|
var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
|
|
|
Assert.Equal(0f, sum);
|
|
Assert.True(allocated == 0, $"1000 gated-out HitProbability calls allocated {allocated} bytes; the hot path must bail before any factor work");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------------
|
|
// 9.3: a Target surviving a dock/die/undock cycle must not crash LockWeapon. Same fixture shape as
|
|
// FireControlCut8Tests.BuildLockScenario -- this is that cut's own crash, still reachable.
|
|
// ---------------------------------------------------------------------------------------------------
|
|
|
|
private sealed class LockScenario
|
|
{
|
|
public ItemManager Items;
|
|
public Zone Zone;
|
|
public Ship Locker;
|
|
public Ship Bystander;
|
|
}
|
|
|
|
private LockScenario BuildLockScenario(GameplaySettings settings)
|
|
{
|
|
var hullData = new HullData
|
|
{
|
|
Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 1000, 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(new GearData
|
|
{
|
|
Name = "LockGun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
|
|
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
|
Behaviors = { new LockWeaponData
|
|
{
|
|
Damage = Constant(0), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0),
|
|
Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1),
|
|
BurstTime = Constant(0), Cooldown = Constant(1000),
|
|
DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } },
|
|
LockSpeed = Constant(1), SensorImpact = Constant(1), LockAngle = Constant(170),
|
|
DirectionImpact = Constant(1), Decay = Constant(1)
|
|
} }
|
|
});
|
|
cache.FlushAsync().Wait();
|
|
|
|
var ledger = new ProvenanceLedger();
|
|
ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 };
|
|
ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 };
|
|
ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 };
|
|
ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 };
|
|
var items = new ItemManager(cache, ledger, settings, _ => { });
|
|
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut9-Lock", Owner = null }, null);
|
|
|
|
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Hull"));
|
|
var lockerHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 };
|
|
var locker = new Ship(items, zone, lockerHull, new EntitySettings());
|
|
|
|
var gunRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("LockGun"));
|
|
var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 };
|
|
Assert.True(locker.TryEquip(gun, new int2(0, 0)));
|
|
|
|
var bystanderHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 3 };
|
|
var bystander = new Ship(items, zone, bystanderHull, new EntitySettings());
|
|
var bystanderGunRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("LockGun"));
|
|
var bystanderGun = new EquippableItem { Data = bystanderGunRef, Durability = 1, Lot = 4 };
|
|
Assert.True(bystander.TryEquip(bystanderGun, new int2(0, 0))); // _orderedEquipment must not be empty/null for Update to run
|
|
|
|
zone.Admit(locker, piloted: false);
|
|
zone.Admit(bystander, piloted: false);
|
|
|
|
locker.Position = float3.zero;
|
|
bystander.Position = float3(0, 0, 100);
|
|
locker.Aim = float3(0, 0, 1);
|
|
locker.SetTarget(bystander);
|
|
locker.EntityInfoGathered[bystander] = 1f;
|
|
locker.SetIff(bystander, true);
|
|
|
|
zone.Update(0f); // warm up resolved stats -- brings weapon Online/Active up
|
|
|
|
return new LockScenario { Items = items, Zone = zone, Locker = locker, Bystander = bystander };
|
|
}
|
|
|
|
// 9.3 (Soul C3, high): Cut 8 fixed a real liveness defect (LockWeaponOnDeactivatedEntityDoesNotThrow)
|
|
// but not the operator's actual crash -- that guard only ever runs on an *active* entity. The live
|
|
// precondition is Target.Value set but absent from EntityInfoGathered on an active entity, and docking
|
|
// still reaches it: Deactivate disposes the Zone.Entities.ObserveRemove subscription that is the only
|
|
// thing that nulls a stale Target, and Activate reseeds EntityInfoGathered from the zone's *current*
|
|
// membership without reconciling Target against it. Dock, let the target leave, undock: the first
|
|
// LockWeapon tick throws. Mutation: delete Entity.Activate's Target reconciliation. Must die.
|
|
[Fact]
|
|
public void TargetSurvivingDockDieUndockDoesNotCrashLockWeapon()
|
|
{
|
|
var s = BuildLockScenario(TestSettings());
|
|
s.Locker.Update(.1f); // lock behavior runs once while both are live and visible, same as normal play
|
|
|
|
s.Locker.Deactivate(); // dock
|
|
Assert.False(s.Locker.Active);
|
|
|
|
// the target dies or leaves while the locker is inactive -- its own Zone.Entities.ObserveRemove
|
|
// subscription (the only thing that would have nulled Target) was disposed by Deactivate above, so
|
|
// nothing reacts to this removal yet.
|
|
s.Zone.Remove(s.Bystander);
|
|
|
|
s.Locker.Activate(); // undock
|
|
Assert.True(s.Locker.Active);
|
|
// Assert.Null(entity) would hand a live Entity into xUnit's reflection-based failure formatter,
|
|
// which recurses through Zone -> Entities -> Ship -> Zone forever; a plain boolean with its own
|
|
// message sidesteps that entirely.
|
|
Assert.True(s.Locker.Target.Value.IsNone, "the invariant: an active entity's Target is always a live entity it has info on -- a stale reference to the departed Bystander survived Activate's reconciliation");
|
|
|
|
var ex = Record.Exception(() => s.Locker.Update(.1f));
|
|
Assert.Null(ex);
|
|
}
|
|
}
|