Cut 9: the die is not a die
9.1, blocking. FireControl seeded CultMath's xorshift32 per shot and drew once. NextFloat returns the top 24 bits of the FIRST round, and one round cannot carry ShotId's low-bit variation into the high bits -- so every zone's hit roll spanned about an eighth, with the threshold fixed at zone creation. A shot was a step function of p: below the band it never hit, above it always did. An unaided shooter hit 0 of 18,926 shots. That is the operator's "no hits registering," and being stable per zone is why it came and went. The seed derivation is unchanged -- still a pure function of (zone, shot id), Cut 6b's rule -- and only its diffusion into the first output changes, via an fmix32 finalizer. Measured across six zone seeds: hit fraction at p=.3 went from 0.0% in five of six zones to 29-31%, and at p=.6 to 58-61%, with the roll spanning the full range. The defect survived three cuts because nothing in the campaign measured a distribution -- every fixture uses accuracy 1 or 0, and a constant satisfies a determinism test perfectly. TheDieIsUniform now asserts the observed hit fraction is within .08 of p across several zone seeds, which is the test whose absence was the real defect. 9.2. Sigma is floored at half a cell. HullKernel approximates a continuous Gaussian with a discrete sum over occupied cells, and that sum collapses once sigma falls well under one cell against an aim point that is not exactly on a cell centre -- true of every shipped hull. Past Precision ~4, pOnHull went to zero and a better targeting system made the weapon unable to hit anything. 9.3. An active entity's Target is now always a live entity it has info on. Cut 8's _active guard could never have fixed the operator's LockWeapon crash -- that code only runs on active entities, and cut8's own harness header says so. The live route was dock, target dies, undock; Deactivate disposed the subscription that nulls a stale Target and Activate reseeded EntityInfoGathered without reconciling it. Still not a TryGetValue in LockWeapon. 229 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
16d8493e0c
commit
6db6dc78f3
4 changed files with 797 additions and 5 deletions
|
|
@ -285,6 +285,19 @@ public abstract class Entity
|
|||
// subscribe with whatever Target already holds, which is harmless -- TargetItem starts null anyway.
|
||||
_subscriptions.Add(Target.Subscribe(_ => TargetItem.Value = null));
|
||||
|
||||
// 9.3 (docs/fire-control-cut.md, Soul C3): a Target surviving Deactivate can point at an entity
|
||||
// this zone removed while inactive -- Deactivate disposes the Zone.Entities.ObserveRemove
|
||||
// subscription that is the only thing that nulls a stale Target, and EntityInfoGathered was just
|
||||
// rebuilt above from the zone's *current* membership, not from whatever Target still remembers.
|
||||
// Reconcile here, now that both the fresh EntityInfoGathered and the Target-change subscription
|
||||
// above are live (so this write also clears TargetItem through it): an active entity's Target
|
||||
// must always be an entity it has info on. Activate owns this reconciliation rather than
|
||||
// Deactivate nulling Target outright, because nulling on every dock would throw away a target
|
||||
// that is still alive and simply never changed while docked -- the common case, and not the one
|
||||
// that crashed.
|
||||
if (Target.Value != null && !EntityInfoGathered.ContainsKey(Target.Value))
|
||||
Target.Value = null;
|
||||
|
||||
if(WeaponGroups.All(wg=>!wg.items.Any()))
|
||||
GenerateWeaponGroups();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -344,6 +344,22 @@ public static class FireControl
|
|||
// so whether a gated shot draws is unobservable from anywhere. The claim this comment used to make is
|
||||
// gone with the stream it was about, and the test that asserted it became vacuous the same moment; what
|
||||
// replaced it pins the live rule instead (combat touches no shared stream, on any path).
|
||||
// 9.1 (docs/fire-control-cut.md): fmix32, MurmurHash3's 32-bit finalizer -- xor-shift, multiply,
|
||||
// xor-shift, multiply, xor-shift. Diffuses a structured seed (here, a zone seed XORed with a small,
|
||||
// low-bits-only ShotId) across every bit before the first xorshift draw reads it, so two seeds that
|
||||
// differ only in ShotId's low bits still land on uncorrelated first outputs. Caller-side mixing only
|
||||
// -- CultMath.Random itself is unchanged (see Commit's own note on why that follow-up is a separate
|
||||
// CultLib cut).
|
||||
private static uint MixSeed(uint seed)
|
||||
{
|
||||
seed ^= seed >> 16;
|
||||
seed *= 0x85ebca6bu;
|
||||
seed ^= seed >> 13;
|
||||
seed *= 0xc2b2ae35u;
|
||||
seed ^= seed >> 16;
|
||||
return seed;
|
||||
}
|
||||
|
||||
private static ShotOutcome Commit(Zone zone, PendingShot shot, float now)
|
||||
{
|
||||
// Cut 6b, 6.1 (Soul finding 6): a shot's dice belong to the shot, not to whatever else happened to
|
||||
|
|
@ -353,7 +369,16 @@ public static class FireControl
|
|||
// no matter what the UI drew from the shared stream in between. This generator is local and dies with
|
||||
// the call: nothing outside Commit may seed or advance a combat draw, and Commit may not read or write
|
||||
// a shared stream.
|
||||
var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);
|
||||
// 9.1 (docs/fire-control-cut.md): the per-shot seed is unchanged -- a pure function of
|
||||
// (zone identity, shot id), Cut 6b's own rule -- but CultMath.Random draws only one xorshift
|
||||
// round for NextFloat's first call, and one round does not diffuse a seed that differs from its
|
||||
// neighbours only in ShotId's low bits. Unmixed, every zone's first draw was a near-constant
|
||||
// function of ShotId (measured span ~0.125), making a shot a step function of p fixed at zone
|
||||
// creation, not a roll (Soul C1). MixSeed is a standard fmix32 finalizer (MurmurHash3): this is
|
||||
// a caller's obligation, not a CultMath defect -- seeding xorshift with a structured value and
|
||||
// drawing once is the documented way to get a correlated first output, and a follow-up to give
|
||||
// CultMath.Random a mixed-seed entry point of its own is recorded for a separate CultLib cut.
|
||||
var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));
|
||||
var p = shot.PBase;
|
||||
if (p > 0f && shot.Target != null)
|
||||
{
|
||||
|
|
@ -486,7 +511,16 @@ public static class FireControl
|
|||
// Higher Precision (a tighter group) gives a smaller sigma; the 1e-3 floor only guards a stray zero or
|
||||
// negative authored value; no authored Precision should ever reach it (GameplaySettings.UnaidedPrecision
|
||||
// is the deliberately-bad floor Precision(Entity) itself falls back to, same shape as UnaidedAccuracy).
|
||||
private static float Sigma(float precision) => 1f / max(precision, 1e-3f);
|
||||
//
|
||||
// 9.2 (docs/fire-control-cut.md, Soul C2): SigmaFloor guards a second, unrelated failure the 1e-3
|
||||
// floor above does nothing about. HullKernel approximates a continuous 2D Gaussian with a discrete
|
||||
// sum over occupied cells; that sum badly undershoots the continuous integral once sigma drops much
|
||||
// below one cell against an aim point that is not exactly on a cell centre -- true of every shipped
|
||||
// hull, none of whose centres of mass are integral. Past that point pOnHull collapsed toward zero as
|
||||
// Precision kept climbing, so a better targeting system made a shot un-fireable. Clamping sigma at
|
||||
// half a cell keeps the discrete sum a faithful share of the kernel over the whole Precision domain.
|
||||
private const float SigmaFloor = 0.5f;
|
||||
private static float Sigma(float precision) => max(1f / max(precision, 1e-3f), SigmaFloor);
|
||||
|
||||
// w(cell) = exp(-d^2 / 2*sigma^2) over every occupied hull cell, `d` the planar cell-space distance from
|
||||
// the aim point, plus pOnHull = (sum of those weights) / (2*pi*sigma^2) -- the share of the full continuous
|
||||
|
|
@ -529,9 +563,14 @@ public static class FireControl
|
|||
|
||||
// A single weighted draw over the kernel's own occupied-cell weights -- the only way Commit picks a cell
|
||||
// once a shot has already passed the roll (R3: one roll decides damage). totalWeight is passed in rather
|
||||
// than resummed so this reads the identical mass HullKernel just computed. The trailing return guards only
|
||||
// floating-point rounding at the very end of the accumulation; it can never fall through for a shot that
|
||||
// reaches here, because the roll already established this hull has nonzero on-hull mass.
|
||||
// than resummed so this reads the identical mass HullKernel just computed.
|
||||
//
|
||||
// 9.2 (docs/fire-control-cut.md, Soul C2): the trailing return used to carry a claim that it "can
|
||||
// never fall through," which was false on its own terms -- it falls through whenever totalWeight == 0,
|
||||
// returning a fixed corner cell, and was unreachable only as a side effect of pOnHull collapsing to
|
||||
// zero and gating PBase to zero first. Now that Sigma floors sigma so pOnHull no longer collapses,
|
||||
// that accidental gate is gone too; the return stays as an explicit floating-point-rounding guard, not
|
||||
// a documented invariant.
|
||||
private static int2 WeightedPick(int2[] cells, float[] weights, float totalWeight, Random random)
|
||||
{
|
||||
var roll = random.NextFloat() * totalWeight;
|
||||
|
|
|
|||
488
tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs
Normal file
488
tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
/* 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,
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// 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 TestCatalogGlobal { Name = "Temperament" });
|
||||
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.Entities.Add(shooter);
|
||||
zone.Entities.Add(target);
|
||||
shooter.Activate();
|
||||
target.Activate();
|
||||
|
||||
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 PBase, 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 and PBase is the one number Commit's die
|
||||
// reads. 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 pBase, 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,
|
||||
PBase = pBase,
|
||||
Tracking = 1e6f,
|
||||
Precision = precision,
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// 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)
|
||||
{
|
||||
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 TestCatalogGlobal { Name = "Temperament" });
|
||||
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(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) } }
|
||||
} }
|
||||
});
|
||||
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(1f), Resolution = Constant(1000f), 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.Entities.Add(shooter);
|
||||
zone.Entities.Add(target);
|
||||
shooter.Activate();
|
||||
target.Activate();
|
||||
|
||||
shooter.Position = float3.zero;
|
||||
target.Position = float3(0, 0, 100);
|
||||
shooter.Target.Value = 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. Mutation: delete the sigma floor -- pOnHull at Precision 1000
|
||||
// collapses back toward zero without 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 (LonginusX 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");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// 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 TestCatalogGlobal { Name = "Temperament" });
|
||||
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.Entities.Add(locker);
|
||||
zone.Entities.Add(bystander);
|
||||
locker.Activate();
|
||||
bystander.Activate();
|
||||
|
||||
locker.Position = float3.zero;
|
||||
bystander.Position = float3(0, 0, 100);
|
||||
locker.LookDirection = float3(0, 0, 1);
|
||||
locker.Target.Value = 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.Entities.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 == null, "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);
|
||||
}
|
||||
}
|
||||
252
tests/mutation_tests_fire_control_cut9.sh
Normal file
252
tests/mutation_tests_fire_control_cut9.sh
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
#!/usr/bin/env bash
|
||||
# Mutation tests for docs/fire-control-cut.md's Cut 9 ("the die is not a die").
|
||||
#
|
||||
# Three mutations against tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs, each from the spec's own
|
||||
# Verification block, one per named finding:
|
||||
#
|
||||
# 1. TheDieIsUniform (9.1, blocking) -- delete FireControl.Commit's fmix32 finalizer, restoring the bare
|
||||
# seed straight into `new Random(...)`. One xorshift round does not diffuse a seed that differs from its
|
||||
# neighbours only in ShotId's low bits, so the roll goes back to being a near-constant step function of
|
||||
# p, fixed per zone at zone creation.
|
||||
# 2. POnHullNeverCollapsesAboveThePrecisionCliff (9.2, blocking) -- delete the SigmaFloor clamp from
|
||||
# FireControl.Sigma, restoring sigma = 1/Precision unclamped. Past Precision ~4-5 (LonginusX-shaped
|
||||
# centres of mass), the discrete kernel sum undershoots the continuous 2*pi*sigma^2 denominator and
|
||||
# pOnHull collapses back to zero.
|
||||
# 3. TargetSurvivingDockDieUndockDoesNotCrashLockWeapon (9.3, high) -- delete Entity.Activate's Target
|
||||
# reconciliation. A Target that outlived a Zone.Entities removal while the entity was inactive (docked)
|
||||
# survives Activate unchanged, and the next LockWeapon tick throws indexing EntityInfoGathered with it.
|
||||
#
|
||||
# A no-op control (a byte-identical rewrite of one target file through this script's own read/write path)
|
||||
# proves the mechanism itself is transparent before any real mutation is trusted.
|
||||
#
|
||||
# Usage:
|
||||
# tests/mutation_tests_fire_control_cut9.sh <path-to-CultLib-45c2f40-worktree>
|
||||
#
|
||||
# Every mutation is reversed (byte-exact, from an on-disk copy) before the script exits, including on failure
|
||||
# or interrupt, and every restore is verified against a sha256 recorded before any mutation ran: the tree-
|
||||
# clean verdict at the end states PASS/FAIL explicitly rather than leaving a reader to infer it. A SIGKILL of
|
||||
# the whole process group is the one termination bash cannot trap at all -- verify the tree independently
|
||||
# after a run that was killed that hard.
|
||||
#
|
||||
# Run only one mutation harness at a time against this tree -- concurrent harnesses corrupt each other's
|
||||
# snapshots (docs/fire-control-cut.md's own campaign has hit this).
|
||||
|
||||
set -u
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 <cultlib-root>" >&2
|
||||
exit 2
|
||||
fi
|
||||
CULTLIB_ROOT="$1"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
TEST_PROJECT="$REPO_ROOT/tests/Aetheria.Shared.Tests"
|
||||
|
||||
FIRE_CONTROL_CS="$REPO_ROOT/Assets/Scripts/ServerShared/FireControl.cs"
|
||||
ENTITY_CS="$REPO_ROOT/Assets/Scripts/ServerShared/Entity.cs"
|
||||
|
||||
# --- byte-exact backup/restore: preserves line endings exactly, no text-mode translation. ---
|
||||
declare -A ORIGINALS
|
||||
declare -A ORIGINAL_HASH
|
||||
FILES=("$FIRE_CONTROL_CS" "$ENTITY_CS")
|
||||
BACKUP_DIR="$(mktemp -d)"
|
||||
|
||||
hash_file() {
|
||||
sha256sum "$1" 2>/dev/null | awk '{print $1}'
|
||||
}
|
||||
|
||||
for f in "${FILES[@]}"; do
|
||||
base="$(basename "$f")"
|
||||
cp -p "$f" "$BACKUP_DIR/$base.orig"
|
||||
ORIGINALS["$f"]="$BACKUP_DIR/$base.orig"
|
||||
ORIGINAL_HASH["$f"]="$(hash_file "$f")"
|
||||
done
|
||||
|
||||
restore_and_verify() {
|
||||
local file="$1"
|
||||
cp -p "${ORIGINALS[$file]}" "$file"
|
||||
local actual
|
||||
actual="$(hash_file "$file")"
|
||||
if [ "$actual" != "${ORIGINAL_HASH[$file]}" ]; then
|
||||
echo "" >&2
|
||||
echo "FATAL: restoring $file did not reproduce its pre-mutation hash -- aborting." >&2
|
||||
echo " expected sha256: ${ORIGINAL_HASH[$file]}" >&2
|
||||
echo " actual sha256: $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
TREE_CLEAN_OK=1
|
||||
TREE_VERDICT_DONE=0
|
||||
|
||||
verify_tree_clean() {
|
||||
if [ "$TREE_VERDICT_DONE" = "1" ]; then return; fi
|
||||
local f actual
|
||||
for f in "${FILES[@]}"; do
|
||||
cp -p "${ORIGINALS[$f]}" "$f" 2>/dev/null
|
||||
actual="$(hash_file "$f")"
|
||||
if [ "$actual" != "${ORIGINAL_HASH[$f]}" ]; then
|
||||
echo "FATAL: $f is NOT byte-identical to how this script found it." >&2
|
||||
echo " expected sha256: ${ORIGINAL_HASH[$f]}" >&2
|
||||
echo " actual sha256: $actual" >&2
|
||||
TREE_CLEAN_OK=0
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
if [ "$TREE_CLEAN_OK" = "1" ]; then
|
||||
echo "=== tree-clean verdict: PASS -- every target file is byte-identical to how this script found it ==="
|
||||
else
|
||||
echo "=== tree-clean verdict: FAIL -- see FATAL lines above; the working tree is contaminated ==="
|
||||
fi
|
||||
TREE_VERDICT_DONE=1
|
||||
}
|
||||
trap verify_tree_clean EXIT INT TERM
|
||||
|
||||
FAILURES=()
|
||||
|
||||
# replace_unique FILE ANCHOR REPLACEMENT
|
||||
# Anchor must occur exactly once (byte-exact match via perl, not sed's line-oriented rewriting, so a
|
||||
# multi-line anchor is matched as literal text). Writes the mutated content back byte-exact.
|
||||
replace_unique() {
|
||||
local file="$1" anchor="$2" replacement="$3"
|
||||
local count
|
||||
count=$(perl -0777 -e '
|
||||
my ($anchor, $file) = @ARGV;
|
||||
local $/; open(my $fh, "<:raw", $file) or die $!;
|
||||
my $text = <$fh>; close $fh;
|
||||
my $n = () = $text =~ /\Q$anchor\E/g;
|
||||
print $n;
|
||||
' "$anchor" "$file")
|
||||
if [ "$count" != "1" ]; then
|
||||
echo "ERROR: $file: anchor must occur exactly once, found $count" >&2
|
||||
echo "--- anchor ---" >&2
|
||||
echo "$anchor" >&2
|
||||
return 1
|
||||
fi
|
||||
# No -i: on this filesystem perl's in-place-edit extension parsing has mangled a literal ":raw" suffix
|
||||
# into stray sibling files before. Read, substitute once, write back byte-exact instead.
|
||||
perl -0777 -e '
|
||||
my ($file, $anchor, $replacement) = @ARGV;
|
||||
local $/; open(my $fh, "<:raw", $file) or die $!;
|
||||
my $text = <$fh>; close $fh;
|
||||
my $qa = quotemeta($anchor);
|
||||
$text =~ s/$qa/$replacement/;
|
||||
open(my $out, ">:raw", $file) or die $!;
|
||||
print $out $text; close $out;
|
||||
' "$file" "$anchor" "$replacement"
|
||||
}
|
||||
|
||||
# A dedicated, isolated artifacts path -- see cut5.sh's own comment on this: mixing this run's build with a
|
||||
# stale default tests/Aetheria.Shared.Tests/bin,obj has produced spurious control failures before.
|
||||
ARTIFACTS_PATH="$(mktemp -d)"
|
||||
|
||||
# run_test TEST_FQN -> 0 (passed) or 1 (failed/errored)
|
||||
run_test() {
|
||||
local test="$1"
|
||||
local out
|
||||
out=$(dotnet test "$TEST_PROJECT" -p:CultLibRoot="$CULTLIB_ROOT" --artifacts-path "$ARTIFACTS_PATH" --filter "FullyQualifiedName~$test" 2>&1)
|
||||
if echo "$out" | grep -q "Passed!" && ! echo "$out" | grep -q "Failed: [1-9]"; then
|
||||
return 0
|
||||
fi
|
||||
LAST_OUTPUT="$out"
|
||||
return 1
|
||||
}
|
||||
|
||||
# check_mutation NAME FILE ANCHOR MUTATED TEST EXPECT("red"|"green")
|
||||
check_mutation() {
|
||||
local name="$1" file="$2" anchor="$3" mutated="$4" test="$5" expect="$6"
|
||||
echo ""
|
||||
echo "=== $name (expect $expect) ==="
|
||||
if ! replace_unique "$file" "$anchor" "$mutated"; then
|
||||
FAILURES+=("$name: anchor replacement failed")
|
||||
restore_and_verify "$file"
|
||||
return
|
||||
fi
|
||||
if run_test "$test"; then
|
||||
passed=1
|
||||
else
|
||||
passed=0
|
||||
fi
|
||||
restore_and_verify "$file" # restore this file immediately, before judging, so a crash still leaves a clean tree
|
||||
|
||||
if [ "$expect" = "green" ]; then
|
||||
if [ "$passed" = "1" ]; then
|
||||
echo "OK: $test passed under a no-op mutation, as expected"
|
||||
else
|
||||
FAILURES+=("$name: expected green (test passing), got failure")
|
||||
echo "$LAST_OUTPUT" | tail -40
|
||||
fi
|
||||
else
|
||||
if [ "$passed" = "0" ]; then
|
||||
echo "OK: $test went red under the mutation, as expected"
|
||||
else
|
||||
FAILURES+=("$name: expected red (mutant killed), but $test still passed")
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# --- No-op control: a byte-identical round trip through this script's own replace_unique path, proving the
|
||||
# anchor/replace/restore mechanism is transparent before any real mutation is trusted. ---
|
||||
check_mutation \
|
||||
"control (no-op round trip)" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \
|
||||
'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \
|
||||
"FireControlCut9Tests.TheDieIsUniform" \
|
||||
"green"
|
||||
|
||||
# --- 9.1: the die must be uniform, not a step function. Mutation: delete the fmix32 finalizer -- the bare,
|
||||
# structured (zone, shot id) seed goes straight into Random again, and one xorshift round does not diffuse a
|
||||
# seed that differs from its neighbours only in ShotId's low bits. ---
|
||||
check_mutation \
|
||||
"9.1 Commit must mix the per-shot seed (fmix32) before drawing, not feed it to Random raw" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \
|
||||
'var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);' \
|
||||
"FireControlCut9Tests.TheDieIsUniform" \
|
||||
"red"
|
||||
|
||||
# --- 9.2: pOnHull must stay monotonic in Precision over the whole domain. Mutation: delete the SigmaFloor
|
||||
# clamp -- sigma is 1/Precision again, unclamped, so pOnHull collapses back to zero past the cliff every
|
||||
# shipped (non-integral-centred) hull sits close to. ---
|
||||
check_mutation \
|
||||
"9.2 Sigma must floor at half a cell, not shrink unbounded with Precision" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'private static float Sigma(float precision) => max(1f / max(precision, 1e-3f), SigmaFloor);' \
|
||||
'private static float Sigma(float precision) => 1f / max(precision, 1e-3f);' \
|
||||
"FireControlCut9Tests.POnHullNeverCollapsesAboveThePrecisionCliff" \
|
||||
"red"
|
||||
|
||||
# --- 9.3: a Target surviving a dock/die/undock cycle must not crash LockWeapon. Mutation: delete Activate's
|
||||
# reconciliation -- a Target the zone removed while this entity was inactive survives Activate unchanged, and
|
||||
# the next LockWeapon tick indexes EntityInfoGathered with a dictionary key that is no longer there. ---
|
||||
check_mutation \
|
||||
"9.3 Activate must reconcile a stale Target against the reseeded EntityInfoGathered" \
|
||||
"$ENTITY_CS" \
|
||||
$' if (Target.Value != null && !EntityInfoGathered.ContainsKey(Target.Value))\r\n Target.Value = null;\r\n\r\n if(WeaponGroups.All(wg=>!wg.items.Any()))' \
|
||||
$' if(WeaponGroups.All(wg=>!wg.items.Any()))' \
|
||||
"FireControlCut9Tests.TargetSurvivingDockDieUndockDoesNotCrashLockWeapon" \
|
||||
"red"
|
||||
|
||||
echo ""
|
||||
verify_tree_clean # explicit call: the tree-clean verdict is part of THIS run's own summary, not only
|
||||
# something printed later at exit (the trap's own call is a no-op after this one runs --
|
||||
# see TREE_VERDICT_DONE above -- and remains the safety net for an interrupted run).
|
||||
|
||||
if [ "${#FAILURES[@]}" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "=== FAILURES ==="
|
||||
for f in "${FAILURES[@]}"; do
|
||||
echo "- $f"
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "${#FAILURES[@]}" -gt 0 ] || [ "$TREE_CLEAN_OK" != "1" ]; then
|
||||
echo ""
|
||||
echo "RESULT: FAILED -- mutations: $([ "${#FAILURES[@]}" -gt 0 ] && echo "${#FAILURES[@]} failure(s)" || echo "all behaved as declared"), tree-clean: $([ "$TREE_CLEAN_OK" = "1" ] && echo PASS || echo FAIL)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "RESULT: All mutations behaved as declared. tree-clean: PASS"
|
||||
exit 0
|
||||
Loading…
Reference in a new issue