From 408b1c45bdcf6c3cb2afba47f646eb5595c622a3 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 02:01:09 +0200
Subject: [PATCH] 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))
{