Aetheria/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs
Meta Vens 84337de569 ZoneGenerator.Place owns every generated distance, fold and belt width
PlanetSafetyRadius, ChildDistanceMinimum/Maximum, ExpandSolar's distance series
and the window filters are deleted. Place (ported from replace.py, root window
from rootReach per root-window-floor) lays children inside-out, folds the
lightest family while the outermost overflows, stretches to the window, merges
rings that cannot keep their heavy member outside its Roche margin and sets
belt half widths. Stations pick well-bearing, sibling-clear off-ring bodies;
the tutorial entrance falls back to an orbit round a root node; turrets stop a
side when within the base body's extent. GenerateZone/GenerateArena take
PlanetSettings. Not yet built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 04:55:57 +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"),
authored.Read<NameGeneratorSettings>("NameGeneratorSettings"), cache, new PlayerSettings(),
Directory.CreateDirectory(Path.Combine(dir, "Narrative")), _ => { }, null, seed);
var items = new ItemManager(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;
}
}