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:
commit
70c7a49739
3 changed files with 110 additions and 25 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue