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 { public sealed class Zones : IDisposable { private readonly ZoneWorlds _worlds = new ZoneWorlds(); public readonly List<(uint Seed, Zone Zone)> All = new List<(uint, Zone)>(); public ZoneGenerationSettings Settings; public Zones() : this(1, 6, null) { } // Not public: xunit builds the fixture through its one public constructor; a test that wants a tuned sample builds its own. internal Zones(uint first, uint last, Action tune) { for (uint seed = first; seed <= last; 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"), 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 planetSettings = authored.Read("PlanetSettings"); foreach (var galaxyZone in galaxy.Zones) { Settings = authored.Read("ZoneSettings"); tune?.Invoke(Settings); var pack = ZoneGenerator.GenerateZone(items, Settings, 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 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 Placed(Zone zone) { var placed = new HashSet(); 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 ChildrenOf(Zone zone, CultRecordKey parent, HashSet 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 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 Descendants(Zone zone, CultRecordKey root, HashSet 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 same random stream makes the same tree; only the window differs, so a small one (tight) folds some of what a huge one // (wide) keeps whole. No rosette or binary: a bodyless node's own mass is renormalised with the rest. private static IEnumerable<(uint Seed, GeneratorPlanet[] Tight, GeneratorPlanet[] Wide)> TightAndWideTrees(float mass) { var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot()); var zoneSettings = authored.Read("ZoneSettings"); var planetSettings = authored.Read("PlanetSettings"); zoneSettings.RosetteProbability = 0; zoneSettings.BinaryCreationProbability = 0; for (uint seed = 1; seed <= 200; seed++) { 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); yield return (seed, tightTree, wideTree); } } // Folding moves a family's mass into its parent body, so a tree's masses still sum to its root's. [Fact] public void FoldingConservesMass() { const float mass = 300000f; var folded = 0; foreach (var (seed, tight, wide) in TightAndWideTrees(mass)) { if (tight.Length < wide.Length) folded++; Assert.True(Math.Abs(tight.Sum(p => p.Mass) - mass) <= 1e-3f * mass, $"seed {seed}: a folded tree's bodies sum to {tight.Sum(p => p.Mass)}, not {mass}"); } _out.WriteLine($"folding: {folded} of 200 trees with a fold"); Assert.True(folded > 10, $"only {folded} trees folded"); } // Under one parent the families folded away are never heavier than the ones kept (rule 4: the lightest goes first). [Fact] public void FoldingTakesTheLightestFamily() { float Family(GeneratorPlanet p) => p.Mass + p.Children.Sum(Family); var parentsThatFolded = 0; foreach (var (seed, tight, wide) in TightAndWideTrees(300000f)) { var tightByPhase = tight.ToDictionary(p => p.Phase); void Compare(GeneratorPlanet w) { if (w.Children.Count == 0 || !tightByPhase.TryGetValue(w.Phase, out var t)) return; var keptPhases = t.Children.Select(c => c.Phase).ToHashSet(); var kept = w.Children.Where(c => keptPhases.Contains(c.Phase)).ToList(); var gone = w.Children.Where(c => !keptPhases.Contains(c.Phase)).ToList(); if (gone.Count > 0 && kept.Count > 0) { parentsThatFolded++; Assert.True(gone.Max(Family) <= kept.Min(Family) * (1 + 1e-4f), $"seed {seed}: a folded family outweighs one that was kept"); } foreach (var child in kept) Compare(child); } Compare(wide[0]); } Assert.True(parentsThatFolded > 10, $"only {parentsThatFolded} parents folded part of their children"); } [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"); } private static float Percentile(List sorted, float fraction) => sorted[Math.Min(sorted.Count - 1, (int) (fraction * sorted.Count))]; // A belt fills the slot between its neighbours' spacing, its parent's Roche margin and the parent's window, up to the // authored width (ruling belt-fills-its-slot): it sits at the slot's midpoint where the test can spell both edges, and // none collapses to a line (finding belts-collapse-to-a-line). Rocks are scattered inside the belt's half width, so their // spread is at most the belt's width; a belt beside another belt has an edge only the generator knows, and is checked // against the weaker bound. [Fact] public void BeltsFillTheirSlots() { var q = 1 / (1 - GravityField.Reach / 2); var ratios = new List(); int belts = 0, bounded = 0, midpoints = 0, lines = 0; foreach (var (seed, zone) in _zones.All) { var mass = BodyMass(zone); var placed = Placed(zone); var beltAt = zone.AsteroidBelts.ToDictionary(b => zone.Planets[b.Key].Orbit.Key, b => b.Value.Data.Asteroids); foreach (var (orbit, rocks) in beltAt) { var parent = zone.Orbits[orbit].Data.Parent.Key; if (rocks.Length == 0 || !mass.TryGetValue(parent, out var parentMass)) continue; belts++; var sibling = ChildrenOf(zone, parent, placed); var at = sibling.IndexOf(orbit); var d = DistanceOf(zone, orbit); var spread = rocks.Max(a => a.Distance) - rocks.Min(a => a.Distance); var width = _zones.Settings.AsteroidBeltWidth.Evaluate(d); ratios.Add(spread / width); if (spread < 1e-3f) lines++; var prevIsBelt = at > 0 && beltAt.ContainsKey(sibling[at - 1]); var nextIsBelt = at < sibling.Count - 1 && beltAt.ContainsKey(sibling[at + 1]); var prevEdge = at == 0 ? 0f : prevIsBelt && beltAt[sibling[at - 1]].Length > 0 ? beltAt[sibling[at - 1]].Max(a => a.Distance) : DistanceOf(zone, sibling[at - 1]); var inner = Math.Max(RocheMargin * Roche(zone, parentMass), prevEdge * q); var grand = zone.Orbits[parent].Data.Parent; var window = grand.IsSet() ? GravityField.Gamma * GravityField.Reach * DistanceOf(zone, parent) / Math.Max(GravityField.Beta, 1 + GravityField.Reach) : float.PositiveInfinity; var next = at < sibling.Count - 1 && !nextIsBelt ? DistanceOf(zone, sibling[at + 1]) / q : float.PositiveInfinity; var outerLimit = Math.Min(window, next); if (!nextIsBelt) bounded++; Assert.True(spread <= Math.Min(width, outerLimit - inner) * (1 + Tolerance), $"seed {seed}: a belt spreads {spread} in a slot of {outerLimit - inner} and an authored width of {width}"); // Past the last sibling a root's window is its own to spell, and a ring member's child is held by a chord as well. var upperKnown = at < sibling.Count - 1 || (grand.IsSet() && mass.ContainsKey(grand.Key)); if (prevIsBelt || nextIsBelt || !upperKnown) continue; midpoints++; Assert.True(Math.Abs(d - (inner + outerLimit) / 2) <= Tolerance * d, $"seed {seed}: a belt at {d} is not the midpoint {(inner + outerLimit) / 2} of its slot [{inner}, {outerLimit}]"); } } ratios.Sort(); _out.WriteLine($"belts {belts}, with a slot bound {bounded}, midpoints checked {midpoints}, rock spread under 0.001: {lines}; " + $"spread / authored width at p10/25/50/75/90/max: {string.Join(" / ", new[] { .1f, .25f, .5f, .75f, .9f }.Select(f => Percentile(ratios, f).ToString("F3")))} / {ratios[ratios.Count - 1]:F3}"); Assert.True(belts > 20 && bounded > 0 && midpoints > 0, $"{belts} belts, {bounded} bounded, {midpoints} midpoints: the seeds do not exercise the rule"); Assert.True(lines == 0, $"{lines} of {belts} belts have no radial width"); } // With the authored width narrower than the slot the width is the ceiling and the belt keeps it: neither wider (W/2 -> W, a // deleted cap) nor reduced to the slot. [Fact] public void ANarrowAuthoredBeltKeepsItsWidth() { var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("ZoneSettings").AsteroidBeltWidth; using var narrow = new Zones(1, 3, z => z.AsteroidBeltWidth = new ExponentialCurve { Exponent = authored.Exponent, Multiplier = authored.Multiplier * .02f, Constant = authored.Constant * .02f }); int belts = 0, filled = 0; foreach (var (seed, zone) in narrow.All) foreach (var (key, belt) in zone.AsteroidBelts) { var rocks = belt.Data.Asteroids; if (rocks.Length < 2) continue; var width = narrow.Settings.AsteroidBeltWidth.Evaluate(DistanceOf(zone, zone.Planets[key].Orbit.Key)); var spread = rocks.Max(a => a.Distance) - rocks.Min(a => a.Distance); belts++; Assert.True(spread <= width * (1 + Tolerance), $"seed {seed}: a belt spreads {spread}, past its authored width {width}"); if (spread >= .5f * width) filled++; } _out.WriteLine($"narrow belts {belts}, spreading at least half the authored width {filled}"); Assert.True(filled >= 10, $"only {filled} of {belts} belts fill the authored width"); } private static GeneratorPlanet Body(GeneratorPlanet parent, float mass, float phase = 0, bool ring = false) { var node = new GeneratorPlanet { Parent = parent, Mass = mass, Phase = phase, Ring = ring }; parent.Children.Add(node); return node; } private static PlanetSettings AuthoredPlanetSettings() => AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("PlanetSettings"); // A rosette's ring round a root with no reach limit of its own sits at half the root's 0.75 x radius window, 3/8 of the radius. [Fact] public void ASoleRootRingSitsAtThreeEighthsOfItsRadius() { const float radius = 1e6f; var root = new GeneratorPlanet { Empty = true }; var members = new[] { Body(root, 100f, 0, true), Body(root, 100f, .5f, true) }; ZoneGenerator.Place(root, AuthoredPlanetSettings(), radius, float.PositiveInfinity); Assert.True(root.Empty && root.Children.Count == 2, "the ring folded into its node"); foreach (var member in members) Assert.True(Math.Abs(member.Distance - .375f * radius) <= 1e-4f * radius, $"a ring member sits at {member.Distance}, not {.375f * radius}"); } // A ring member's children end within a fifth of the chord to the member's nearest partner, where the chord, not the member's // fade, is what holds them. [Fact] public void ARingMembersChildrenStayWithinAFifthOfTheChord() { const float radius = 1e6f; const int count = 12; var root = new GeneratorPlanet { Empty = true }; var members = Enumerable.Range(0, count).Select(i => Body(root, 100f, (float) i / count, true)).ToList(); foreach (var member in members) { Body(member, 10f); Body(member, 10f); } ZoneGenerator.Place(root, AuthoredPlanetSettings(), radius, float.PositiveInfinity); foreach (var member in members) { Assert.True(root.Children.Count == count && member.Children.Count == 2, "a ring member or its children folded"); var chord = .2f * 2 * member.Distance * (float) Math.Sin(Math.PI / count); var fade = GravityField.Gamma * GravityField.Reach * member.Distance / Math.Max(GravityField.Beta, 1 + GravityField.Reach); Assert.True(chord < fade, "the fade, not the chord, would hold these children"); var outermost = member.Children.Max(c => c.Distance); Assert.True(Math.Abs(outermost - chord) <= 1e-4f * chord, $"a ring member's outermost child sits at {outermost}, not at the chord's fifth {chord}"); } } [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"); } }