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)) {