Merge fire control Cut 9: the die is not a die
The combat roll was not a roll. 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. Every zone's hit roll spanned about an eighth with its threshold fixed at zone creation, so 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, which is the operator's "I can target and fire with no hits registering," and being stable per zone is why it came and went. Measured across six zone seeds: hit fraction at p=.3 went from 0.0% in five of six zones to 29-31%, at p=.6 to 58-61%, spanning the full range. The seed stays a pure function of (zone, shot id); only its diffusion changes. Three cuts shipped over this because nothing in the campaign measured a distribution -- every fixture uses accuracy 1 or 0, and a constant satisfies a determinism test perfectly. The missing test, not the seeding line, was the defect. Also: pOnHull collapsed to zero above Precision ~4 because no shipped hull's centre of mass is integral, so a better targeting system made a weapon unable to hit anything; sigma is now floored at half a cell. And the LockWeapon crash Cut 8 did not actually fix -- that code only runs on active entities -- is closed at its real route, dock/target dies/undock. 229 tests. All nine harnesses clean, run sequentially in an isolated worktree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
9db64c112a
6 changed files with 806 additions and 14 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
#
|
||||
# OutOfArcConsumesNoDraw no longer exists. It asserted against ItemManager.Random (`e.Items.Random`), but
|
||||
# Cut 6b (6.1) moved every shot's roll onto its own freshly-seeded, immediately-discarded
|
||||
# `new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u)` local to Commit -- a deliberate,
|
||||
# `new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u))` local to Commit -- a deliberate,
|
||||
# good change (reproducibility independent of unrelated draws elsewhere) that also meant Commit stopped
|
||||
# reading or writing ItemManager.Random on any path, arc or no arc, which made the old test's own declared
|
||||
# mutation ("roll first and multiply by zero") pass for a reason that had nothing to do with arcs. Cut 7
|
||||
|
|
@ -197,7 +197,7 @@ check_mutation \
|
|||
check_mutation \
|
||||
"RollsAreSeeded: Commit's draw must be a pure function of (zone, shot id), not fresh entropy per call" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);' \
|
||||
'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \
|
||||
'var random = new Random((uint) System.Guid.NewGuid().GetHashCode() | 1u);' \
|
||||
"FireAuthorityTests.RollsAreSeeded" \
|
||||
"red"
|
||||
|
|
@ -350,8 +350,8 @@ check_mutation \
|
|||
check_mutation \
|
||||
"CombatNeverDrawsFromTheSharedStream: Commit must never read ItemManager's shared stream, on any path" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);' \
|
||||
'var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u); shot.Source.ItemManager.Random.NextFloat();' \
|
||||
'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \
|
||||
'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u)); shot.Source.ItemManager.Random.NextFloat();' \
|
||||
"FireAuthorityTests.CombatNeverDrawsFromTheSharedStream" \
|
||||
"red"
|
||||
|
||||
|
|
|
|||
|
|
@ -194,8 +194,8 @@ check_mutation() {
|
|||
check_mutation \
|
||||
"control (no-op round trip)" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);' \
|
||||
'var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);' \
|
||||
'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \
|
||||
'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \
|
||||
"FireControlCut6Tests.SameFightRollsSameThroughUnrelatedDraws" \
|
||||
"green"
|
||||
|
||||
|
|
@ -206,7 +206,7 @@ check_mutation \
|
|||
echo ""
|
||||
echo "=== 6.1 Commit must not read or write the shared ItemManager.Random stream (expect red) ==="
|
||||
if replace_unique "$FIRE_CONTROL_CS" \
|
||||
'var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);' \
|
||||
'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \
|
||||
'var random = shot.Source.ItemManager.Random;' \
|
||||
&& replace_unique "$FIRE_CONTROL_CS" \
|
||||
'return MakeOutcome(shot, hit, shielded, shieldBroken, cell, aimed, now);' \
|
||||
|
|
@ -224,8 +224,8 @@ fi
|
|||
check_mutation \
|
||||
"6.1 ShotId must reach the seed" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);' \
|
||||
'var random = new Random((zone.CombatSeed * 2654435761u) | 1u);' \
|
||||
'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \
|
||||
'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) | 1u));' \
|
||||
"FireControlCut6Tests.ShotIdDecidesTheDie" \
|
||||
"red"
|
||||
|
||||
|
|
|
|||
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