Aetheria/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs
2026-10-17 05:56:58 +02:00

342 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using CultMath;
using GameCult.Caching;
using Xunit;
using Xunit.Abstractions;
using static CultMath.math;
using float2 = CultMath.float2;
// Aetheria gravity, children within reach: the production generator's zones (every zone of seeds 1-6, ambient off) read back
// through the Zone's own tree, orbits and bodies. Each expectation is spelled from the rule it names and the law's constants
// (GravityField), never from ZoneGenerator.Place's helpers.
public sealed class ChildrenWithinReachTests : IClassFixture<ChildrenWithinReachTests.Zones>
{
public sealed class Zones : IDisposable
{
private readonly ZoneWorlds _worlds = new ZoneWorlds();
public readonly List<(uint Seed, Zone Zone)> All = new List<(uint, Zone)>();
public Zones()
{
for (uint seed = 1; seed <= 6; seed++)
{
var dir = Path.Combine(_worlds.Root, $"galaxy{seed}");
Directory.CreateDirectory(dir);
var catalog = Path.Combine(dir, "Aetheria.cc");
File.Copy(TestCatalog.Repo, catalog);
var cache = new CultCache(TestCatalog.Registry());
_worlds.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 galaxy = new Galaxy(authored.Read<TutorialGenerationSettings>("TutorialGenerationSettings"),
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 planetSettings = authored.Read<PlanetSettings>("PlanetSettings");
foreach (var galaxyZone in galaxy.Zones)
{
var pack = ZoneGenerator.GenerateZone(items, authored.Read<ZoneGenerationSettings>("ZoneSettings"), planetSettings, galaxy, galaxyZone,
galaxyZone == galaxy.Entrance && galaxy.IsPrelude, false);
All.Add((seed, new Zone(items, planetSettings, pack, galaxyZone, galaxy)));
}
}
}
public void Dispose() => _worlds.Dispose();
}
private readonly Zones _zones;
private readonly ITestOutputHelper _out;
public ChildrenWithinReachTests(Zones zones, ITestOutputHelper output)
{
_zones = zones;
_out = output;
}
private const float Tolerance = 1e-3f;
// A child's own family folded into it after it was placed raises its mass, and the cusp bound grows as mass^.05.
private const float CuspTolerance = 1e-2f;
private const float RocheMargin = 1.5f, CuspMargin = 1.5f; // the rulings tail-taming-law-r3-band-loose-cusp and c4-proceed-and-fly
private static float Roche(Zone zone, float mass) => zone.Settings.RocheLimit.Evaluate(mass);
private static float Strength(Zone zone, float mass) => zone.Settings.WellStrength.Evaluate(mass);
// Orbits that carry a well-bearing body, with its mass; belts and bodyless nodes carry none.
private static Dictionary<CultRecordKey, float> BodyMass(Zone zone) =>
zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).GroupBy(b => b.Orbit.Key).ToDictionary(g => g.Key, g => g.First().Mass);
// The generated orbits: every body's and its ancestors', which leaves out stations, turrets and Lagrange copies.
private static HashSet<CultRecordKey> Placed(Zone zone)
{
var placed = new HashSet<CultRecordKey>();
foreach (var body in zone.Planets.Values)
for (var key = body.Orbit.Key; placed.Add(key) && zone.Orbits[key].Data.Parent.IsSet();) key = zone.Orbits[key].Data.Parent.Key;
return placed;
}
private static List<CultRecordKey> ChildrenOf(Zone zone, CultRecordKey parent, HashSet<CultRecordKey> placed) =>
placed.Where(k => zone.Orbits[k].Data.Parent.IsSet() && zone.Orbits[k].Data.Parent.Key.Equals(parent))
.OrderBy(k => zone.Orbits[k].Data.Distance).ToList();
private static float DistanceOf(Zone zone, CultRecordKey orbit) => zone.Orbits[orbit].Data.Distance;
private static List<CultRecordKey> MassiveRoots(Zone zone) =>
zone.Orbits.Keys.Where(k => !zone.Orbits[k].Data.Parent.IsSet() && zone.Wells.Reach(k) > 0f).ToList();
private static float2 Anchor(Zone zone, CultRecordKey root) => zone.Orbits[root].Data.FixedPosition;
private static IEnumerable<CultRecordKey> Descendants(Zone zone, CultRecordKey root, HashSet<CultRecordKey> placed)
{
foreach (var child in ChildrenOf(zone, root, placed))
{
yield return child;
foreach (var below in Descendants(zone, child, placed)) yield return below;
}
}
[Fact]
public void SubzonesAreIsolated()
{
var zonesChecked = 0;
var pairs = 0;
var violatingPairs = 0;
var orbits = 0;
var violatingOrbits = 0;
foreach (var (seed, zone) in _zones.All)
{
var roots = MassiveRoots(zone);
if (roots.Count < 2) continue;
zonesChecked++;
var placed = Placed(zone);
foreach (var i in roots)
{
var nearest = roots.Where(j => !j.Equals(i)).Min(j => length(Anchor(zone, i) - Anchor(zone, j)));
Assert.True(Math.Abs(zone.Wells.Reach(i) - GravityField.Reach * nearest) <= Tolerance * nearest,
$"seed {seed}: a root's reach {zone.Wells.Reach(i)} is not Reach x the nearest root's distance {nearest} (its floor binds)");
foreach (var j in roots.Where(j => !j.Equals(i)))
{
pairs++;
if (zone.Wells.Reach(i) + zone.Wells.Support(j) > length(Anchor(zone, i) - Anchor(zone, j)) * (1 + Tolerance)) violatingPairs++;
foreach (var orbit in Descendants(zone, i, placed))
{
orbits++;
if (length(zone.OrbitPositionAt(orbit, 0) - Anchor(zone, j)) < zone.Wells.Reach(j) * (1 - Tolerance)) violatingOrbits++;
}
}
}
}
_out.WriteLine($"subzones isolated: {zonesChecked} multi-root zones, {pairs} ordered root pairs ({violatingPairs} with reach_i + support_j > d), " +
$"{orbits} orbit-vs-root checks ({violatingOrbits} inside another root's reach)");
Assert.True(zonesChecked > 0 && pairs > 0 && orbits > 0, "the seeds generated no zone with two massive roots");
Assert.True(violatingPairs == 0, $"{violatingPairs} of {pairs} ordered root pairs overlap");
Assert.True(violatingOrbits == 0, $"{violatingOrbits} of {orbits} orbits sit inside another root's reach");
}
[Fact]
public void NoChildSitsInsideItsParentsRocheMargin()
{
var children = 0;
foreach (var (seed, zone) in _zones.All)
{
var mass = BodyMass(zone);
var placed = Placed(zone);
foreach (var parent in placed.Where(mass.ContainsKey))
foreach (var child in ChildrenOf(zone, parent, placed))
{
children++;
var margin = RocheMargin * Roche(zone, mass[parent]);
Assert.True(DistanceOf(zone, child) >= margin * (1 - Tolerance), $"seed {seed}: a child sits at {DistanceOf(zone, child)}, inside {margin}");
}
}
Assert.True(children > 100, $"only {children} children checked");
}
[Fact]
public void EveryMassiveChildKeepsACusp()
{
var checkedChildren = 0;
foreach (var (seed, zone) in _zones.All)
{
var mass = BodyMass(zone);
var placed = Placed(zone);
foreach (var parent in placed.Where(mass.ContainsKey))
foreach (var child in ChildrenOf(zone, parent, placed).Where(mass.ContainsKey))
{
checkedChildren++;
var cusp = 2 * CuspMargin * Roche(zone, mass[child]) * Strength(zone, mass[parent]) / Strength(zone, mass[child]);
Assert.True(DistanceOf(zone, child) >= cusp * (1 - CuspTolerance), $"seed {seed}: a massive child sits at {DistanceOf(zone, child)}, inside its cusp bound {cusp}");
}
}
Assert.True(checkedChildren > 50, $"only {checkedChildren} massive children checked");
}
[Fact]
public void AFamilyEndsBeforeItsParentsFade()
{
var families = 0;
foreach (var (seed, zone) in _zones.All)
{
var placed = Placed(zone);
var mass = BodyMass(zone);
foreach (var node in placed.Where(k => zone.Orbits[k].Data.Parent.IsSet() && zone.Wells.Reach(k) > 0f))
{
var kids = ChildrenOf(zone, node, placed);
if (!kids.Any(k => zone.Wells.Reach(k) > 0f)) continue;
families++;
Assert.True(zone.Wells.Support(node) <= GravityField.Gamma * zone.Wells.Reach(node) * (1 + Tolerance),
$"seed {seed}: a family's support {zone.Wells.Support(node)} runs past Gamma x its reach {zone.Wells.Reach(node)}");
var parent = zone.Orbits[node].Data.Parent.Key;
if (mass.ContainsKey(parent)) // a ring member's reach is RingReach x D; a body's child's is Reach x D
Assert.True(Math.Abs(zone.Wells.Reach(node) - GravityField.Reach * DistanceOf(zone, node)) <= Tolerance * DistanceOf(zone, node),
$"seed {seed}: a family's reach {zone.Wells.Reach(node)} is not Reach x its distance {DistanceOf(zone, node)}");
}
}
Assert.True(families > 20, $"only {families} families checked");
}
[Fact]
public void SiblingsSitHalfAReachApart()
{
var q = 1 / (1 - GravityField.Reach / 2);
var pairs = 0;
foreach (var (seed, zone) in _zones.All)
{
var mass = BodyMass(zone);
var placed = Placed(zone);
foreach (var parent in placed.Where(mass.ContainsKey))
{
var kids = ChildrenOf(zone, parent, placed);
for (var k = 1; k < kids.Count; k++)
{
pairs++;
Assert.True(DistanceOf(zone, kids[k]) >= DistanceOf(zone, kids[k - 1]) * q * (1 - Tolerance),
$"seed {seed}: siblings at {DistanceOf(zone, kids[k - 1])} and {DistanceOf(zone, kids[k])} are closer than a factor {q}");
}
}
}
Assert.True(pairs > 100, $"only {pairs} sibling pairs checked");
}
// The mass split is renormalised so a family's masses sum to its root's; folding moves a family's mass into its parent body.
// A tree with a rosette or binary holds a bodyless node whose own mass is renormalised with the rest, so it is left out.
[Fact]
public void FoldingConservesMass()
{
var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot());
var zoneSettings = authored.Read<ZoneGenerationSettings>("ZoneSettings");
var planetSettings = authored.Read<PlanetSettings>("PlanetSettings");
zoneSettings.RosetteProbability = 0; // a rosette or binary holds a bodyless node
zoneSettings.BinaryCreationProbability = 0;
const float mass = 300000f;
var trees = 0;
var folded = 0;
for (uint seed = 1; seed <= 200; seed++)
{
// The same random stream makes the same tree; only the window differs, so a smaller one folds more of it.
var tight = new CultMath.Random(seed);
var tightTree = ZoneGenerator.GenerateEntities(zoneSettings, planetSettings, ref tight, mass, 1500f, float2.zero, float.PositiveInfinity);
var wide = new CultMath.Random(seed);
var wideTree = ZoneGenerator.GenerateEntities(zoneSettings, planetSettings, ref wide, mass, 1e7f, float2.zero, float.PositiveInfinity);
trees++;
if (tightTree.Length < wideTree.Length) folded++;
Assert.True(Math.Abs(tightTree.Sum(p => p.Mass) - mass) <= 1e-3f * mass, $"seed {seed}: a folded tree's bodies sum to {tightTree.Sum(p => p.Mass)}, not {mass}");
}
_out.WriteLine($"folding: {trees} trees, {folded} with a fold");
Assert.True(trees > 20 && folded > 10, $"{trees} trees, {folded} with a fold");
}
[Fact]
public void RingsKeepTheirMembersApartAndCircumbinaryChildrenFar()
{
var rings = 0;
var outer = 0;
foreach (var (seed, zone) in _zones.All)
{
var mass = BodyMass(zone);
var placed = Placed(zone);
foreach (var node in placed.Where(k => !mass.ContainsKey(k) && !zone.Planets.Values.Any(b => b.Orbit.Key.Equals(k))))
{
var kids = ChildrenOf(zone, node, placed);
var members = kids.Where(mass.ContainsKey).ToList();
if (members.Count < 2) continue;
var ring = members.Where(k => DistanceOf(zone, k) - DistanceOf(zone, members[0]) <= WellTree.RingWindow).ToList();
if (ring.Count < 2) continue;
rings++;
var radius = DistanceOf(zone, ring[0]);
var heavy = ring.Max(k => mass[k]);
Assert.True(radius >= RocheMargin * Roche(zone, heavy) * (1 - Tolerance),
$"seed {seed}: a ring of radius {radius} sits inside its heavy member's Roche margin");
foreach (var far in kids.Where(k => !ring.Contains(k)))
{
outer++;
Assert.True(DistanceOf(zone, far) >= (1 + GravityField.RingReach) * GravityField.Beta * radius * (1 - Tolerance),
$"seed {seed}: a circumbinary child at {DistanceOf(zone, far)} is inside the ring group of radius {radius}");
}
}
}
Assert.True(rings > 3, $"only {rings} rings checked");
_out.WriteLine($"rings {rings}, circumbinary children {outer}");
}
[Fact]
public void BeltsKeepTheirEdges()
{
var belts = 0;
foreach (var (seed, zone) in _zones.All)
{
var mass = BodyMass(zone);
foreach (var (beltKey, belt) in zone.AsteroidBelts)
{
belts++;
var orbit = zone.Planets[beltKey].Orbit.Key;
var parent = zone.Orbits[orbit].Data.Parent.Key;
var inner = belt.Data.Asteroids.Min(a => a.Distance);
var outermost = belt.Data.Asteroids.Max(a => a.Distance);
Assert.True(inner > 0f, $"seed {seed}: an asteroid sits at {inner}");
if (mass.TryGetValue(parent, out var m))
Assert.True(inner >= RocheMargin * Roche(zone, m) * (1 - Tolerance), $"seed {seed}: a belt's inner edge {inner} is inside its parent's Roche margin");
// The belt keeps clear of its neighbours' rails by a sibling spacing (rule 3), inner and outer.
var sibling = ChildrenOf(zone, parent, Placed(zone));
var at = sibling.IndexOf(orbit);
if (at > 0)
Assert.True(inner >= DistanceOf(zone, sibling[at - 1]) * (1 / (1 - GravityField.Reach / 2)) * (1 - Tolerance), $"seed {seed}: a belt reaches in to {inner}, within a spacing of its inner neighbour");
if (at < sibling.Count - 1)
Assert.True(outermost <= DistanceOf(zone, sibling[at + 1]) * (1 - GravityField.Reach / 2) * (1 + Tolerance), $"seed {seed}: a belt reaches out to {outermost}, within a spacing of its outer neighbour");
if (zone.Orbits[parent].Data.Parent.IsSet())
Assert.True(outermost <= GravityField.Gamma * GravityField.Reach * DistanceOf(zone, parent) / Math.Max(GravityField.Beta, 1 + GravityField.Reach) * (1 + Tolerance),
$"seed {seed}: a belt reaches {outermost}, past its parent's window");
}
}
Assert.True(belts > 20, $"only {belts} belts checked");
}
[Fact]
public void NoStrengthlessOrbitRidesARing()
{
var rings = 0;
foreach (var (seed, zone) in _zones.All)
{
var mass = BodyMass(zone);
var placed = Placed(zone);
foreach (var node in placed.Where(k => !mass.ContainsKey(k) && !zone.Planets.Values.Any(b => b.Orbit.Key.Equals(k))))
{
var members = ChildrenOf(zone, node, placed).Where(mass.ContainsKey).ToList();
if (members.Count < 2) continue;
rings++;
var radius = DistanceOf(zone, members[0]);
// Every orbit of the zone, stations and turrets included, that hangs on the node and carries no body.
foreach (var orbit in zone.Orbits.Keys.Where(k => zone.Orbits[k].Data.Parent.IsSet() && zone.Orbits[k].Data.Parent.Key.Equals(node) && !mass.ContainsKey(k)))
Assert.True(DistanceOf(zone, orbit) >= (1 + GravityField.RingReach) * radius * (1 - Tolerance),
$"seed {seed}: a strengthless orbit at {DistanceOf(zone, orbit)} rides the ring group of radius {radius}");
}
}
Assert.True(rings > 3, $"only {rings} rings checked");
}
}