From cacd926182d35966d311bc99c3a82332e8bf4625 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 01:59:06 +0200
Subject: [PATCH 1/4] ZoneGenerator.Place: Settle recurses into each placed
child; the queue, its guard and the settled set are deleted
Co-Authored-By: Claude Opus 5.5
---
Assets/Scripts/ServerShared/ZoneGenerator.cs | 16 +++++-----------
1 file changed, 5 insertions(+), 11 deletions(-)
diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs
index 6f8347fc9..82a2355e9 100644
--- a/Assets/Scripts/ServerShared/ZoneGenerator.cs
+++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs
@@ -647,7 +647,6 @@ public static class ZoneGenerator
}
}
- var settled = new HashSet(); // ring members, settled by their node so the ring is judged on their final masses
var chordCap = new Dictionary(); // a ring member's children stay within .2 of the nearest partner's chord
void Settle(GeneratorPlanet node)
{
@@ -660,11 +659,12 @@ public static class ZoneGenerator
if (WellMass(node) > 0)
{
PlaceChildren(node.Children.ToList(), () => RocheMargin * Roche(WellMass(node)), upper, node);
+ foreach (var child in node.Children.ToList()) Settle(child);
return;
}
var ring = node.Children.Where(c => c.Ring && WellMass(c) > 0).ToList();
- if (ring.Count == 0) return;
+ if (ring.Count == 0) { foreach (var child in node.Children.ToList()) Settle(child); return; }
var heavy = ring.OrderByDescending(c => c.Mass).First();
var ringDistance = Math.Min(.5f * cap, share * reach / (GravityField.Beta * GravityField.Beta * (1 + GravityField.RingReach)));
PlaceChildren(node.Children.Where(c => !ring.Contains(c)).ToList(),
@@ -674,7 +674,6 @@ public static class ZoneGenerator
member.Distance = ringDistance;
chordCap[member] = .2f * 2 * ringDistance * sin(PI / ring.Count);
Settle(member);
- settled.Add(member);
}
// A member's mass is final only once it has settled (its own overflowing children fold into it).
if (ringDistance < RocheMargin * Roche(ring.Max(c => c.Mass)))
@@ -683,17 +682,12 @@ public static class ZoneGenerator
node.Empty = false;
node.Mass = 0;
foreach (var child in node.Children.ToList()) Fold(child, node);
+ return;
}
+ foreach (var child in node.Children.Where(c => !ring.Contains(c)).ToList()) Settle(child);
}
- var queue = new Queue();
- queue.Enqueue(root);
- while (queue.Count > 0)
- {
- var node = queue.Dequeue();
- if (!settled.Contains(node)) Settle(node);
- foreach (var child in node.Children) queue.Enqueue(child);
- }
+ Settle(root);
}
}
From 408b1c45bdcf6c3cb2afba47f646eb5595c622a3 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 02:01:09 +0200
Subject: [PATCH 2/4] ZoneGenerator.Place: a bodyless node is one body of its
family's mass wherever a mass is read, and ChildrenWithinReachTests widens to
seeds 1-30
Co-Authored-By: Claude Opus 5.5
---
Assets/Scripts/ServerShared/ZoneGenerator.cs | 13 +++----
.../ChildrenWithinReachTests.cs | 34 ++++++++++++++++---
.../StationPlacementTests.cs | 6 ++--
3 files changed, 40 insertions(+), 13 deletions(-)
diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs
index 82a2355e9..2246aae07 100644
--- a/Assets/Scripts/ServerShared/ZoneGenerator.cs
+++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs
@@ -586,7 +586,7 @@ public static class ZoneGenerator
/// outermost overflows the window, and the rest are stretched geometrically so the outermost sits at the window's edge. A
/// non-root's window ends before its own fade (Gamma x Reach x its distance); a root's, in a zone of several, ends under
/// rootReach / Beta so its support never meets another root's (root-window-floor), and is 0.75 x the area radius at most.
- /// A rosette's ring is one orbit; once its members have settled (their own children folded in), it folds into its node,
+ /// A rosette's ring is one orbit, and a member may itself be bodyless, which counts as one body of its family's mass; once its members have settled (their own children folded in), it folds into its node,
/// which becomes a body, if it cannot keep its heaviest member outside its Roche margin. Ported from docs/research/gravity-well-law/replace.py.
///
public static void Place(GeneratorPlanet root, PlanetSettings planetSettings, float radius, float rootReach)
@@ -596,6 +596,8 @@ public static class ZoneGenerator
float Strength(float mass) => WellTree.Curves(planetSettings, mass).Strength;
float WellMass(GeneratorPlanet p) => p.Empty || p.Belt ? 0f : p.Mass;
float Family(GeneratorPlanet p) => (p.Empty ? 0f : p.Mass) + p.Children.Sum(c => Family(c));
+ // A bodyless node pulls from afar as one body of its family's mass, which it becomes if its ring cannot stand (binaries-orbited-from-afar)
+ float OneBody(GeneratorPlanet p) => p.Empty ? Family(p) : WellMass(p);
void Fold(GeneratorPlanet node, GeneratorPlanet into)
{
into.Mass += Family(node);
@@ -607,8 +609,7 @@ public static class ZoneGenerator
upper *= 1 - 1e-6f;
float AtLeast(GeneratorPlanet child, float edge)
{
- // A bodyless node stands in for one body of its family's mass, which it becomes if its ring cannot stand
- var m = child.Empty ? Family(child) : WellMass(child);
+ var m = OneBody(child);
var mInto = WellMass(into);
return m <= 0 || mInto <= 0 ? edge : Math.Max(edge, 2 * CuspMargin * Roche(m) * Strength(mInto) / Strength(m));
}
@@ -663,9 +664,9 @@ public static class ZoneGenerator
return;
}
- var ring = node.Children.Where(c => c.Ring && WellMass(c) > 0).ToList();
+ var ring = node.Children.Where(c => c.Ring && OneBody(c) > 0).ToList();
if (ring.Count == 0) { foreach (var child in node.Children.ToList()) Settle(child); return; }
- var heavy = ring.OrderByDescending(c => c.Mass).First();
+ var heavy = ring.OrderByDescending(OneBody).First();
var ringDistance = Math.Min(.5f * cap, share * reach / (GravityField.Beta * GravityField.Beta * (1 + GravityField.RingReach)));
PlaceChildren(node.Children.Where(c => !ring.Contains(c)).ToList(),
() => (1 + GravityField.RingReach) * ringDistance * GravityField.Beta, upper, heavy);
@@ -676,7 +677,7 @@ public static class ZoneGenerator
Settle(member);
}
// A member's mass is final only once it has settled (its own overflowing children fold into it).
- if (ringDistance < RocheMargin * Roche(ring.Max(c => c.Mass)))
+ if (ringDistance < RocheMargin * Roche(ring.Max(OneBody)))
{
// The ring cannot keep its heaviest member outside its Roche margin inside its node's reach: the node is one body.
node.Empty = false;
diff --git a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs
index ffd50613f..3249944ef 100644
--- a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs
+++ b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs
@@ -9,7 +9,7 @@ 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
+// Aetheria gravity, children within reach: the production generator's zones (every zone of seeds 1-30, 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
@@ -20,9 +20,9 @@ public sealed class ChildrenWithinReachTests : IClassFixture All = new List<(uint, Zone)>();
public ZoneGenerationSettings Settings;
- // Seeds 7, 11 and 12 held rings inside their heavy member's Roche margin (ring-roche-final-heavy), which seeds 1-6 do not
- // show. Seed 10 holds a bodyless non-ring node whose massive children are never placed (distance 0); widen past 9 once that is fixed.
- public Zones() : this(1, 9, null) { }
+ // Seeds 7, 11 and 12 held rings inside their heavy member's Roche margin (ring-roche-final-heavy), and seeds 10, 13, 21 and 30
+ // held a bodyless ring member whose own bodies sat at their node's centre (bodyless-node-places-children); seeds 1-6 show neither.
+ public Zones() : this(1, 30, 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)
@@ -479,6 +479,32 @@ public sealed class ChildrenWithinReachTests : IClassFixture c.Distance > 0), "a bodyless member's own ring was never placed");
+ }
+ }
+
// 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]
diff --git a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs
index 759e3e83d..c94f40831 100644
--- a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs
+++ b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs
@@ -133,17 +133,17 @@ public sealed class StationPlacementTests : IDisposable
Assert.True(ahead > 0 && behind > 0, $"the seeds put stations on one side only (ahead {ahead}, behind {behind})");
}
- // A binary is a bodyless node under its parent; the station's source is never such a node, however many binaries there are.
+ // A rosette or binary is a bodyless node, a root included; the station's source is never such a node, however many there are.
[Fact]
public void AStationNeverSitsOnABinarysBodylessNode()
{
var binaries = 0;
var stations = 0;
- foreach (var built in GeneratedZones(null, 0, 4, int.MaxValue, z => { z.SatelliteCreationProbability = 1; z.BinaryCreationProbability = 1; }))
+ foreach (var built in GeneratedZones(null, 0, 4))
{
var zone = built.Zone;
var bodyOrbits = zone.Planets.Values.Select(b => b.Orbit.Key).ToHashSet();
- binaries += zone.Orbits.Count(o => o.Value.Data.Parent.IsSet() && !bodyOrbits.Contains(o.Key) && zone.Wells.Reach(o.Key) > 0f);
+ binaries += zone.Orbits.Count(o => !bodyOrbits.Contains(o.Key) && zone.Wells.Reach(o.Key) > 0f);
var ridden = zone.Entities.OfType().Select(e => e.OrbitData).ToHashSet();
foreach (var station in Orbitals(built).Where(e => TypeOf(built.Items, e) == HullType.Station))
{
From 5a6309403560398a7796116b063dd9da42084708 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 04:22:30 +0200
Subject: [PATCH 3/4] ZoneGenerator.Place: a folded family joins a bodyless
target as a child and AtLeast reads its target through OneBody; direct-Place
theories pin OneBody, the heavy member, the margin and the ringless settle
Co-Authored-By: Claude Opus 5.5
---
Assets/Scripts/ServerShared/ZoneGenerator.cs | 7 ++-
.../ChildrenWithinReachTests.cs | 53 +++++++++++++++++++
2 files changed, 58 insertions(+), 2 deletions(-)
diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs
index 2246aae07..c0c8cf16f 100644
--- a/Assets/Scripts/ServerShared/ZoneGenerator.cs
+++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs
@@ -598,10 +598,13 @@ public static class ZoneGenerator
float Family(GeneratorPlanet p) => (p.Empty ? 0f : p.Mass) + p.Children.Sum(c => Family(c));
// A bodyless node pulls from afar as one body of its family's mass, which it becomes if its ring cannot stand (binaries-orbited-from-afar)
float OneBody(GeneratorPlanet p) => p.Empty ? Family(p) : WellMass(p);
+ // A folded family joins its target: a body takes its mass, a bodyless node (whose own Mass is no mass) takes it as a child,
+ // which its own Settle then places.
void Fold(GeneratorPlanet node, GeneratorPlanet into)
{
- into.Mass += Family(node);
node.Parent.Children.Remove(node);
+ if (into.Empty) { node.Parent = into; into.Children.Add(node); }
+ else into.Mass += Family(node);
}
void PlaceChildren(List children, Func lower, float upper, GeneratorPlanet into)
@@ -610,7 +613,7 @@ public static class ZoneGenerator
float AtLeast(GeneratorPlanet child, float edge)
{
var m = OneBody(child);
- var mInto = WellMass(into);
+ var mInto = OneBody(into);
return m <= 0 || mInto <= 0 ? edge : Math.Max(edge, 2 * CuspMargin * Roche(m) * Strength(mInto) / Strength(m));
}
diff --git a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs
index 3249944ef..abf7b45c4 100644
--- a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs
+++ b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs
@@ -505,6 +505,59 @@ public sealed class ChildrenWithinReachTests : IClassFixture (p.Empty ? 0f : p.Mass) + p.Children.Sum(Total);
+
+ // A bodyless ring member is the heavy one when its family outweighs its siblings, whatever mass it was stored with before it
+ // became a binary, and a family folded out of the ring's way joins it: a bodyless member of family 600 (stored 10), a body of
+ // 100 and a non-ring child of 150 (a child that cannot fit beyond the ring always folds) keep all 850, and the body is untouched.
+ [Fact]
+ public void ABodylessHeavyMemberTakesTheFoldedChildAndItsMass()
+ {
+ var root = new GeneratorPlanet { Empty = true };
+ var bodyless = Body(root, 10f, 0, true);
+ bodyless.Empty = true;
+ Body(bodyless, 300f, 0, true);
+ Body(bodyless, 300f, .5f, true);
+ var body = Body(root, 100f, .5f, true);
+ var outer = Body(root, 150f);
+ ZoneGenerator.Place(root, AuthoredPlanetSettings(), 1e8f, float.PositiveInfinity);
+ Assert.True(root.Empty && root.Children.Count == 2 && !root.Children.Contains(outer), "the non-ring child did not fold out of the ring's way");
+ Assert.True(Math.Abs(Total(root) - 850f) <= 1e-3f, $"the tree holds {Total(root)} of 850: a folded family's mass was lost");
+ Assert.True(body.Mass == 100f, "the child folded into the lighter member, not the bodyless one whose family outweighs it");
+ }
+
+ // A ring is judged on its heaviest member's family, however little mass a bodyless member was stored with. With a Roche limit
+ // steepening as mass squared, a ring of one bodyless member of nine 1000s and a body of 1 stands outside nine 1000s' Roche margin and
+ // folds inside it; judged on the stored mass of 1, it would stand either way.
+ [Theory]
+ [InlineData(.98f, false)]
+ [InlineData(1.02f, true)]
+ public void ARingIsJudgedOnABodylessMembersFamily(float clearance, bool stands)
+ {
+ var settings = AuthoredPlanetSettings();
+ settings.RocheLimit = new ExponentialCurve { Exponent = 2f, Multiplier = 1f, Constant = 0f };
+ var ringDistance = clearance * RocheMargin * settings.RocheLimit.Evaluate(9000f);
+ var root = new GeneratorPlanet { Empty = true };
+ var bodyless = Body(root, 1f, 0, true);
+ bodyless.Empty = true;
+ for (var k = 0; k < 9; k++) Body(bodyless, 1000f, k / 9f, true);
+ Body(root, 1f, .5f, true);
+ ZoneGenerator.Place(root, settings, ringDistance / .375f, float.PositiveInfinity);
+ Assert.True(root.Empty == stands, stands ? "a ring outside its heaviest family's Roche margin folded" : "a ring inside its heaviest family's Roche margin stood");
+ }
+
+ // A node with no ring still settles its children: a satellite of a child at distance 0 has no reach and folds into it.
+ [Fact]
+ public void ABodylessNodeWithoutARingStillSettlesItsChildren()
+ {
+ var root = new GeneratorPlanet { Empty = true };
+ var child = Body(root, 500f);
+ Body(child, 50f);
+ Body(root, 700f);
+ ZoneGenerator.Place(root, AuthoredPlanetSettings(), 1e8f, float.PositiveInfinity);
+ Assert.True(child.Children.All(c => c.Distance > 0), "a child's satellite was left at its centre, unsettled");
+ }
+
// 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]
From 49c6e3f8372b2eb0daac82a107d49d94096694dc Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 04:22:31 +0200
Subject: [PATCH 4/4] StationPlacementTests: the bodyless-node rule runs on a
wide light sample of the production generator that holds standing binaries
under bodies
Co-Authored-By: Claude Opus 5.5
---
.../Aetheria.Shared.Tests/StationPlacementTests.cs | 14 +++++++++++---
1 file changed, 11 insertions(+), 3 deletions(-)
diff --git a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs
index c94f40831..d9845a0c8 100644
--- a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs
+++ b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs
@@ -133,17 +133,25 @@ public sealed class StationPlacementTests : IDisposable
Assert.True(ahead > 0 && behind > 0, $"the seeds put stations on one side only (ahead {ahead}, behind {behind})");
}
- // A rosette or binary is a bodyless node, a root included; the station's source is never such a node, however many there are.
+ // A binary under a body is a bodyless node; the station's source is never such a node, however many there are. The authored
+ // settings leave none standing (every binary's ring folds into its node), so the zones are wide and light enough for rings to stand,
+ // with every satellite a binary: that is the sample in which the candidate rule's exclusion of a bodyless node does any work.
[Fact]
public void AStationNeverSitsOnABinarysBodylessNode()
{
var binaries = 0;
var stations = 0;
- foreach (var built in GeneratedZones(null, 0, 4))
+ foreach (var built in GeneratedZones(null, 0, 4, int.MaxValue, z =>
+ {
+ z.SatelliteCreationProbability = 1;
+ z.BinaryCreationProbability = 1;
+ z.ZoneRadius.Minimum *= 3; z.ZoneRadius.Maximum *= 3;
+ z.ZoneMass.Minimum *= .1f; z.ZoneMass.Maximum *= .1f;
+ }))
{
var zone = built.Zone;
var bodyOrbits = zone.Planets.Values.Select(b => b.Orbit.Key).ToHashSet();
- binaries += zone.Orbits.Count(o => !bodyOrbits.Contains(o.Key) && zone.Wells.Reach(o.Key) > 0f);
+ binaries += zone.Orbits.Count(o => o.Value.Data.Parent.IsSet() && !bodyOrbits.Contains(o.Key) && zone.Wells.Reach(o.Key) > 0f);
var ridden = zone.Entities.OfType().Select(e => e.OrbitData).ToHashSet();
foreach (var station in Orbitals(built).Where(e => TypeOf(built.Items, e) == HullType.Station))
{