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

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

359 lines
14 KiB
C#

using System;
using System.IO;
using System.Linq;
using CultMath;
using GameCult.Caching;
using Xunit;
using static CultMath.math;
// A painted ship owns a typed incoming-paint warning (cut lock-warning; rulings sensor-stat-set item 4 and
// lock-warning-strength). Every fixture drives the production path: a real LockWeapon in a real ship's gear
// executes inside Zone.Update, and the zone publishes every ship's paints before any ship updates. A paint made
// during tick N is therefore what IncomingPaints holds after tick N+1, whatever order the ships sit in the zone.
public sealed class LockWarningTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-lockwarn-" + Guid.NewGuid().ToString("N"));
private string Catalog => Path.Combine(_root, "Aetheria.cc");
private static readonly int2 HardpointCell = new int2(0, 0);
private CultCache _openCache;
private ItemManager _items;
private Zone _zone;
public LockWarningTests()
{
Directory.CreateDirectory(_root);
using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true))
{
cache.Upsert(new VerseGrammar { Revision = 1 });
var hullShape = new Shape(3, 3);
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
cache.Upsert(new HullData
{
Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 1000, Mass = 1000,
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } }
});
cache.Upsert(Launcher("Launcher", lockSpeed: .6f, sensorImpact: 1, lockAngle: 30, directionImpact: 1));
cache.Upsert(Launcher("SkewLauncher", lockSpeed: .8f, sensorImpact: 3, lockAngle: 45, directionImpact: 2));
cache.FlushAsync().Wait();
}
_openCache = AetheriaStores.Open(Catalog, catalogWritable: true);
var ledger = new ProvenanceLedger();
for (var lot = 1; lot < 12; lot++) ledger.Lots[lot] = new Lot { Origin = new Attributed(), Quality = 1 };
_items = new ItemManager(1u, _openCache, ledger, new GameplaySettings
{
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = .1f,
TargetGearInfoThreshold = .8f,
FiringArc = 170,
CommitHorizon = .5f,
SchematicCellSize = 1f,
UnaidedAccuracy = .05f,
UnaidedTracking = 10f,
UnaidedPrecision = 2f,
AgentMinHitProbability = 0f,
BeamResolveInterval = .1f
}, _ => { });
_zone = new Zone(_items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null);
}
public void Dispose()
{
_openCache?.Dispose();
Directory.Delete(_root, true);
}
private static PerformanceStat Const(float v) => new PerformanceStat { Min = v, Max = v };
internal static GearData Launcher(string name, float lockSpeed, float sensorImpact, float lockAngle, float directionImpact) => WithMunitionParts(new GearData
{
Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors =
{
new LauncherData // the production launcher type; the catalog round-trips it (a bare LockWeaponData is not a stored union member)
{
Damage = Const(0), Range = Const(1000), MinRange = Const(0), Velocity = Const(0),
Spread = Const(0), DamageSpread = Const(0), Penetration = Const(0), Count = Const(1),
BurstTime = Const(0), Cooldown = Const(1000),
LockSpeed = Const(lockSpeed), SensorImpact = Const(sensorImpact), LockAngle = Const(lockAngle),
DirectionImpact = Const(directionImpact), Decay = Const(.1f)
}
}
});
// A launcher design carries the five munition parts (StatValidation.ValidateMunitionParts); these fixtures are about the lock.
private static GearData WithMunitionParts(GearData launcher)
{
launcher.Behaviors.AddRange(MunitionPartsTests.Parts(0));
return launcher;
}
private int _nextLot = 2;
private Ship NewShip(float3 position, string launcher = null)
{
var hullRef = _items.ItemData.RefOf<ItemData>(_items.ItemData.GetByName<HullData>("Skiff"));
var ship = new Ship(_items, _zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings())
{ Faction = new Faction { Name = "F" + Guid.NewGuid().ToString("N") } };
if (launcher != null)
{
var gearRef = _items.ItemData.RefOf<ItemData>(_items.ItemData.GetByName<GearData>(launcher));
Assert.True(ship.TryEquip(new EquippableItem { Data = gearRef, Durability = 1, Lot = _nextLot++ }, HardpointCell));
}
_zone.Admit(ship, piloted: false);
ship.Position = position;
_zone.Update(0f); // brings the gear online
return ship;
}
private static LockWeapon LauncherOf(Entity e) => e.GetBehavior<LockWeapon>();
private static EquippedItem GearOf(Entity e) => e.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon));
// The locker targets `target`, hostile to it, aimed straight at it (or along `aim`). The next zone tick paints.
private static void Aim(Ship locker, Ship target, float3? aim = null, float info = 1f)
{
locker.SetIff(target, true);
locker.EntityInfoGathered[target] = info;
locker.SetTarget(target);
locker.Aim = aim ?? normalize(target.Position - locker.Position);
}
private void Tick(float dt) => _zone.Update(dt);
[Fact]
public void APaintedShipLearnsBearingClassAndStrength()
{
var locker = NewShip(float3(0, 0, 0), "Launcher");
var b = NewShip(float3(3, 0, 4));
var launcher = LauncherOf(locker);
Aim(locker, b);
Tick(.5f);
Assert.Empty(b.IncomingPaints); // published at the start of the next tick
Assert.Equal(.3f, launcher.Lock, 4); // fixture: 0.6 speed * 0.5 s on a dead-on, fully sensed target
Tick(.5f);
var warning = Assert.Single(b.IncomingPaints);
Assert.Equal(-.6f, warning.Bearing.x, 4);
Assert.Equal(-.8f, warning.Bearing.y, 4);
Assert.Same(GearOf(locker).Data, warning.EmitterClass);
Assert.Equal(PaintMode.Track, warning.Mode);
Assert.Equal(.3f, warning.Strength, 4);
// The strength follows the emitter's lock as it grows: a stale or scaled copy would not.
Assert.Equal(.6f, launcher.Lock, 4);
Tick(0f);
Assert.Equal(.6f, Assert.Single(b.IncomingPaints).Strength, 4);
}
// The whole range of strength is pinned, not its middle: lock = 0.6 * dt on a dead-on, fully sensed target.
[Theory]
[InlineData(.25f, .15f)]
[InlineData(1f, .6f)]
[InlineData(1.25f, .75f)]
[InlineData(1.5f, .9f)]
[InlineData(1.55f, .93f)]
[InlineData(1.6f, .96f)]
[InlineData(1.65f, .99f)]
[InlineData(1.66f, .996f)]
public void StrengthIsTheLockAcrossItsWholeRange(float dt, float expectedLock)
{
var locker = NewShip(float3(0, 0, 0), "Launcher");
var b = NewShip(float3(0, 0, 9));
Aim(locker, b);
Tick(dt);
Assert.Equal(expectedLock, LauncherOf(locker).Lock, 4);
Tick(0f);
Assert.Equal(expectedLock, Assert.Single(b.IncomingPaints).Strength, 4);
}
// A saturated lock is a strength of exactly 1: the launcher's own Execute outside the zone tick, so that the
// zone's fire resolution does not run on the shot a full lock fires.
[Fact]
public void AFullLockIsAStrengthOfOne()
{
var locker = NewShip(float3(0, 0, 0), "Launcher");
var b = NewShip(float3(0, 0, 9));
Aim(locker, b);
LauncherOf(locker).Execute(5f);
Tick(0f);
Assert.Equal(1f, Assert.Single(b.IncomingPaints).Strength, 4);
}
// The lock's progress terms: direction (lerp ^ DirectionImpact) and sensor info (info ^ SensorImpact), each
// with an exponent other than 1, aimed off-axis at a partly sensed target. Lock after dt = 1:
// 0.8 * (1 - 30/90)^2 * 0.8^3 = 0.18204.
[Fact]
public void TheLockGrowsByDirectionAndSensorTermsNotJustSpeed()
{
var locker = NewShip(float3(0, 0, 0), "SkewLauncher");
var b = NewShip(float3(0, 0, 9));
var thirtyOff = float3(sin(30f * .0174533f), 0, cos(30f * .0174533f));
Aim(locker, b, thirtyOff, info: .8f);
Tick(1f);
Assert.Equal(.18204f, LauncherOf(locker).Lock, 4);
Tick(0f);
Assert.Equal(.18204f, Assert.Single(b.IncomingPaints).Strength, 4);
}
// Dead on, the direction term is 1 and the sensor term alone scales the lock: 0.8 * 0.5^3 = 0.1.
[Fact]
public void TheSensorTermAloneScalesADeadOnLock()
{
var locker = NewShip(float3(0, 0, 0), "SkewLauncher");
var b = NewShip(float3(0, 0, 9));
Aim(locker, b, info: .5f);
Tick(1f);
Assert.Equal(.1f, LauncherOf(locker).Lock, 4);
}
[Fact]
public void APaintFromALockerItCannotSeeStillWarns()
{
var locker = NewShip(float3(0, 0, 0), "Launcher");
var b = NewShip(float3(0, 0, 9));
b.EntityInfoGathered[locker] = 0;
Assert.False(b.VisibleEnemies.Any(x => ReferenceEquals(x, locker)), "the locker is not a visible enemy");
Aim(locker, b);
Tick(.5f);
Tick(0f);
Assert.Single(b.IncomingPaints);
Assert.False(b.VisibleEnemies.Any(x => ReferenceEquals(x, locker)), "the locker is not a visible enemy");
}
[Fact]
public void TargetedIsNotPainted()
{
var locker = NewShip(float3(0, 0, 0), "Launcher");
var b = NewShip(float3(0, 0, 9));
var c = NewShip(float3(9, 0, 0));
// B is targeted but the launcher is aimed at C, far outside LockAngle of B.
locker.SetIff(b, true);
locker.SetIff(c, true);
locker.EntityInfoGathered[b] = 1;
locker.EntityInfoGathered[c] = 1;
locker.SetTarget(b);
locker.Aim = float3(1, 0, 0);
Tick(.5f);
Tick(0f);
Assert.Empty(b.IncomingPaints);
Assert.Empty(c.IncomingPaints); // C is aimed at but not the target: nothing paints it either
}
// A broken lock stops warning on the same tick whichever of the two ships the zone updates first.
[Theory]
[InlineData(true)]
[InlineData(false)]
public void AWarningEndsTwoTicksAfterItsLastPaintInEveryEntityOrder(bool receiverFirst)
{
Ship locker, b;
if (receiverFirst)
{
b = NewShip(float3(0, 0, 9));
locker = NewShip(float3(0, 0, 0), "Launcher");
}
else
{
locker = NewShip(float3(0, 0, 0), "Launcher");
b = NewShip(float3(0, 0, 9));
}
Assert.Equal(receiverFirst ? (Entity) b : locker, _zone.Entities[0]);
Aim(locker, b);
Tick(.5f); // tick 1: painted
Assert.Empty(b.IncomingPaints);
Tick(.5f); // tick 2: tick 1's paint published, painted again
Assert.Single(b.IncomingPaints);
locker.Aim = float3(0, 0, -1); // the lock breaks: decays, paints nothing
Tick(.5f); // tick 3: tick 2's paint published, nothing painted
Assert.Single(b.IncomingPaints);
Assert.True(LauncherOf(locker).Lock > 0f, "fixture: the lock decays, it does not vanish");
Tick(.5f); // tick 4: nothing to publish
Assert.Empty(b.IncomingPaints);
}
[Fact]
public void ARetargetedLockerNoLongerWarnsItsOldTarget()
{
var locker = NewShip(float3(0, 0, 0), "Launcher");
var b = NewShip(float3(0, 0, 9));
var c = NewShip(float3(9, 0, 0));
var launcher = LauncherOf(locker);
Aim(locker, b);
Tick(.5f);
Tick(.5f);
Assert.Single(b.IncomingPaints);
Aim(locker, c); // retarget: aim at C
Tick(.5f); // the lock restarts on C: 0.3
Tick(0f); // tick after the retarget: B has nothing newer to publish
Assert.Empty(b.IncomingPaints);
Assert.Equal(.3f, Assert.Single(c.IncomingPaints).Strength, 4);
Assert.Equal(.3f, launcher.Lock, 4);
}
[Fact]
public void EachPainterIsItsOwnWarning()
{
var one = NewShip(float3(0, 0, 0), "Launcher");
var two = NewShip(float3(10, 0, 10), "Launcher");
var b = NewShip(float3(0, 0, 10));
Aim(one, b); // dead on: 0.6 * 0.5 = 0.3
var twentyOff = float3(-cos(20f * .0174533f), 0, sin(20f * .0174533f));
Aim(two, b, twentyOff); // 20 degrees off: 0.3 * (1 - 20/90) = 0.23333
Tick(.5f);
Tick(0f);
Assert.Equal(2, b.IncomingPaints.Count);
var strengths = b.IncomingPaints.Select(w => w.Strength).OrderBy(s => s).ToArray();
Assert.Equal(.23333f, strengths[0], 4);
Assert.Equal(.3f, strengths[1], 4); // never summed
Assert.Equal(2, b.IncomingPaints.Select(w => (w.Bearing.x, w.Bearing.y)).Distinct().Count());
}
[Fact]
public void AnInactiveShipHoldsNoWarnings()
{
var locker = NewShip(float3(0, 0, 0), "Launcher");
var b = NewShip(float3(0, 0, 9));
Aim(locker, b);
Tick(.5f);
Tick(0f);
Assert.Single(b.IncomingPaints);
b.Deactivate();
Assert.Empty(b.IncomingPaints);
Tick(.5f); // painted while docked or dead
b.Activate();
Tick(0f);
Assert.Empty(b.IncomingPaints); // the paint received while inactive was never stored
// A paint still waiting in the inbox when the ship deactivates does not survive to the next activation.
Tick(.5f);
b.Deactivate();
b.Activate();
Tick(0f);
Assert.Empty(b.IncomingPaints);
}
}