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

345 lines
16 KiB
C#

using System;
using System.IO;
using CultMath;
using GameCult.Caching;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// The global flow as a world fact (campaign aetheria-fauna, cut fauna-flow-field; ruling sim-knows-the-flow; target
// invariants flow-one-owner and flow-step-free). Every test reads Zone.Flow / Zone.FlowScroll, the path a fauna
// class will read, from zones built the way the other ServerShared tests build them.
public sealed class FlowFieldTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-flow-" + Guid.NewGuid().ToString("N"));
private readonly CultCache _cache;
private readonly ItemManager _items;
public FlowFieldTests()
{
Directory.CreateDirectory(_root);
_cache = AetheriaStores.Open(Path.Combine(_root, "flow.cc"), Path.Combine(_root, "flow-run.cc"), catalogWritable: true);
_cache.Upsert(new VerseGrammar { Revision = 1 });
_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(),
TargetDetectionInfoThreshold = .1f, TargetGearInfoThreshold = .8f,
FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 2f
};
_items = new ItemManager(1u, _cache, new ProvenanceLedger(), settings, _ => { });
}
public void Dispose()
{
_cache.Dispose();
Directory.Delete(_root, true);
}
private static FlowSettings Flow(float amplitude = 15f, float slide = 0f, float swirl = 0f) => new FlowSettings
{
GlobalScale = 512f, GlobalAmplitude = amplitude, GlobalScrollSpeed = .025f, Period = 8f,
SlopeAmplitude = slide, SwirlAmplitude = swirl
};
// A zone at `time` (the pack's clock, as a saved zone restores it), flat unless `strength` digs the zone's own well.
private Zone ZoneWith(FlowSettings flow, double time = 0, float strength = 0f)
{
var planets = new PlanetSettings { Flow = flow };
var pack = new ZonePack { Time = time };
if (strength > 0f) WellFixture.Dig(_items.ItemData, pack, planets, strength, 100f);
return new Zone(_items, planets, pack, new GalaxyZone { Name = "Flow Zone", Owner = null }, null);
}
private static readonly float3[] Probes =
{
new float3(0, 0, 0), new float3(37.5f, 12f, -80.25f), new float3(-410f, -30f, 125f),
new float3(1234f, 50f, 987f), new float3(-2501f, 7f, -3333f), new float3(9000f, 90f, -4000f)
};
private static void AssertClose(float3 expected, float3 actual, float tolerance = 1e-3f)
{
Assert.True(abs(expected.x - actual.x) <= tolerance && abs(expected.y - actual.y) <= tolerance &&
abs(expected.z - actual.z) <= tolerance, $"expected {expected} but flow was {actual}");
}
[Fact]
public void ScrollIsSpeedTimesZoneTime()
{
// The flow at zone time t is the flow at time 0 of a field moved along y by -scale * speed * t, so the
// noise scrolls by speed x zone time and nothing else (an odd t keeps the shift off a whole field period).
const double t = 7.0;
var flow = Flow();
var later = ZoneWith(flow, t);
var start = ZoneWith(flow, 0);
var shift = flow.GlobalScale * flow.GlobalScrollSpeed * (float) t;
foreach (var p in Probes)
AssertClose(start.Flow(new float3(p.x, p.y - shift, p.z)), later.Flow(p));
Assert.Equal(flow.GlobalScrollSpeed * t, later.FlowScroll.x, 6);
Assert.Equal(float2(0f, 0f), start.FlowScroll);
}
[Fact]
public void FlowDoesNotDependOnStepSize()
{
var fine = ZoneWith(Flow());
var coarse = ZoneWith(Flow());
for (var i = 0; i < 60; i++) fine.Update(1f / 60f);
for (var i = 0; i < 7; i++) coarse.Update(1f / 7f);
Assert.Equal(1.0, fine.Time, 4);
Assert.Equal(1.0, coarse.Time, 4);
foreach (var p in Probes) AssertClose(fine.Flow(p), coarse.Flow(p));
Assert.Equal(fine.FlowScroll.x, coarse.FlowScroll.x, 5);
Assert.Equal(fine.FlowScroll.y, coarse.FlowScroll.y, 5);
// The flow moved: a scroll of 0.025 per second is a different field from the start, somewhere.
var start = ZoneWith(Flow());
Assert.Contains(Probes, p => length(start.Flow(p) - fine.Flow(p)) > .05f);
}
[Fact]
public void ZeroAmplitudesGiveZeroFlowAndNoNaN()
{
// All amplitudes zero (and a zero scale a bad config could carry): no term runs, so nothing divides by zero.
var none = ZoneWith(new FlowSettings());
foreach (var p in Probes) Assert.Equal(float3.zero, none.Flow(p));
// A zone built from default PlanetSettings has no flow, the baseline every other ServerShared test relies on.
var defaults = new Zone(_items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Default", Owner = null }, null);
foreach (var p in Probes) Assert.Equal(float3.zero, defaults.Flow(p));
// A term with zero amplitudes is skipped outright: not even a gradient that cannot be normalised reaches it.
var unusable = float2(float.PositiveInfinity, float.NaN);
Assert.Equal(float3.zero, FlowField.Slope(new FlowSettings(), unusable));
// A flat zone has a zero gradient; swirl and slide are zero there, never NaN.
var flat = ZoneWith(Flow(amplitude: 0f, slide: 1f, swirl: 1f));
foreach (var p in Probes)
{
var f = flat.Flow(p);
Assert.False(float.IsNaN(f.x) || float.IsNaN(f.y) || float.IsNaN(f.z), "zero gradient gave NaN");
Assert.Equal(float3.zero, f);
}
}
// Zone.Flow's slope term reads the owner's force at the point: two equal wells 600 apart (two subzones, each felt within
// its reach of 300), probes inside each off the axis, where the direction is not along the axis.
[Fact]
public void TheSlopeTermFollowsTheSuperposedForce()
{
var planets = new PlanetSettings { Flow = Flow(amplitude: 0f, slide: 1.5f) };
var pack = new ZonePack();
WellFixture.Dig(_items.ItemData, pack, planets, 300f, 100f, new float2(-300f, 0f));
WellFixture.Dig(_items.ItemData, pack, planets, 300f, 100f, new float2(300f, 0f));
var zone = new Zone(_items, planets, pack, new GalaxyZone { Name = "Two Wells", Owner = null }, null);
var checkedPoints = 0;
foreach (var at in new[] { float2(-200f, 150f), float2(-350f, -120f), float2(250f, 220f), float2(420f, -90f) })
{
var flow = zone.Flow(float3(at.x, 3f, at.y));
var expected = 1.5f * normalize(zone.GetForce(at));
Assert.Equal(expected.x, flow.x, 3);
Assert.Equal(expected.y, flow.z, 3);
checkedPoints++;
}
Assert.Equal(4, checkedPoints);
}
[Fact]
public void SlopeTermSwirlsAndSlidesOnTheGradient()
{
// The zone's own well is deepest at the origin: downhill at p is toward it.
var at = new float3(500f, 3f, 300f);
var downhill = normalize(float2(-at.x, -at.z));
var slide = ZoneWith(Flow(amplitude: 0f, slide: 1.5f), strength: 300f).Flow(at);
Assert.Equal(0f, slide.y, 4);
Assert.Equal(1.5f * downhill.x, slide.x, 2);
Assert.Equal(1.5f * downhill.y, slide.z, 2);
// Swirl is perpendicular to downhill, and has a handedness: (down.y, -down.x).
var swirl = ZoneWith(Flow(amplitude: 0f, swirl: 2f), strength: 300f).Flow(at);
Assert.Equal(0f, swirl.y, 4);
Assert.Equal(2f * downhill.y, swirl.x, 2);
Assert.Equal(-2f * downhill.x, swirl.z, 2);
Assert.Equal(0f, dot(float2(swirl.x, swirl.z), downhill), 2);
Assert.Equal(2f, length(float2(swirl.x, swirl.z)), 2);
}
[Fact]
public void ZoneFlowReadsItsPlanetSettings()
{
var one = ZoneWith(Flow(amplitude: 15f), 3.0);
var two = ZoneWith(Flow(amplitude: 30f), 3.0);
foreach (var p in Probes) AssertClose(one.Flow(p) * 2f, two.Flow(p));
// The flow is live (the origin is a legitimate zero of the field, so it is not every probe).
Assert.Contains(Probes, p => length(one.Flow(p)) > 1f);
}
[Fact]
public void ZoneFlowIsTheLawAtTheScrollTheShaderGets()
{
// Zone.Flow and the lowered scroll are one zone-time value: the flow at p is the law evaluated at the zone's
// own FlowScroll, after any run of steps (a flow that ran ahead of or behind the shader's scroll differs here).
var zone = ZoneWith(Flow(), 3.25);
for (var step = 0; step < 90; step++)
{
zone.Update(1f / 60f);
foreach (var p in Probes) AssertClose(FlowField.Global(Flow(), p, zone.FlowScroll), zone.Flow(p), 1e-4f);
}
}
[Fact]
public void PausedSimFreezesTheFlowPhases()
{
// Presenters take their cycle phase from zone time: a zone that does not advance does not move them, and a
// zone that does moves them by dt / period (mod 1).
const float period = 8f;
var zone = ZoneWith(Flow(), 5.0);
var before = zone.CyclePhase(period);
var scroll = zone.FlowScroll;
zone.Update(0f);
Assert.Equal(before, zone.CyclePhase(period));
Assert.Equal(scroll, zone.FlowScroll);
zone.Update(2f);
Assert.Equal(frac(before + 2f / period), zone.CyclePhase(period), 5);
Assert.InRange(zone.CyclePhase(period), 0f, .9999999f);
}
[Theory]
[InlineData(1)]
[InlineData(10)]
public void ScrollKeepsAdvancingAtDaysOfZoneTime(int days)
{
// Zone time persists and only grows. A float of speed x time stops resolving a 1/60 s step after days; the
// phases the shader gets stay in [0, 1) and advance by speed x dt on every step.
const double dt = 1.0 / 60.0;
var flow = Flow();
var zone = ZoneWith(flow, days * 86400.0 + .1);
var previous = zone.FlowScroll;
for (var step = 0; step < 30; step++)
{
zone.Update((float) dt);
var now = zone.FlowScroll;
Assert.InRange(now.x, 0f, .9999999f);
Assert.InRange(now.y, 0f, .9999999f);
var dx = now.x - previous.x;
var dy = now.y - previous.y;
if (dx < -.5f) dx += 1f;
if (dy < -.5f) dy += 1f;
var expected = flow.GlobalScrollSpeed * dt;
Assert.InRange((double) dx, expected - 5e-7, expected + 5e-7);
Assert.InRange((double) dy, expected * 1.61803398875 - 5e-7, expected * 1.61803398875 + 5e-7);
previous = now;
}
}
// The law as Volumetric.cginc spelled it before the per-octave fold: Tri3D(p) with p * Golden for the second
// octave and p = pos / scale - (0, speed * t, 0), in double with the unfolded scroll. The oracle the fold must
// agree with; it shares no code with FlowField.
// The origin is a degenerate point of the field (a zero cross product), where no law can be compared.
private static readonly float3[] OffOrigin = Probes[1..];
private static double OTri(double x) => Math.Abs(x - Math.Floor(x) - .5);
private static double[] OTri3(double x, double y, double z) => new[] { OTri(z + OTri(y)), OTri(z + OTri(x)), OTri(y + OTri(x)) };
private static double[] ONormalize(double[] v)
{
var l = Math.Sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
return new[] { v[0] / l, v[1] / l, v[2] / l };
}
private static double[] UnfoldedLaw(FlowSettings f, float3 p, double t)
{
double g = 1.61803398875f;
var s = (double) f.GlobalScrollSpeed * t;
double x = p.x / (double) f.GlobalScale, y = p.y / (double) f.GlobalScale - s, z = p.z / (double) f.GlobalScale;
var t1 = ONormalize(OTri3(x, y, z));
var t2 = ONormalize(OTri3(x * g, y * g, z * g));
var c = new[] { t1[1] * t2[2] - t1[2] * t2[1], t1[2] * t2[0] - t1[0] * t2[2], t1[0] * t2[1] - t1[1] * t2[0] };
return new[] { c[0] * f.GlobalAmplitude, c[1] * f.GlobalAmplitude * .5, c[2] * f.GlobalAmplitude };
}
private static double Distance(float3 a, double[] b) =>
Math.Sqrt((a.x - b[0]) * (a.x - b[0]) + (a.y - b[1]) * (a.y - b[1]) + (a.z - b[2]) * (a.z - b[2]));
[Fact]
public void FoldedScrollIsTheUnfoldedLawAtAnyAge()
{
// fold-law: the phase each octave gets is the unfolded speed x time (second octave: x Golden), folded once in
// double. Computing the second from the folded first (frac(frac(s) x Golden)) agrees below 40 s and nowhere after.
var f = Flow();
foreach (var t in new[] { 0.0, 3.1, 39.99, 40.0, 40.01, 86400.0, 864000.0 + .3, 8640000.0 + .7 })
foreach (var p in OffOrigin)
Assert.True(Distance(FlowField.Global(f, p, FlowField.Scroll(f, t)), UnfoldedLaw(f, p, t)) / f.GlobalAmplitude < 5e-5,
$"t={t} p={p}");
}
[Fact]
public void NoJumpWhereAPhaseWraps()
{
// Each octave's phase wraps at its own times (the first every 40 s of zone time at speed .025): across a wrap
// the flow moves by what the unfolded law moves, so nothing jumps.
var f = Flow();
double speed = f.GlobalScrollSpeed, golden = 1.61803398875f;
const double h = 1.0 / 120.0;
foreach (var (octave, cycles) in new[] { (0, 1.0), (0, 21600.0), (1, 1.0), (1, 34950.0) })
{
var t = octave == 0 ? cycles / speed : cycles / (speed * golden);
var before = FlowField.Scroll(f, t - h);
var after = FlowField.Scroll(f, t + h);
Assert.True(octave == 0 ? before.x > .9f && after.x < .1f : before.y > .9f && after.y < .1f,
$"octave {octave} cycles {cycles}: the probe times do not straddle a wrap");
foreach (var p in OffOrigin)
{
var folded = FlowField.Global(f, p, after) - FlowField.Global(f, p, before);
var a = UnfoldedLaw(f, p, t + h);
var b = UnfoldedLaw(f, p, t - h);
Assert.True(Distance(folded, new[] { a[0] - b[0], a[1] - b[1], a[2] - b[2] }) / f.GlobalAmplitude < 1e-4,
$"octave {octave} cycles {cycles} p={p}");
}
}
}
[Theory]
[InlineData(10)]
[InlineData(100)]
public void CycleKeepsAdvancingAtDays(int days)
{
// The cycle phase takes zone time in double: a float zone time at 10 days (8.6e5 s) resolves 0.06 s and at
// 100 days 1 s, so a 1/60 s step would not move the phase.
foreach (var period in new[] { 8f, .25f })
{
const double dt = 1.0 / 60.0;
var time = days * 86400.0 + .1;
var previous = FlowField.Cycle(time, period);
for (var step = 0; step < 30; step++)
{
time += dt;
var now = FlowField.Cycle(time, period);
var advance = now - previous;
if (advance < -.5f) advance += 1f;
Assert.InRange((double) advance, dt / period - 1e-5, dt / period + 1e-5);
previous = now;
}
}
}
[Fact]
public void PhasesAreStrictlyBelowOne()
{
// A phase just below an integer rounds to 1f in a float cast; the contract is [0, 1), so it reads 0 instead
// (1 is 0 modulo 1: the flow there is the flow at 0).
var f = Flow();
double speed = f.GlobalScrollSpeed, golden = 1.61803398875f;
foreach (var gap in new[] { 1e-9, 1e-12 })
{
var first = FlowField.Scroll(f, (1.0 - gap) / speed);
var second = FlowField.Scroll(f, (2.0 - gap) / (speed * golden));
Assert.InRange(first.x, 0f, .9999999f);
Assert.InRange(second.y, 0f, .9999999f);
Assert.InRange(FlowField.Cycle(8.0 * (1.0 - gap), 8f), 0f, .9999999f);
}
}
}