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

272 lines
14 KiB
C#

/* 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.IO;
using CultMath;
using GameCult.Caching;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
// Cut 1 (docs/mining-cut.md): belts stop being simulated every tick on a threaded double buffer and become a
// pure function of zone time, evaluated on request. These tests pin that: a chunk's pose is exactly the
// closed-form orbit at the zone's current time (no staleness), it does not depend on how many ticks it took to
// get there (no stored state), and the size rule moved off the deleted per-tick pass unchanged. Every expected
// value below is computed independently, by the closed form written in this file -- never by calling
// AsteroidBelt.Pose/Size, the code under test.
public sealed class MiningCut1Tests : IDisposable
{
private int _fixtureCount;
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-miningcut1-" + Guid.NewGuid().ToString("N"));
public MiningCut1Tests() => Directory.CreateDirectory(_root);
public void Dispose() => Directory.Delete(_root, true);
private static GameplaySettings GameSettings() => new GameplaySettings
{
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp()
};
// Authored-shape curves, not defaults: a default ExponentialLerp/ExponentialCurve evaluates to a flat
// constant everywhere, which would hide a broken formula (every asteroid or every damage state would read
// alike). These give size and hitpoints real, distinct curvature over the fixture's damage range. A body's
// well strength is its mass here (c = mass), with a small constant core, so the test spells every period from
// the masses it chose.
private const float SunStrength = 400f;
private const float PlanetStrength = 25f;
private static PlanetSettings Settings() => new PlanetSettings
{
BodyRadius = new ExponentialCurve(),
WellStrength = new ExponentialCurve { Multiplier = 1f, Exponent = 1f },
RocheLimit = new ExponentialCurve { Constant = 2f },
AsteroidSize = new ExponentialLerp { Minimum = 2f, Maximum = 9f, Exponent = 1.7f },
AsteroidHitpoints = new ExponentialLerp { Minimum = 10f, Maximum = 300f, Exponent = 2f },
AsteroidRespawnTime = new ExponentialLerp { Minimum = 5f, Maximum = 60f, Exponent = 1f }
};
private const int AsteroidCount = 24;
private const float RootDistance = 4000f;
private const float RootPhase = 0.17f;
// A belt of AsteroidCount asteroids at distinct distances, phases, size parameters and rotation speeds,
// orbiting a planet (RootDistance/RootPhase round a fixed sun) that is itself moving -- so a test that reads the parent's
// motion through ChunkPose is exercising real recursion, not a fixed point. Each call opens its own
// catalog, so two zones built from this fixture never share cache state.
private (Zone zone, CultRecordKey beltKey, Asteroid[] asteroids, PlanetSettings settings) BuildFixture()
{
var dir = Path.Combine(_root, $"cat{_fixtureCount++}");
Directory.CreateDirectory(dir);
var catalog = Path.Combine(dir, "Aetheria.cc");
var run = Path.Combine(dir, "run.cc");
// OrbitData and BodyData (AsteroidBeltData) are run types (AetheriaStores.RunTypes), not catalog types:
// they need a run store to write into.
var cache = AetheriaStores.Open(catalog, run, catalogWritable: true);
var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { });
var settings = Settings();
var sunOrbitKey = cache.Upsert(new OrbitData()).Key;
var sunKey = cache.Upsert(new PlanetData { Name = "Sun", Orbit = new CultRecordRef<OrbitData>(sunOrbitKey), Mass = SunStrength }).Key;
var rootOrbit = new OrbitData { Parent = new CultRecordRef<OrbitData>(sunOrbitKey), Distance = RootDistance, Phase = RootPhase };
var rootKey = cache.Upsert(rootOrbit).Key;
var planetKey = cache.Upsert(new PlanetData { Name = "Planet", Orbit = new CultRecordRef<OrbitData>(rootKey), Mass = PlanetStrength }).Key;
var beltOrbit = new OrbitData { Parent = new CultRecordRef<OrbitData>(rootKey), Distance = 900f, Phase = 0.42f };
var beltOrbitKey = cache.Upsert(beltOrbit).Key;
var asteroids = new Asteroid[AsteroidCount];
for (var i = 0; i < AsteroidCount; i++)
{
asteroids[i] = new Asteroid
{
Distance = 120f + i * 11.3f,
Phase = frac(i * 0.6180339887f),
Size = frac(i * 0.37f + 0.1f),
RotationSpeed = 0.15f + i * 0.037f
};
}
var beltData = new AsteroidBeltData { Orbit = new CultRecordRef<OrbitData>(beltOrbitKey), Asteroids = asteroids };
var beltKey = cache.Upsert(beltData).Key;
var pack = new ZonePack
{
Orbits = { new CultRecordRef<OrbitData>(sunOrbitKey), new CultRecordRef<OrbitData>(rootKey), new CultRecordRef<OrbitData>(beltOrbitKey) },
Planets = { new CultRecordRef<BodyData>(sunKey), new CultRecordRef<BodyData>(planetKey), new CultRecordRef<BodyData>(beltKey) },
Radius = 5000f,
Mass = 10000f,
Time = 0
};
var zone = new Zone(items, settings, pack, new GalaxyZone { Name = "MiningCut1", Owner = null }, null);
return (zone, beltKey, asteroids, settings);
}
// Zone.GetOrbitPosition's placement round a body of well strength `strength`, with the period spelled here from
// the field law outside the core (a = c/d, so T = 2 pi d / sqrt(c)), so the expected value never calls
// production orbit code.
private static float2 OrbitPosition(double time, float distance, float phase, float strength, float2 fixedPosition = default)
{
var period = 2 * PI * distance / sqrt(strength);
var wrapped = (float) frac(time / period);
return OrbitData.Evaluate(frac(wrapped + phase)) * distance + fixedPosition;
}
// The pose of an asteroid circling a planet of well strength `strength`, spelled independently of AsteroidBelt.Pose.
private static float4 ExpectedPose(double time, Asteroid asteroid, float2 parentPosition, float strength, float size)
{
var rot = (float) (time * asteroid.RotationSpeed % (PI * 2));
var period = 2 * PI * asteroid.Distance / sqrt(strength);
var pos = OrbitData.Evaluate((float) frac(time / period + asteroid.Phase)) * asteroid.Distance + parentPosition;
return float4(pos.x, pos.y, rot, size);
}
private static void AssertClose(float expected, float actual, float eps, string what)
{
Assert.True(Math.Abs(expected - actual) <= eps, $"{what}: expected {expected}, got {actual} (eps {eps})");
}
[Fact]
public void ChunkPoseIsTheOrbitAtZoneTime()
{
var (zone, beltKey, asteroids, settings) = BuildFixture();
// Uneven dt, so a tick-count-based or double-buffered implementation would disagree with a pure
// function of the accumulated time.
var dts = new[] { 0.13f, 0.07f, 0.31f, 0.02f, 0.5f, 0.11f, 0.19f, 0.08f, 0.44f, 0.03f, 0.27f, 0.09f };
double time = 0;
foreach (var dt in dts)
{
zone.Update(dt);
time += dt;
}
var parentPosition = OrbitPosition(time, RootDistance, RootPhase, SunStrength);
for (var i = 0; i < asteroids.Length; i++)
{
var expected = ExpectedPose(time, asteroids[i], parentPosition, PlanetStrength, settings.AsteroidSize.Evaluate(asteroids[i].Size));
var actual = zone.ChunkPose(beltKey, i);
AssertClose(expected.x, actual.x, 0.01f, $"asteroid {i} x");
AssertClose(expected.y, actual.y, 0.01f, $"asteroid {i} y");
AssertClose(expected.z, actual.z, 0.001f, $"asteroid {i} rotation");
AssertClose(expected.w, actual.w, 0.001f, $"asteroid {i} size");
}
}
// The fixture's rotation speeds are under 1.1 rad/s, so the accumulated ~2 s of the test above never
// reaches one turn and cannot tell a wrapped rotation from an unwrapped one. Forty seconds turns the
// faster asteroids more than once.
[Fact]
public void ChunkRotationWrapsAtOneTurn()
{
var (zone, beltKey, asteroids, settings) = BuildFixture();
zone.Update(40f);
var parentPosition = OrbitPosition(40.0, RootDistance, RootPhase, SunStrength);
var wrapped = 0;
for (var i = 0; i < asteroids.Length; i++)
{
if (40.0 * asteroids[i].RotationSpeed > PI * 2) wrapped++;
var expected = ExpectedPose(40.0, asteroids[i], parentPosition, PlanetStrength, settings.AsteroidSize.Evaluate(asteroids[i].Size));
var actual = zone.ChunkPose(beltKey, i);
AssertClose(expected.z, actual.z, 0.001f, $"asteroid {i} rotation");
Assert.InRange(actual.z, 0f, PI * 2);
}
Assert.True(wrapped > 0, "sanity: the fixture must turn at least one asteroid past one full rotation");
}
[Fact]
public void ChunkPoseDoesNotDependOnUpdateCadence()
{
var (zoneOneStep, beltKeyA, asteroids, settings) = BuildFixture();
zoneOneStep.Update(1.0f);
var (zoneManySteps, beltKeyB, _, _) = BuildFixture();
for (var i = 0; i < 60; i++)
zoneManySteps.Update(1f / 60f);
for (var i = 0; i < asteroids.Length; i++)
{
var a = zoneOneStep.ChunkPose(beltKeyA, i);
var b = zoneManySteps.ChunkPose(beltKeyB, i);
// Float summation of 60 steps of 1/60 drifts from 1.0 by a few ULPs; the pose is a pure function
// of that (nearly identical) time, not of how many ticks produced it, so a loose position epsilon
// catches a cadence-dependent implementation without chasing float noise.
AssertClose(a.x, b.x, 0.05f, $"asteroid {i} x");
AssertClose(a.y, b.y, 0.05f, $"asteroid {i} y");
AssertClose(a.z, b.z, 0.01f, $"asteroid {i} rotation");
AssertClose(a.w, b.w, 0.0001f, $"asteroid {i} size");
}
}
// Cut 2 (docs/mining-cut.md) moved wear off AsteroidBelt and onto Zone, so this now pins only the two pure
// size formulas AsteroidBelt still owns (undamaged, and damaged-given-a-damage-value). Whether a chunk is
// broken, and what its size reads as when broken, is Zone's decision -- see MiningCut2Tests
// (BrokenChunkHasNoRadius, DamagedChunkShrinksTowardBroken).
[Fact]
public void UndamagedAndDamagedSizeAreThePureCurves()
{
var (zone, beltKey, asteroids, settings) = BuildFixture();
var belt = zone.AsteroidBelts[beltKey];
const int undamagedIndex = 5;
const int damagedIndex = 9;
var undamagedExpected = settings.AsteroidSize.Evaluate(asteroids[undamagedIndex].Size);
Assert.Equal(undamagedExpected, belt.UndamagedSize(undamagedIndex, settings), 4);
var hp = settings.AsteroidHitpoints.Evaluate(asteroids[damagedIndex].Size);
var damage = hp * 0.6f;
var damagedExpected = settings.AsteroidSize.Evaluate((hp - damage) / hp * asteroids[damagedIndex].Size);
var damagedActual = belt.DamagedSize(damagedIndex, damage, settings);
Assert.Equal(damagedExpected, damagedActual, 4);
Assert.True(damagedActual < settings.AsteroidSize.Evaluate(asteroids[damagedIndex].Size),
"a damaged asteroid must be smaller than its undamaged size");
}
// Stryker gap (2026-09-25 Stryker pass on this cut): nothing called Zone.EvaluateBelt or
// AsteroidBelt.Evaluate, the renderer's own path (ZoneRenderer.cs:452-478), leaving both at NoCoverage.
// This does not re-derive the orbit formula (that is ChunkPoseIsTheOrbitAtZoneTime's job); it pins that the
// batch path is the same function as the per-index one, per index, over every asteroid -- so the renderer's
// buffer can never quietly diverge into a second copy of the pose formula.
[Fact]
public void EvaluateBeltFillsEveryAsteroidExactlyAsChunkPoseWould()
{
var (zone, beltKey, asteroids, _) = BuildFixture();
zone.Update(0.37f);
var buffer = new float4[asteroids.Length];
zone.EvaluateBelt(beltKey, buffer, zone.ExactTime);
Assert.Equal(asteroids.Length, buffer.Length);
for (var i = 0; i < asteroids.Length; i++)
{
var expected = zone.ChunkPose(beltKey, i);
Assert.Equal(expected.x, buffer[i].x, 4);
Assert.Equal(expected.y, buffer[i].y, 4);
Assert.Equal(expected.z, buffer[i].z, 4);
Assert.Equal(expected.w, buffer[i].w, 4);
}
}
// Stryker gap: AsteroidBelt.Radius (Zone.cs, AsteroidBelt constructor) had no assertion, so Max survived a
// Min mutation. The renderer's visibility bounds (ZoneRenderer.cs:452) and minimap mesh (:663) both use it
// as the belt's farthest asteroid; a Min there would cull or mis-bound the belt.
[Fact]
public void BeltRadiusIsTheFarthestAsteroidsDistance()
{
var (zone, beltKey, asteroids, _) = BuildFixture();
var expectedRadius = float.MinValue;
foreach (var a in asteroids)
if (a.Distance > expectedRadius) expectedRadius = a.Distance;
Assert.Equal(expectedRadius, zone.AsteroidBelts[beltKey].Radius, 4);
}
}