Aetheria/tests/Aetheria.Shared.Tests/VapourCloudTests.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

428 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using CultMath;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
using UniRx;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
using int2 = CultMath.int2;
// Vapour clouds in the sim (aetheria-release cut vapour-cloud, ruling vapour-cloud-obscuration): a VapourDump
// behaviour vents a zone cloud that thins every sensor sight line through it, both ways, through Sensor.Gain's
// one detection rule; a lock falls by the existing detection-threshold rule and nothing else. The scene is two
// ships facing each other along z with a fixed visibility, so what a sensor gathers is a number the clouds
// alone change: steady info is visibility x sensitivity / (distance x TargetInfoDecay), and visibility 100,
// sensitivity 1, decay .5 puts it at .5 at range 400 (above the .1 threshold) and saturated at range 40.
public sealed class VapourCloudTests : IDisposable
{
private const float Dt = .05f;
private const float Threshold = .1f;
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-vapour-" + Guid.NewGuid().ToString("N"));
private readonly List<CultCache> _caches = new List<CultCache>();
private static readonly object Glow = new object();
public VapourCloudTests() => Directory.CreateDirectory(_root);
public void Dispose()
{
foreach (var c in _caches) c.Dispose();
Directory.Delete(_root, true);
}
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
private static Shape Solid(int w, int h)
{
var shape = new Shape(w, h);
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
return shape;
}
private sealed class Lab
{
public ItemManager Items;
public Zone Zone;
public Ship Observer;
public Ship Target;
public EquippedItem Dump;
public LockWeapon Lock;
public int Lot = 1;
public bool HoldTarget = true;
}
private Lab Build(float distance, float radius = 20f, float opacity = .9f, float lifetime = 1000f)
{
var settings = new GameplaySettings
{
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = Threshold, TargetGearInfoThreshold = .8f,
TargetInfoDecay = .5f, VisibilityDecay = 0f,
FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 1f
};
var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true);
_caches.Add(cache);
cache.Upsert(new VerseGrammar { Revision = 1 });
cache.Upsert(new HullData
{
Name = "Hull", HullType = HullType.Ship, Shape = Solid(5, 5), Durability = 1000000, Mass = 1000,
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } }
});
GearData Gear(string name, HardpointType hardpoint, BehaviorData behavior) => new GearData
{
Name = name, Hardpoint = hardpoint, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { behavior }
};
cache.Upsert(Gear("Eye", HardpointType.Tool, new SensorData
{
Sensitivity = Constant(1f),
SensitivityCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } },
PingBoost = Constant(.000004f), PingEnergy = Constant(0f),
PingRange = Constant(300f), PingCooldown = Constant(4f)
}));
cache.Upsert(Gear("Vent", HardpointType.Tool, new VapourDumpData
{
Radius = Constant(radius), Opacity = Constant(opacity), Lifetime = Constant(lifetime)
}));
cache.Upsert(Gear("LockGun", HardpointType.Sensors, 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),
LockSpeed = Constant(1), SensorImpact = Constant(1), LockAngle = Constant(170),
DirectionImpact = Constant(1), Decay = Constant(1)
}));
cache.FlushAsync().Wait();
var ledger = new ProvenanceLedger();
for (var i = 1; i <= 50; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
var items = new ItemManager(1u, cache, ledger, settings, _ => { });
var zone = new Zone(items, new PlanetSettings(), new ZonePack { Radius = 5000 }, new GalaxyZone { Name = "Vapour", Owner = null }, null);
var lab = new Lab { Items = items, Zone = zone };
EquippableItem Make(string name) => new EquippableItem
{
Data = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>(name)), Durability = 1, Lot = lab.Lot++
};
Ship NewShip()
{
var hull = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Hull"));
var ship = new Ship(items, zone, new EquippableItem { Data = hull, Durability = 1000000, Lot = lab.Lot++ }, new EntitySettings());
Assert.True(ship.TryEquip(Make("Eye")));
return ship;
}
lab.Observer = NewShip();
Assert.True(lab.Observer.TryEquip(Make("LockGun"), new int2(0, 0)));
lab.Target = NewShip();
var dump = Make("Vent");
Assert.True(lab.Target.TryEquip(dump));
lab.Dump = lab.Target.Equipment.Single(x => x.EquippableItem == dump);
// Held off until a test pulls the trigger: the dump vents on its first run.
lab.Dump.Enabled.Value = false;
zone.Admit(lab.Observer, piloted: false);
zone.Admit(lab.Target, piloted: false);
lab.Observer.Position = float3(0, 0, 0);
lab.Observer.Aim = float3(0, 0, 1);
lab.Target.Position = float3(0, 0, distance);
lab.Target.Aim = float3(0, 0, -1);
lab.Observer.SetIff(lab.Target, true);
lab.Target.SetIff(lab.Observer, true);
zone.Update(0f);
lab.Lock = lab.Observer.Weapons.OfType<LockWeapon>().Single();
return lab;
}
// One tick: both ships stay where they were put, facing each other, each a fixed visibility to the other's sensor.
private static void Tick(Lab lab)
{
foreach (var ship in new[] { lab.Observer, lab.Target })
{
ship.SetSignature(Glow, SignatureChannel.Thermal, 100f, null);
if (ship != lab.Target || lab.HoldTarget) ship.Velocity = float2.zero;
}
lab.Observer.Direction = float2(0, 1);
lab.Target.Direction = float2(0, -1);
lab.Zone.Update(Dt);
}
private static void Run(Lab lab, int ticks)
{
for (var i = 0; i < ticks; i++) Tick(lab);
}
private static float Info(Ship watcher, Ship watched) => watcher.EntityInfoGathered[watched];
// The observer has settled on the target and holds a full lock on it: the state a cloud has to break.
private static void Establish(Lab lab)
{
Run(lab, 100);
Assert.True(Info(lab.Observer, lab.Target) > Threshold, "the observer never gathered the target");
Assert.Contains(lab.Target, lab.Observer.VisibleEnemies);
lab.Observer.SetTarget(lab.Target);
Run(lab, 100);
Assert.True(lab.Lock.IsLocked, "the observer never locked the target");
Assert.Empty(lab.Zone.Clouds);
}
private static void Vent(Lab lab)
{
lab.Dump.Enabled.Value = true;
for (var i = 0; i < 5 && lab.Zone.Clouds.Count == 0; i++) Tick(lab);
Assert.Single(lab.Zone.Clouds);
}
private static VapourCloud Hand(Zone zone, float2 at, float radius, float opacity, float lifetime = 1000f)
{
var cloud = new VapourCloud { Body = new KinematicBody { Position = at }, Radius = radius, Opacity = opacity, Lifetime = lifetime };
zone.Vent(cloud);
return cloud;
}
[Fact]
public void ACloudBreaksALockAtRange()
{
var lab = Build(400f);
Establish(lab);
Vent(lab);
var ticks = 0;
while (ticks++ < 600 && !lab.Observer.Target.Value.IsNone) Tick(lab);
Assert.True(lab.Observer.Target.Value.IsNone, "the cloud never broke the target");
Assert.True(Info(lab.Observer, lab.Target) < Threshold, $"info {Info(lab.Observer, lab.Target)} is above the threshold");
Run(lab, 3);
Assert.Equal(0f, lab.Lock.Lock);
Assert.DoesNotContain(lab.Target, lab.Observer.VisibleEnemies);
}
[Fact]
public void AStrongCloseSensorBurnsThrough()
{
var lab = Build(40f);
Establish(lab);
var before = Info(lab.Observer, lab.Target);
Vent(lab);
Run(lab, 600);
var after = Info(lab.Observer, lab.Target);
Assert.True(after < before - .2f, $"the cloud thinned nothing: {before} -> {after}");
Assert.True(after > Threshold, $"info {after} fell below the threshold");
Assert.Equal(lab.Target, lab.Observer.Target.Value.Entity);
Assert.True(lab.Lock.IsLocked);
}
[Fact]
public void TheCloudBlindsItsVenter()
{
var lab = Build(400f);
Run(lab, 100);
Assert.True(Info(lab.Target, lab.Observer) > Threshold, "the venter never gathered the observer");
Assert.Contains(lab.Observer, lab.Target.VisibleEnemies);
Vent(lab);
Run(lab, 300);
Assert.True(Info(lab.Target, lab.Observer) < Threshold);
Assert.DoesNotContain(lab.Observer, lab.Target.VisibleEnemies);
// (The observer updates before the venter in the tick that vents, so it is one tick behind.)
Assert.Equal(Info(lab.Observer, lab.Target), Info(lab.Target, lab.Observer), 3);
}
[Fact]
public void ACloudFadesAndIsRemoved()
{
var lab = Build(400f);
// Vented 3s into the zone's life: the fade runs from the vent, not from the zone's start.
for (var i = 0; i < 3; i++) lab.Zone.Update(1f);
var cloud = Hand(lab.Zone, float2(0, 0), 10f, .8f, lifetime: 10f);
var a = float2(-5, 0);
var b = float2(5, 0);
Assert.Equal(.2f, lab.Zone.Obscuration(a, b), 5);
for (var i = 0; i < 5; i++) lab.Zone.Update(1f);
Assert.Equal(.4f, cloud.OpacityAt(lab.Zone.Time), 5);
Assert.Equal(.6f, lab.Zone.Obscuration(a, b), 5);
Assert.Single(lab.Zone.Clouds);
for (var i = 0; i < 5; i++) lab.Zone.Update(1f);
Assert.Empty(lab.Zone.Clouds);
Assert.Equal(1f, lab.Zone.Obscuration(a, b));
}
[Fact]
public void ACloudDriftsWithItsVelocityUntilItFades()
{
var lab = Build(400f);
var cloud = new VapourCloud { Body = new KinematicBody { Position = float2(0, 0), Velocity = float2(10, 0) }, Radius = 5f, Opacity = .5f, Lifetime = 1000f };
lab.Zone.Vent(cloud);
Assert.Equal(.5f, lab.Zone.Obscuration(float2(0, -20), float2(0, 20)), 5);
lab.Zone.Update(1f);
// The launch velocity decays on the zone's launch drag, so it has moved most of the way and no further.
Assert.InRange(cloud.Body.Position.x, 5f, 9.9f);
Assert.Equal(1f, lab.Zone.Obscuration(float2(0, -20), float2(0, 20)));
Assert.Equal(.5f, lab.Zone.Obscuration(float2(cloud.Body.Position.x, -20), float2(cloud.Body.Position.x, 20)), 2);
}
[Fact]
public void ACloudWithNoLifetimeObscuresNothing()
{
var lab = Build(400f);
var cloud = Hand(lab.Zone, float2(0, 0), 10f, .8f, lifetime: 0f);
Assert.Equal(0f, cloud.OpacityAt(lab.Zone.Time));
Assert.Equal(1f, lab.Zone.Obscuration(float2(-5, 0), float2(5, 0)));
}
[Fact]
public void AnEffectVentsOnce()
{
var lab = Build(400f, radius: 17f, opacity: .7f, lifetime: 123f);
lab.HoldTarget = false;
Run(lab, 2);
lab.Target.Velocity = float2(3, 0);
VapourCloud vented = null;
float2 venterVelocity = float2.zero;
float2 venterAt = float2.zero;
float2 cloudVelocity = float2.zero;
float2 cloudAt = float2.zero;
using var sub = lab.Zone.Clouds.ObserveAdd().Subscribe(add =>
{
vented = add.Value;
cloudVelocity = add.Value.Body.Velocity;
cloudAt = add.Value.Body.Position;
venterVelocity = lab.Target.Velocity;
venterAt = lab.Target.Position.xz;
});
lab.Dump.Enabled.Value = true;
Run(lab, 100);
Assert.Single(lab.Zone.Clouds);
Assert.Same(vented, lab.Zone.Clouds.Single());
Assert.True(length(venterVelocity) > 0f, "the venter was not moving");
Assert.Equal(venterVelocity, cloudVelocity);
Assert.Equal(venterAt, cloudAt);
Assert.Same(lab.Target, vented.Venter);
// Each stat is the authored one, evaluated through the host.
Assert.Equal(17f, vented.Radius);
Assert.Equal(.7f, vented.Opacity);
Assert.Equal(123f, vented.Lifetime);
}
[Fact]
public void ACloudThinsOnlyTheSightLinesItTouches()
{
var lab = Build(400f);
var first = Hand(lab.Zone, float2(0, 0), 10f, .6f);
float Through(float2 a, float2 b) => lab.Zone.Obscuration(a, b);
Assert.Equal(.4f, Through(float2(-50, 0), float2(50, 0)), 5);
Assert.Equal(.4f, Through(float2(50, 0), float2(-50, 0)), 5);
// Passing wide of the centre but within the radius, neither end inside the disc.
Assert.Equal(.4f, Through(float2(-50, 9.9f), float2(50, 9.9f)), 5);
Assert.Equal(1f, Through(float2(-50, 10.1f), float2(50, 10.1f)));
// Ending inside the disc, and a point inside it (the observer inside its own cloud).
Assert.Equal(.4f, Through(float2(-50, 0), float2(5, 0)), 5);
Assert.Equal(.4f, Through(float2(3, 0), float2(3, 0)), 5);
Assert.Equal(1f, Through(float2(30, 0), float2(30, 0)));
// A segment that stops short of the disc, or starts past it, touches nothing even on the same line.
Assert.Equal(1f, Through(float2(-50, 0), float2(-20, 0)));
Assert.Equal(1f, Through(float2(20, 0), float2(50, 0)));
// Two clouds on one sight line multiply.
var second = Hand(lab.Zone, float2(30, 0), 5f, .5f);
Assert.NotEqual(first.Id, second.Id);
Assert.Equal(.2f, Through(float2(-50, 0), float2(50, 0)), 5);
Assert.Equal(.4f, Through(float2(-50, 0), float2(15, 0)), 5);
}
// Where the rule is decided: once the start-up seed has decayed away, the info a sensor settles at is what
// survives the cloud times what the open sight line settles at, in both directions.
[Fact]
public void TheGainIsScaledByWhatSurvivesTheCloud()
{
var clear = Build(400f);
var cloudy = Build(400f);
// Mid-way between them and clear of both: only the segment, not either end, touches it.
Hand(cloudy.Zone, float2(0, 200), 10f, .6f, lifetime: 1e6f);
Run(clear, 400);
Run(cloudy, 400);
Assert.True(Info(clear.Observer, clear.Target) > Threshold);
Assert.Equal(.4f, Info(cloudy.Observer, cloudy.Target) / Info(clear.Observer, clear.Target), 2);
Assert.Equal(.4f, Info(cloudy.Target, cloudy.Observer) / Info(clear.Target, clear.Observer), 2);
}
// A ping is gathered through the same rule. Settled, the tick that a ping reaches the target adds the ping gain
// in place of the passive one; that extra, in a lab with a cloud on the sight line, is what survives the
// cloud times the extra in the open (info is linear in the gain while nothing saturates).
[Fact]
public void ThePingGainIsScaledToo()
{
float PingExtra(Lab lab)
{
Run(lab, 400);
lab.Observer.Sensor.Ping();
var biggest = 0f;
var last = Info(lab.Observer, lab.Target);
for (var i = 0; i < 38; i++)
{
Tick(lab);
biggest = max(biggest, Info(lab.Observer, lab.Target) - last);
last = Info(lab.Observer, lab.Target);
}
return biggest;
}
var clear = Build(250f);
var cloudy = Build(250f);
Hand(cloudy.Zone, float2(0, 125), 10f, .6f, lifetime: 1e6f);
var open = PingExtra(clear);
Assert.True(open > .05f, $"the ping left no mark on the open trace ({open})");
Assert.Equal(.4f, PingExtra(cloudy) / open, 2);
}
// The chunk path reads the same rule: the passive rate toward a point is scaled by the same survivor fraction.
[Fact]
public void TheChunkRateIsScaledByTheSameFactor()
{
var lab = Build(100f);
var at = float2(0, 100);
var open = lab.Observer.Sensor.PassiveRate(100f, at);
Assert.True(open > 0f);
Hand(lab.Zone, float2(0, 50), 10f, .6f);
Assert.Equal(.4f, lab.Observer.Sensor.PassiveRate(100f, at) / open, 5);
}
// The dump's wire shape: BehaviorData union 42, its radius, opacity and lifetime at keys 1, 2 and 3.
[Fact]
public void TheDumpIsUnion42WithRadiusOpacityLifetimeAtKeys1To3()
{
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(ZonePack).Assembly);
var data = new VapourDumpData { Radius = Constant(1f), Opacity = Constant(2f), Lifetime = Constant(3f) };
var bytes = MessagePackSerializer.Serialize<BehaviorData>(data, options);
var reader = new MessagePackReader(bytes);
Assert.Equal(2, reader.ReadArrayHeader());
Assert.Equal(42, reader.ReadInt32());
Assert.Equal(4, reader.ReadArrayHeader());
reader.Skip();
Assert.Equal(1f, MessagePackSerializer.Deserialize<PerformanceStat>(ref reader, options).Min);
Assert.Equal(2f, MessagePackSerializer.Deserialize<PerformanceStat>(ref reader, options).Min);
Assert.Equal(3f, MessagePackSerializer.Deserialize<PerformanceStat>(ref reader, options).Min);
}
}