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

200 lines
8.8 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using CultMath;
using GameCult.Caching;
using Xunit;
// Operator rulings suns-never-green and suns-never-green-edges: no generated sun colour (the body's two colour stops, its
// fog tint and its light colour) takes a hue where green dominates. The band is the Settings default, 75 to 165 degrees;
// yellow (60) and teal (170) stay allowed. Hues are recovered from the RGB the production generator wrote.
public sealed class SunsNeverGreenTests : IDisposable
{
private const float Degree = 1f / 360f;
// Hue recovered from a float RGB wobbles by a few millionths of a turn; the band is judged with this much margin.
private const float Slack = 1e-3f;
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-sunsgreen-" + Guid.NewGuid().ToString("N"));
private int _runs;
private readonly List<CultCache> _caches = new List<CultCache>();
public SunsNeverGreenTests() => Directory.CreateDirectory(_root);
public void Dispose()
{
foreach (var cache in _caches) cache.Dispose();
Directory.Delete(_root, true);
}
[Theory]
[InlineData(76f)]
[InlineData(80f)]
[InlineData(120f)]
[InlineData(160f)]
[InlineData(164f)]
public void HuesInsideTheDefaultBandAreRefused(float degrees)
{
var settings = new ZoneGenerationSettings();
Assert.True(ColorMath.HueInBand(degrees * Degree, settings.SunHueExcludedStart, settings.SunHueExcludedEnd));
}
[Theory]
[InlineData(0f)]
[InlineData(60f)]
[InlineData(74f)]
[InlineData(166f)]
[InlineData(170f)]
[InlineData(180f)]
[InlineData(300f)]
public void YellowTealAndTheRestOfTheCircleAreAllowed(float degrees)
{
var settings = new ZoneGenerationSettings();
Assert.False(ColorMath.HueInBand(degrees * Degree, settings.SunHueExcludedStart, settings.SunHueExcludedEnd));
}
// The hues below are read back from RGB the generator wrote, so the reader must invert the writer.
[Fact]
public void ReadingHueBackFromRgbInvertsWritingIt()
{
for (var degrees = 5; degrees < 360; degrees += 5)
foreach (var value in new[] { .5f, 1f })
{
var rgb = ColorMath.HsvToRgb(new float3(degrees * Degree, .75f, value));
Assert.Equal(degrees * Degree, ColorMath.RgbToHsv(rgb).x, 4);
}
}
[Fact]
public void TheDrawMapsOntoTheWholeAllowedArcAndNothingElse()
{
var settings = new ZoneGenerationSettings();
var start = settings.SunHueExcludedStart;
var end = settings.SunHueExcludedEnd;
var low = float.MaxValue;
var high = float.MinValue;
for (var i = 0; i < 1000; i++)
{
var hue = ColorMath.HueOutsideBand(i / 1000f, start, end);
Assert.False(ColorMath.HueInBand(hue, start, end), $"draw {i}/1000 landed in the band");
// Unwrapped from the band's far edge, the allowed arc runs 165 up to 75 + 360.
var along = ColorMath.HueInBand(hue, 0f, end) ? hue + 1f : hue;
low = Math.Min(low, along);
high = Math.Max(high, along);
}
Assert.InRange(low, end, end + 1f * Degree);
Assert.InRange(high, 1f + start - 1f * Degree, 1f + start);
}
[Fact]
public void GeneratedSunsKeepEveryColourOutOfTheDefaultBand()
{
var settings = new ZoneGenerationSettings();
var suns = GenerateSuns(z => { }, out _);
Assert.True(suns.Count >= 10, $"only {suns.Count} suns generated");
foreach (var sun in suns)
foreach (var hue in Hues(sun))
Assert.False(ColorMath.HueInBand(hue, settings.SunHueExcludedStart + Slack, settings.SunHueExcludedEnd - Slack),
$"a sun colour fell in the green band (hue {hue / Degree:F1} degrees)");
}
[Fact]
public void TheBandComesFromSettingsNotFromTheGenerator()
{
// A blue band, 200 to 300 degrees: suns avoid it and now take green freely.
var suns = GenerateSuns(z =>
{
z.SunHueExcludedStart = 200f * Degree;
z.SunHueExcludedEnd = 300f * Degree;
}, out _);
Assert.True(suns.Count >= 10, $"only {suns.Count} suns generated");
var hues = suns.SelectMany(Hues).ToList();
Assert.All(hues, hue => Assert.False(ColorMath.HueInBand(hue, 200f * Degree + Slack, 300f * Degree - Slack)));
Assert.Contains(hues, hue => ColorMath.HueInBand(hue, 75f * Degree, 165f * Degree));
}
[Fact]
public void TheDrawIsUniformOverTheAllowedArc()
{
var settings = new ZoneGenerationSettings();
const int draws = 1000, bins = 10;
var counts = new int[bins];
var arc = 1f - (settings.SunHueExcludedEnd - settings.SunHueExcludedStart);
for (var i = 0; i < draws; i++)
{
var hue = ColorMath.HueOutsideBand((i + .5f) / draws, settings.SunHueExcludedStart, settings.SunHueExcludedEnd);
var along = hue - settings.SunHueExcludedEnd;
if (along < 0) along += 1f;
counts[Math.Min(bins - 1, (int)(along / arc * bins))]++;
}
Assert.All(counts, count => Assert.InRange(count, draws / bins - 1, draws / bins + 1));
}
// The body blends its two stops in RGB (Gradient Blend), so the blend is sampled, not just the stops.
[Theory]
[InlineData(75f, 165f)]
[InlineData(55f, 185f)]
[InlineData(20f, 250f)]
public void TheRampBetweenTheStopsNeverCrossesTheBand(float startDegrees, float endDegrees)
{
var suns = GenerateSuns(z =>
{
z.SunHueExcludedStart = startDegrees * Degree;
z.SunHueExcludedEnd = endDegrees * Degree;
}, out _);
Assert.True(suns.Count >= 10, $"only {suns.Count} suns generated");
foreach (var sun in suns)
{
var a = sun.Colors[0].xyz;
var b = sun.Colors[1].xyz;
for (var step = 0; step <= 40; step++)
{
var hue = ColorMath.RgbToHsv(a + (b - a) * (step / 40f)).x;
Assert.False(ColorMath.HueInBand(hue, startDegrees * Degree + Slack, endDegrees * Degree - Slack),
$"the ramp passed through the band (hue {hue / Degree:F1} degrees, step {step}/40)");
}
foreach (var colourHue in Hues(sun))
Assert.False(ColorMath.HueInBand(colourHue, startDegrees * Degree + Slack, endDegrees * Degree - Slack),
$"a sun colour fell in the band (hue {colourHue / Degree:F1} degrees)");
}
}
private static IEnumerable<float> Hues(SunData sun) =>
sun.Colors.Select(c => c.xyz)
.Concat(new[] { sun.FogTintColor, sun.LightColor })
.Select(rgb => ColorMath.RgbToHsv(rgb).x);
// Production path: Galaxy, ItemManager and ZoneGenerator.GenerateZone over the shipped ZoneSettings, as in OrbitsFromFieldTests.
private List<SunData> GenerateSuns(Action<ZoneGenerationSettings> adjust, out int zones)
{
var suns = new List<SunData>();
zones = 0;
_runs++;
for (uint seed = 1; seed <= 8; seed++)
{
var dir = Path.Combine(_root, $"galaxy{_runs}-{seed}");
Directory.CreateDirectory(dir);
var catalog = Path.Combine(dir, "Aetheria.cc");
File.Copy(TestCatalog.Repo, catalog);
var cache = new CultCache(TestCatalog.Registry());
_caches.Add(cache);
cache.AddBackingStore(new GameCult.Caching.MessagePack.SingleFileMessagePackBackingStore(catalog), AetheriaStores.CatalogTypes);
cache.AddBackingStore(new GameCult.Caching.MessagePack.SingleFileMessagePackBackingStore(Path.Combine(dir, "run.cc")), AetheriaStores.RunTypes);
var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot());
var tutorial = authored.Read<TutorialGenerationSettings>("TutorialGenerationSettings");
var galaxy = new Galaxy(tutorial, authored.Read<SectorBackgroundSettings>("TutorialBackgroundSettings"),
cache, new PlayerSettings(),
Directory.CreateDirectory(Path.Combine(dir, "Narrative")), _ => { }, null, seed);
var items = new ItemManager(1u, cache, new ProvenanceLedger(), authored.Read<GameplaySettings>("GameplaySettings"), _ => { });
var zoneSettings = authored.Read<ZoneGenerationSettings>("ZoneSettings");
adjust(zoneSettings);
foreach (var zone in galaxy.Zones)
{
zones++;
var pack = ZoneGenerator.GenerateZone(items, zoneSettings, RestoredHullsTests.AuthoredPlanetSettings(), galaxy, zone, false, false);
suns.AddRange(pack.Planets.Select(p => cache.Get(p)).OfType<SunData>());
}
}
return suns;
}
}