From 8ee159914b7c800adc464188cdf40d7c3e6c5c48 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 21:40:01 +0200 Subject: [PATCH 1/2] ZoneGenerator: a ring is judged on its members' settled masses The Roche-margin check on a rosette's ring read heavy.Mass before any member had settled; each member's own Settle later folds its overflowing children into it, so a ring could stand with a member inside its Roche margin. Settle now places and settles the ring's members first and judges the ring on ring.Max(Mass); the queue skips members already settled. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ZoneGenerator.cs | 26 ++++++++++--------- .../ChildrenWithinReachTests.cs | 19 ++++++++++++++ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 30d9d52ae..6f8347fc9 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -586,8 +586,8 @@ 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; it folds into its node, which becomes a body, if it cannot keep the heavy member outside - /// its Roche margin. Ported from docs/research/gravity-well-law/replace.py. + /// A rosette's ring is one orbit; 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) { @@ -647,6 +647,7 @@ 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) { @@ -668,19 +669,20 @@ public static class ZoneGenerator 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); - // The heavy member's mass is final only now (the outer children fold into it). - if (ringDistance < RocheMargin * Roche(heavy.Mass)) - { - // The pair cannot keep its heavy member outside its Roche margin inside its node's reach: the node is one body. - node.Empty = false; - node.Mass = 0; - foreach (var child in node.Children.ToList()) Fold(child, node); - return; - } foreach (var member in ring) { 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))) + { + // The ring cannot keep its heaviest member outside its Roche margin inside its node's reach: the node is one body. + node.Empty = false; + node.Mass = 0; + foreach (var child in node.Children.ToList()) Fold(child, node); } } @@ -689,7 +691,7 @@ public static class ZoneGenerator while (queue.Count > 0) { var node = queue.Dequeue(); - Settle(node); + if (!settled.Contains(node)) Settle(node); foreach (var child in node.Children) queue.Enqueue(child); } } diff --git a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs index 96ffb8b82..912089d99 100644 --- a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs +++ b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs @@ -458,6 +458,25 @@ public sealed class ChildrenWithinReachTests : IClassFixture Date: Sat, 17 Oct 2026 22:18:34 +0200 Subject: [PATCH 2/2] ChildrenWithinReachTests: sample seeds 1-9 so the ring Roche defect cannot hide behind six Seeds 7, 11 and 12 reproduce the ring-inside-Roche-margin defect on the base generator; seeds 1-6 do not. Seed 10 shows a separate defect (a bodyless non-ring node whose children are never placed), so the range stops at 9. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs index 912089d99..ffd50613f 100644 --- a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs +++ b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs @@ -20,7 +20,9 @@ public sealed class ChildrenWithinReachTests : IClassFixture All = new List<(uint, Zone)>(); public ZoneGenerationSettings Settings; - public Zones() : this(1, 6, null) { } + // 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) { } // 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)