- 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>
274 lines
13 KiB
C#
274 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using CultMath;
|
|
using GameCult.Caching;
|
|
using Xunit;
|
|
using static CultMath.math;
|
|
using float2 = CultMath.float2;
|
|
using Random = CultMath.Random;
|
|
|
|
// Floating bodies (aetheria-release cut floating-bodies): the drift loot and mines share, the structured
|
|
// dice they roll with, and the zone-scoped ids they carry. The body has no consumer yet; these tests are
|
|
// its whole contract.
|
|
public sealed class FloatingBodyTests : IDisposable
|
|
{
|
|
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-floatingbody-" + Guid.NewGuid().ToString("N"));
|
|
private readonly List<CultCache> _caches = new List<CultCache>();
|
|
|
|
public FloatingBodyTests() => Directory.CreateDirectory(_root);
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var c in _caches) c.Dispose();
|
|
Directory.Delete(_root, true);
|
|
}
|
|
|
|
private static GameplaySettings Settings(float drag = .05f, float launchDrag = .2f, float gravity = 1f) => new GameplaySettings
|
|
{
|
|
FloatingBodyDrag = drag, FloatingBodyLaunchDrag = launchDrag, FloatingBodyGravity = gravity
|
|
};
|
|
|
|
private Zone NewZone()
|
|
{
|
|
var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true);
|
|
_caches.Add(cache);
|
|
var settings = new GameplaySettings
|
|
{
|
|
DefaultEntitySettings = new EntitySettings(),
|
|
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } },
|
|
QualityPriceModifier = new ExponentialLerp()
|
|
};
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { });
|
|
return new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null);
|
|
}
|
|
|
|
private static KinematicBody Run(KinematicBody b, float seconds, float dt, float2 force, GameplaySettings s)
|
|
{
|
|
var steps = (int) Math.Round(seconds / dt);
|
|
for (var i = 0; i < steps; i++) b.Step(dt, force, s);
|
|
return b;
|
|
}
|
|
|
|
[Fact]
|
|
public void LaunchVelocityDecaysExponentially()
|
|
{
|
|
var s = Settings();
|
|
// Speed .3: the semi-implicit step's position error is bounded by speed * dt / 2 (about .005 at dt .1), inside the 1e-2 pin.
|
|
var start = new KinematicBody { Velocity = new float2(.18f, .24f) };
|
|
foreach (var dt in new[] { 1f / 120f, .1f })
|
|
{
|
|
var b = Run(start, 3f, dt, float2.zero, s);
|
|
Assert.Equal(.3f * exp(-s.FloatingBodyLaunchDrag * 3f), length(b.Velocity), 4);
|
|
}
|
|
|
|
var fine = Run(start, 3f, 1f / 120f, float2.zero, s);
|
|
var coarse = Run(start, 3f, .1f, float2.zero, s);
|
|
Assert.True(length(fine.Position - coarse.Position) < 1e-2f);
|
|
// It really moved, along the launch direction.
|
|
Assert.True(fine.Position.x > 0f && fine.Position.y > fine.Position.x);
|
|
}
|
|
|
|
[Fact]
|
|
public void DriftFollowsTheForce()
|
|
{
|
|
var s = Settings();
|
|
var force = new float2(.02f, -.01f);
|
|
var b = Run(new KinematicBody(), 600f, .1f, force, s);
|
|
Assert.Equal(force.x * s.FloatingBodyGravity / s.FloatingBodyDrag, b.Drift.x, 2);
|
|
Assert.Equal(force.y * s.FloatingBodyGravity / s.FloatingBodyDrag, b.Drift.y, 2);
|
|
Assert.Equal(b.Velocity + b.Drift, b.TotalVelocity);
|
|
|
|
var noGravity = Run(new KinematicBody(), 10f, .1f, force, Settings(gravity: 0f));
|
|
Assert.Equal(float2.zero, noGravity.Drift);
|
|
Assert.Equal(float2.zero, noGravity.Position);
|
|
|
|
var noForce = Run(new KinematicBody { Velocity = new float2(1f, 0f) }, 2f, .1f, float2.zero, s);
|
|
Assert.Equal(float2.zero, noForce.Drift);
|
|
Assert.True(noForce.Position.x > 0f && noForce.Position.y == 0f);
|
|
}
|
|
|
|
[Fact]
|
|
public void OneStepMovesByLaunchPlusDrift()
|
|
{
|
|
var s = Settings();
|
|
var b = new KinematicBody { Velocity = new float2(2f, 0f), Drift = new float2(0f, 1f) };
|
|
b.Step(.5f, new float2(.4f, 0f), s);
|
|
var velocity = 2f * exp(-s.FloatingBodyLaunchDrag * .5f);
|
|
var drift = new float2(.4f * .5f, 1f) * exp(-s.FloatingBodyDrag * .5f);
|
|
Assert.Equal(velocity, b.Velocity.x, 4);
|
|
Assert.Equal(drift.x, b.Drift.x, 4);
|
|
Assert.Equal(drift.y, b.Drift.y, 4);
|
|
Assert.Equal((velocity + drift.x) * .5f, b.Position.x, 4);
|
|
Assert.Equal(drift.y * .5f, b.Position.y, 4);
|
|
}
|
|
|
|
[Fact]
|
|
public void ShotDiceAreUnchanged()
|
|
{
|
|
// fmix32 as FireControl.MixSeed wrote it, the function this cut moved.
|
|
static uint Reference(uint seed)
|
|
{
|
|
seed ^= seed >> 16; seed *= 0x85ebca6bu; seed ^= seed >> 13; seed *= 0xc2b2ae35u; seed ^= seed >> 16;
|
|
return seed;
|
|
}
|
|
var inputs = new List<uint> { 0u, 0xFFFFFFFFu };
|
|
for (uint i = 1; inputs.Count < 64; i++) inputs.Add(i * 2654435761u);
|
|
foreach (var x in inputs) Assert.Equal(Reference(x), SimulationDice.Mix(x));
|
|
Assert.Equal(0u, SimulationDice.Mix(0u));
|
|
Assert.Equal(0x514E28B7u, SimulationDice.Mix(1u));
|
|
}
|
|
|
|
[Fact]
|
|
public void DiceStreamsAreIndependentAndRepeatable()
|
|
{
|
|
var loot = SimulationDice.For(1234u, SimulationDice.LootStream, 5u);
|
|
var mine = SimulationDice.For(1234u, SimulationDice.MineStream, 5u);
|
|
Assert.NotEqual(loot.NextUInt(), mine.NextUInt());
|
|
|
|
var a = SimulationDice.For(1234u, SimulationDice.LootStream, 5u);
|
|
var b = SimulationDice.For(1234u, SimulationDice.LootStream, 5u);
|
|
for (var i = 0; i < 8; i++) Assert.Equal(a.NextUInt(), b.NextUInt());
|
|
|
|
Assert.NotEqual(SimulationDice.For(1234u, SimulationDice.LootStream, 5u).NextUInt(), SimulationDice.For(1235u, SimulationDice.LootStream, 5u).NextUInt());
|
|
Assert.NotEqual(SimulationDice.For(1234u, SimulationDice.LootStream, 5u).NextUInt(), SimulationDice.For(1234u, SimulationDice.LootStream, 6u).NextUInt());
|
|
|
|
// Never seeded 0: the argument set that mixes to zero still rolls.
|
|
var zero = SimulationDice.For(0u, 0u, 0u);
|
|
Assert.Equal(1u, zero.state);
|
|
}
|
|
|
|
// The stream is mixed into the seed, not added beside it: over many ordinals no two streams give the same first
|
|
// draw, and none of them tracks another's.
|
|
[Fact]
|
|
public void EveryStreamDrawsIndependentlyOfEveryOther()
|
|
{
|
|
uint[] streams = { SimulationDice.LootStream, SimulationDice.MineStream, SimulationDice.CombatSampleStream, SimulationDice.PatrolStream };
|
|
const int count = 512;
|
|
var draws = streams.Select(s => Enumerable.Range(1, count).Select(n => SimulationDice.For(1234u, s, (uint) n).NextFloat()).ToArray()).ToArray();
|
|
for (var a = 0; a < streams.Length; a++)
|
|
for (var b = a + 1; b < streams.Length; b++)
|
|
{
|
|
Assert.Equal(0, Enumerable.Range(0, count).Count(i => draws[a][i] == draws[b][i]));
|
|
var meanA = draws[a].Average();
|
|
var meanB = draws[b].Average();
|
|
var covariance = Enumerable.Range(0, count).Sum(i => (draws[a][i] - meanA) * (draws[b][i] - meanB)) / count;
|
|
var correlation = covariance / (StandardDeviation(draws[a], meanA) * StandardDeviation(draws[b], meanB));
|
|
Assert.InRange(correlation, -.15f, .15f); // 512 independent draws: sigma ~ .044
|
|
}
|
|
}
|
|
|
|
private static float StandardDeviation(float[] xs, float mean) => MathF.Sqrt(xs.Sum(x => (x - mean) * (x - mean)) / xs.Length);
|
|
|
|
[Fact]
|
|
public void NeighbouringZonesAndOrdinalsNeverShareADraw()
|
|
{
|
|
// Folding stream and ordinal into the zone seed with one xor would make (zone, n) and (zone ^ 1, n ^ 1)
|
|
// the same seed; the double mix keeps them apart.
|
|
for (uint zone = 0; zone < 64; zone++)
|
|
for (uint n = 0; n < 16; n++)
|
|
Assert.NotEqual(SimulationDice.For(zone, SimulationDice.LootStream, n).NextUInt(),
|
|
SimulationDice.For(zone ^ 1u, SimulationDice.LootStream, n ^ 1u).NextUInt());
|
|
}
|
|
|
|
[Fact]
|
|
public void BodyIdsAreUniqueInAZone()
|
|
{
|
|
var zone = NewZone();
|
|
var seen = new HashSet<FloatingBodyId>();
|
|
uint last = 0;
|
|
for (var i = 0; i < 10000; i++)
|
|
{
|
|
var id = zone.NextBodyId();
|
|
Assert.True(id.Value > last);
|
|
last = id.Value;
|
|
Assert.True(seen.Add(id));
|
|
}
|
|
Assert.Equal(1u, NewZone().NextBodyId().Value);
|
|
Assert.Equal("1", new FloatingBodyId(1).ToString());
|
|
Assert.Equal(new FloatingBodyId(7), new FloatingBodyId(7));
|
|
Assert.NotEqual(new FloatingBodyId(7), new FloatingBodyId(8));
|
|
}
|
|
|
|
// Ship.Update's own gravity line, through the real ship and a real zone with one well at the origin.
|
|
private Ship ShipAt(float x, out Zone zone)
|
|
{
|
|
var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), Path.Combine(_root, Guid.NewGuid().ToString("N") + ".run.cc"), catalogWritable: true);
|
|
_caches.Add(cache);
|
|
var hullShape = new Shape(5, 5);
|
|
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
|
|
cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 });
|
|
cache.FlushAsync().Wait();
|
|
var settings = new GameplaySettings
|
|
{
|
|
DefaultEntitySettings = new EntitySettings(),
|
|
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } },
|
|
QualityPriceModifier = new ExponentialLerp()
|
|
};
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { });
|
|
var planets = new PlanetSettings { HeightExaggeration = 10f }; // the tilt reads force / K, which K = 1 would hide
|
|
var pack = new ZonePack { Radius = 1000 };
|
|
WellFixture.Dig(cache, pack, planets, 300, 100);
|
|
zone = new Zone(items, planets, pack, new GalaxyZone { Name = "Test Zone", Owner = null }, null);
|
|
var design = cache.GetByName<HullData>("Skiff");
|
|
var hull = new EquippableItem
|
|
{
|
|
Data = cache.RefOf<ItemData>(design), Durability = design.Durability,
|
|
Lot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(design), Origin = new Attributed(), Quality = .5f, Roles = new List<RoleFill>() })
|
|
};
|
|
var ship = new Ship(items, zone, hull, new EntitySettings());
|
|
zone.Admit(ship, piloted: false);
|
|
ship.Aim = float3(0, 0, 1);
|
|
ship.Position = float3(x, 0, 0);
|
|
return ship;
|
|
}
|
|
|
|
[Fact]
|
|
public void AShipInAWellIsPulledByThePotential()
|
|
{
|
|
foreach (var delta in new[] { 1f / 60f, 1f / 30f })
|
|
{
|
|
var ship = ShipAt(400f, out var zone);
|
|
var at = ship.Position.xz;
|
|
var expected = zone.GetForce(at) * delta;
|
|
Assert.True(expected.x < 0f, "the well must pull a ship at +x toward the centre");
|
|
|
|
ship.Update(delta);
|
|
|
|
var tolerance = Math.Max(1e-7f, length(expected) * 1e-4f);
|
|
Assert.InRange(ship.Velocity.x, expected.x - tolerance, expected.x + tolerance);
|
|
Assert.InRange(ship.Velocity.y, expected.y - tolerance, expected.y + tolerance);
|
|
|
|
// The tilt is the surface of the drawn height (potential / K): its slope is the force over K.
|
|
var slope = zone.GetForce(at) / zone.Settings.HeightExaggeration;
|
|
var normal = normalize(float3(slope.x, 1f, slope.y));
|
|
var q = ship.Rotation;
|
|
var u = float3(q.x, q.y, q.z);
|
|
var v = float3(0, 1, 0);
|
|
var up = v + 2 * (q.w * cross(u, v) + cross(u, cross(u, v)));
|
|
Assert.True(length(up - normal) < 1e-4f, "the ship tilts to the zone's surface normal");
|
|
}
|
|
}
|
|
|
|
// Ship.Update's order: position first, then gravity sampled where the ship now is (leapfrog's kick after the drift). A
|
|
// ship already moving falls toward the centre through the field at its new place, not the one it left.
|
|
[Fact]
|
|
public void AMovingShipFeelsTheFieldWhereItHasMoved()
|
|
{
|
|
var ship = ShipAt(400f, out var zone);
|
|
ship.Velocity = float2(-200f, 0f);
|
|
const float delta = .25f;
|
|
|
|
ship.Update(delta);
|
|
|
|
var moved = float2(400f, 0f) + float2(-200f, 0f) * delta;
|
|
Assert.Equal(moved.x, ship.Position.x, 1e-3f);
|
|
var pulled = (ship.Velocity - float2(-200f, 0f)) / delta;
|
|
var expected = zone.GetForce(moved);
|
|
Assert.True(expected.x < 0f, "the well pulls inward at the ship's new place");
|
|
Assert.InRange(pulled.x, expected.x * (1f + 1e-3f), expected.x * (1f - 1e-3f));
|
|
Assert.InRange(pulled.y, -1e-3f, 1e-3f);
|
|
Assert.NotEqual(zone.GetForce(float2(400f, 0f)).x, expected.x); // the premise: the field differs between the two places
|
|
}
|
|
}
|