From 0f26c4bb35d088ff3b81a85d94c0f05b2764592f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 17:20:52 +0200 Subject: [PATCH 1/3] Suns never take a green hue: draw sun hues from the circle minus a Settings band Primary hue maps through ColorMath.HueOutsideBand; the secondary takes the out-of-band side; fog tint and light derive from primary. Band edges are SunHueExcludedStart/End (75 and 165 degrees by default). Not yet verified on Yggdrasil at this commit. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ColorMath.cs | 6 + Assets/Scripts/ServerShared/Settings.cs | 3 + Assets/Scripts/ServerShared/ZoneGenerator.cs | 7 +- .../SunsNeverGreenTests.cs | 142 ++++++++++++++++++ 4 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs diff --git a/Assets/Scripts/ServerShared/ColorMath.cs b/Assets/Scripts/ServerShared/ColorMath.cs index b94764bd3..26ea704a7 100644 --- a/Assets/Scripts/ServerShared/ColorMath.cs +++ b/Assets/Scripts/ServerShared/ColorMath.cs @@ -18,6 +18,12 @@ public static class ColorMath return saturate(float3(r,g,b)); } + // A hue band on the circle runs from start up to end, wrapping past 1 when end < start. + public static bool HueInBand(float hue, float start, float end) => frac(hue - start) < frac(end - start); + + // Maps u in [0,1) onto the hues outside the band, so a uniform draw stays uniform over the allowed arc. + public static float HueOutsideBand(float u, float start, float end) => frac(end + u * (1 - frac(end - start))); + public static float3 HsvToRgb(in float3 hsv) { float3 rgb = HueToRgb(hsv.x); diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index cda5e1623..af22d2840 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -142,6 +142,9 @@ public class ZoneGenerationSettings public float SunSecondaryColorDistance = .25f; public float SunLightSaturation = .5f; public float SunFogTintSaturation = .5f; + // The green band no sun colour may take, as fractions of the hue circle (75 to 165 degrees: chartreuse through spring green). + public float SunHueExcludedStart = 75f / 360f; + public float SunHueExcludedEnd = 165f / 360f; public ExponentialLerp GasGiantBandCount; public float GasGiantBandColorSeparation = .25f; diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 202e5e4e9..12a8810e9 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -159,8 +159,11 @@ public static class ZoneGenerator { if (gas is SunData sun) { - float primary = random.NextFloat(); - float secondary = frac(primary + 1 + zoneSettings.SunSecondaryColorDistance * (random.NextFloat() > .5 ? 1 : -1)); + float primary = ColorMath.HueOutsideBand(random.NextFloat(), zoneSettings.SunHueExcludedStart, zoneSettings.SunHueExcludedEnd); + float step = zoneSettings.SunSecondaryColorDistance * (random.NextFloat() > .5 ? 1 : -1); + float secondary = frac(primary + 1 + step); + if (ColorMath.HueInBand(secondary, zoneSettings.SunHueExcludedStart, zoneSettings.SunHueExcludedEnd)) + secondary = frac(primary + 1 - step); gas.Colors = new [] { float4(ColorMath.HsvToRgb(float3(primary, zoneSettings.SunColorSaturation, .5f)), 0), diff --git a/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs b/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs new file mode 100644 index 000000000..e94deab92 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs @@ -0,0 +1,142 @@ +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 _caches = new List(); + + 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)); + } + + [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)); + } + + private static IEnumerable 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 GenerateSuns(Action adjust, out int zones) + { + var suns = new List(); + 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"); + var galaxy = new Galaxy(tutorial, authored.Read("TutorialBackgroundSettings"), + authored.Read("NameGeneratorSettings"), cache, new PlayerSettings(), + Directory.CreateDirectory(Path.Combine(dir, "Narrative")), _ => { }, null, seed); + var items = new ItemManager(cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); + var zoneSettings = authored.Read("ZoneSettings"); + adjust(zoneSettings); + foreach (var zone in galaxy.Zones) + { + zones++; + var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, zone, false, false); + suns.AddRange(pack.Planets.Select(p => cache.Get(p)).OfType()); + } + } + return suns; + } +} From 5b1bfe6cec9c7045614fad028f336c6e7bf34e56 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 17:38:37 +0200 Subject: [PATCH 2/3] ColorMath.RgbToHcv: read green-dominant colours with the right channel order The else branch swapped y and z, so the hue read back from many colours was wrong (values above 1). Nothing else consumed it; the sun-hue test reads hues back from RGB. Builds; first run of SunsNeverGreenTests failed on this. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ColorMath.cs | 2 +- tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/ColorMath.cs b/Assets/Scripts/ServerShared/ColorMath.cs index 26ea704a7..f91ab9ad0 100644 --- a/Assets/Scripts/ServerShared/ColorMath.cs +++ b/Assets/Scripts/ServerShared/ColorMath.cs @@ -41,7 +41,7 @@ public static class ColorMath public static float3 RgbToHcv(in float3 rgb) { // Based on work by Sam Hocevar and Emil Persson - float4 p = (rgb.y < rgb.z) ? float4(rgb.zy, -1.0f, 2.0f/3.0f) : float4(rgb.zy, 0.0f, -1.0f/3.0f); + float4 p = (rgb.y < rgb.z) ? float4(rgb.zy, -1.0f, 2.0f/3.0f) : float4(rgb.yz, 0.0f, -1.0f/3.0f); float4 q = (rgb.x < p.x) ? float4(p.xyw, rgb.x) : float4(rgb.x, p.yzx); float c = q.x - min(q.w, q.y); float h = abs((q.w - q.y) / (6 * c + Epsilon) + q.z); diff --git a/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs b/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs index e94deab92..fc42536cc 100644 --- a/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs +++ b/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs @@ -53,6 +53,18 @@ public sealed class SunsNeverGreenTests : IDisposable 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() { From 4930ecd06a772a275223d342e8ab59b636dc3ca5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 19:14:15 +0200 Subject: [PATCH 3/3] Suns: secondary hue stays on the allowed arc with the primary; ramp never crosses the band The secondary is placed along the allowed arc (reflected at its ends, distance capped at half the arc) so the RGB blend between the two stops stays outside the band for any Settings band. Tests sample the production ramp and pin draw uniformity. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ColorMath.cs | 19 +++++++- Assets/Scripts/ServerShared/ZoneGenerator.cs | 9 ++-- .../SunsNeverGreenTests.cs | 46 +++++++++++++++++++ 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/ColorMath.cs b/Assets/Scripts/ServerShared/ColorMath.cs index f91ab9ad0..3814272cd 100644 --- a/Assets/Scripts/ServerShared/ColorMath.cs +++ b/Assets/Scripts/ServerShared/ColorMath.cs @@ -22,7 +22,24 @@ public static class ColorMath public static bool HueInBand(float hue, float start, float end) => frac(hue - start) < frac(end - start); // Maps u in [0,1) onto the hues outside the band, so a uniform draw stays uniform over the allowed arc. - public static float HueOutsideBand(float u, float start, float end) => frac(end + u * (1 - frac(end - start))); + public static float HueOutsideBand(float u, float start, float end) => frac(end + u * AllowedArc(start, end)); + + static float AllowedArc(float start, float end) => 1 - frac(end - start); + + // The second hue of a pair whose first is HueOutsideBand(u): distance away along the allowed arc, on the side given by up, + // reflected to the other side if that would leave the arc. Both hues lie on one arc, so the straight RGB blend between + // the two stops never crosses the band. The distance is capped at half the arc, so the reflection always fits and a + // wide band shortens the distance instead of leaving the secondary inside it. + public static float SecondaryHueOutsideBand(float u, bool up, float distance, float start, float end) + { + float arc = AllowedArc(start, end); + float d = min(distance, arc / 2); + float t = u * arc; + float s = up ? t + d : t - d; + if (s < 0 || s > arc) + s = up ? t - d : t + d; + return frac(end + s); + } public static float3 HsvToRgb(in float3 hsv) { diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 12a8810e9..c6ec75375 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -159,11 +159,10 @@ public static class ZoneGenerator { if (gas is SunData sun) { - float primary = ColorMath.HueOutsideBand(random.NextFloat(), zoneSettings.SunHueExcludedStart, zoneSettings.SunHueExcludedEnd); - float step = zoneSettings.SunSecondaryColorDistance * (random.NextFloat() > .5 ? 1 : -1); - float secondary = frac(primary + 1 + step); - if (ColorMath.HueInBand(secondary, zoneSettings.SunHueExcludedStart, zoneSettings.SunHueExcludedEnd)) - secondary = frac(primary + 1 - step); + var hueDraw = random.NextFloat(); + float primary = ColorMath.HueOutsideBand(hueDraw, zoneSettings.SunHueExcludedStart, zoneSettings.SunHueExcludedEnd); + float secondary = ColorMath.SecondaryHueOutsideBand(hueDraw, random.NextFloat() > .5, zoneSettings.SunSecondaryColorDistance, + zoneSettings.SunHueExcludedStart, zoneSettings.SunHueExcludedEnd); gas.Colors = new [] { float4(ColorMath.HsvToRgb(float3(primary, zoneSettings.SunColorSaturation, .5f)), 0), diff --git a/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs b/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs index fc42536cc..e6f5314ef 100644 --- a/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs +++ b/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs @@ -113,6 +113,52 @@ public sealed class SunsNeverGreenTests : IDisposable 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 Hues(SunData sun) => sun.Colors.Select(c => c.xyz) .Concat(new[] { sun.FogTintColor, sun.LightColor })