Merge eureka/aetheria-gravity-bodyless-node-places-children (head 49c6e3f8)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-18 05:52:45 +02:00
commit 70c7a49739
3 changed files with 110 additions and 25 deletions

View file

@ -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.
/// </summary>
public static void Place(GeneratorPlanet root, PlanetSettings planetSettings, float radius, float rootReach)
@ -596,10 +596,15 @@ 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);
// 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<GeneratorPlanet> children, Func<float> lower, float upper, GeneratorPlanet into)
@ -607,9 +612,8 @@ 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 mInto = WellMass(into);
var m = OneBody(child);
var mInto = OneBody(into);
return m <= 0 || mInto <= 0 ? edge : Math.Max(edge, 2 * CuspMargin * Roche(m) * Strength(mInto) / Strength(m));
}
@ -647,7 +651,6 @@ public static class ZoneGenerator
}
}
var settled = new HashSet<GeneratorPlanet>(); // ring members, settled by their node so the ring is judged on their final masses
var chordCap = new Dictionary<GeneratorPlanet, float>(); // a ring member's children stay within .2 of the nearest partner's chord
void Settle(GeneratorPlanet node)
{
@ -660,12 +663,13 @@ 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;
var heavy = ring.OrderByDescending(c => c.Mass).First();
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(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);
@ -674,26 +678,20 @@ 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)))
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;
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<GeneratorPlanet>();
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);
}
}

View file

@ -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<ChildrenWithinReachTests.Zones>
@ -20,9 +20,9 @@ public sealed class ChildrenWithinReachTests : IClassFixture<ChildrenWithinReach
public readonly List<(uint Seed, Zone Zone)> 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<ZoneGenerationSettings> tune)
@ -479,6 +479,85 @@ public sealed class ChildrenWithinReachTests : IClassFixture<ChildrenWithinReach
$"the ring stood at {ringDistance} with a member grown to {Math.Max(heavy.Mass, light.Mass)}");
}
// A ring member that is itself bodyless (a binary of binaries) is still on its node's ring: from afar it pulls as one body of
// its family's mass (binaries-orbited-from-afar), so it is placed and judged with the ring, never left at its node's centre.
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ABodylessRingMemberSitsOnItsRing(bool bothBodyless)
{
const float radius = 1e6f;
var root = new GeneratorPlanet { Empty = true };
var a = Body(root, 100f, 0, true);
var b = Body(root, 100f, .5f, true);
foreach (var member in bothBodyless ? new[] { a, b } : new[] { a })
{
member.Empty = true;
Body(member, 50f, 0, true);
Body(member, 50f, .5f, true);
}
ZoneGenerator.Place(root, AuthoredPlanetSettings(), radius, float.PositiveInfinity);
Assert.True(root.Empty && root.Children.Count == 2, "the ring folded into its node or lost a member");
foreach (var member in new[] { a, b })
{
Assert.True(Math.Abs(member.Distance - .375f * radius) <= 1e-4f * radius, $"a ring member sits at {member.Distance}, not {.375f * radius}");
Assert.True(member.Children.All(c => c.Distance > 0), "a bodyless member's own ring was never placed");
}
}
private static float Total(GeneratorPlanet p) => (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]

View file

@ -133,13 +133,21 @@ 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 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, int.MaxValue, z => { z.SatelliteCreationProbability = 1; z.BinaryCreationProbability = 1; }))
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();