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