diff --git a/Assets/Resources/Settings.asset b/Assets/Resources/Settings.asset index 78735dab3..f1f320a67 100644 --- a/Assets/Resources/Settings.asset +++ b/Assets/Resources/Settings.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7a0a0d2e7b8367e7c402975e5683434068f99b8c13c848764bbac39b43886411 -size 12477 +oid sha256:8cc62a055653d90b906819239de99238253299f3ce4219cf58bddf9b67e3be67 +size 12523 diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 980828e53..18278dd15 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -134,6 +134,12 @@ public class ZoneGenerationSettings public float TrojanHalfWidth = .06f; public ExponentialCurve TrojanCount = new ExponentialCurve { Exponent = 1f, Multiplier = .04f, Constant = 8f }; + // Laned belts (ruling lanes-two-sided-visual-margin): the chance a belt candidate that kept its body and has room on both + // sides carries a laned belt, and the lane's half-width in the host's body radii. 0 here: a settings object built in code + // draws none. + public float LaneProbability = 0f; + public float LaneBodyRadii = 5f; + public float ResourceDensityMinimum = .1f; public float ResourceDensityMaximum = 1.5f; diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index c62f23442..a10950bbf 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -221,10 +221,12 @@ public static class ZoneGenerator // rocks round both L4 and L5. Drawn from a stream of its own, so no other draw in the zone moves; not a GeneratorPlanet, // so it takes no slot and no station candidacy sees it. var swarmRandom = new Random(seed ^ TrojanStream); + var swarmHosts = new HashSet(); foreach (var host in planets.Where(p => p.Parent != null && !p.Parent.Empty && !p.Belt && p.Distance > 0 && p.Mass > zoneSettings.PlanetMass && p.Mass <= zoneSettings.SunMass)) { if (swarmRandom.NextFloat() >= zoneSettings.TrojanProbability) continue; + swarmHosts.Add(host); var hostOrbit = orbitMap[host]; var rail = new OrbitData { Parent = hostOrbit.Parent, Distance = hostOrbit.Distance, Phase = hostOrbit.Phase }; pack.Orbits.Add(cache.Upsert(rail)); @@ -240,9 +242,40 @@ public static class ZoneGenerator pack.Planets.Add(cache.Upsert(swarmBody)); } + // Laned belts (rulings belt-shapes-direction, lanes-two-sided-visual-margin): a belt candidate that kept its body carries + // an annulus on a copy of its rail that fills its slot less a lane round the body, rocks on both sides. The lane is sized + // against the body the player sees (LaneBodyRadii body radii a side), and only a body whose slot holds a band at least + // that wide on both sides gets one. One belt shape per parent, and none on a swarm host. Drawn from a stream of its own, + // so no other draw in the zone moves; its rocks sit inside the host's slot, below the next sibling's spacing, so they + // raise no L_F and no support: the lane moves no well, period or height. + var laneRandom = new Random(seed ^ LaneStream); + var shaped = new HashSet(planets.Where(p => p.Belt).Select(p => p.Parent)); + foreach (var host in planets.Where(p => p.BeltCandidate && !p.Belt)) + { + if (laneRandom.NextFloat() >= zoneSettings.LaneProbability) continue; + var lane = zoneSettings.LaneBodyRadii * planetSettings.BodyRadius.Evaluate(host.Mass); + if (swarmHosts.Contains(host) || shaped.Contains(host.Parent) + || host.SlotInner > host.Distance - 2 * lane || host.SlotOuter < host.Distance + 2 * lane) continue; + shaped.Add(host.Parent); + var hostOrbit = orbitMap[host]; + var rail = new OrbitData { Parent = hostOrbit.Parent, Distance = hostOrbit.Distance, Phase = hostOrbit.Phase }; + pack.Orbits.Add(cache.Upsert(rail)); + var laned = new AsteroidBeltData + { + Orbit = cache.RefOf(rail), + Asteroids = LaneRocks(zoneSettings, ref laneRandom, host.Mass, rail.Distance, lane, host.SlotInner, host.SlotOuter) + }; + BodyData lanedBody = laned; + var lanedKey = cache.Upsert(lanedBody).Key; + laned.Name = lanedKey.Value.Substring(0, 8); + FieldKinds.Ensure(lanedKey, laned, cache); + pack.Planets.Add(cache.Upsert(lanedBody)); + } + // The generator's own well tree, for clearance only (the Zone builds its own): what a rail must stay out of is the body's - // support, or its Roche margin if more. A belt's extent is the rule Zone reads (AsteroidBelt.Extent). Swarm rails are - // left out: a swarm's extent is its own rail, a copy of its host's, so it raises no L_F and no support. + // support, or its Roche margin if more. A belt's extent is the rule Zone reads (AsteroidBelt.Extent). Swarm and lane rails + // are left out: a swarm's extent is its own rail, a copy of its host's, and a lane's outer rock sits inside its host's slot, + // below the next sibling's spacing, so neither raises an L_F or a support. var wellNodes = planets.Select(p => new WellNode(cache.RefOf(orbitMap[p]).Key, p.Parent == null ? default : cache.RefOf(orbitMap[p.Parent]).Key, p.Distance, p.Phase, p.Empty || p.Belt ? 0f : p.Mass, p.Belt ? AsteroidBelt.Extent(beltOf[p], p.Distance) : p.Distance, p.FixedPosition)).ToList(); @@ -598,8 +631,11 @@ public static class ZoneGenerator : root.AllPlanets().Where(p => p != root && p.Mass < settings.BeltMassCeiling && !binaries.Contains(p) && p.Children.Count == 0); foreach(var planet in beltCandidates.Reverse()) + { + planet.BeltCandidate = true; if (random.NextFloat() < settings.BeltProbability && !planet.Parent.Children.Any(p=>p.Belt)) planet.Belt = true; + } var totalMass = root.AllPlanets().Sum(p => p.Mass); var rootMass = root.Mass; @@ -633,6 +669,32 @@ public static class ZoneGenerator }; return rocks; } + + private const uint LaneStream = 0x85EBCA6Bu; + + // A laned belt's rocks: the count an annulus of the host's mass at `distance` would draw, over the bands [inner, distance - lane] + // and [distance + lane, outer], each rock in a band by its area and drawn across it as the annulus draws across its width. + public static Asteroid[] LaneRocks(ZoneGenerationSettings settings, ref Random random, float mass, float distance, float lane, float inner, float outer) + { + float Sq(float x) => x * x; + var innerArea = Sq(distance - lane) - Sq(inner); + var share = innerArea / (innerArea + Sq(outer) - Sq(distance + lane)); + var rocks = new Asteroid[(int) settings.AsteroidCount.Evaluate(mass * distance)]; + for (var i = 0; i < rocks.Length; i++) + { + var innerBand = random.NextFloat() < share; + var a = innerBand ? inner : distance + lane; + var b = innerBand ? distance - lane : outer; + rocks[i] = new Asteroid + { + Distance = (a + b) / 2 + random.NextFloat() * (random.NextFloat() - .5f) * (b - a), + Phase = random.NextFloat(), + Size = random.NextFloat(), + RotationSpeed = settings.AsteroidRotationSpeed.Evaluate(random.NextFloat()) + }; + } + return rocks; + } public const float CuspMargin = 1.5f; public const float SiblingSpacing = .5f; @@ -691,15 +753,18 @@ public static class ZoneGenerator var g = children.Count > 1 ? pow(upper / positions[positions.Count - 1], 1f / (children.Count - 1)) : 1f; for (var k = 0; k < children.Count; k++) children[k].Distance = positions[k] * pow(g, k); - // A belt is an interval inside its slot: inner edge outside the lower bound and the previous sibling's spacing, outer edge - // inside the next sibling's spacing and the window; the authored width is the widest it may be. + // Every child's slot: from the lower bound and the previous sibling's spacing out to the next sibling's spacing and the + // window. Place is the only writer of a slot; a belt fills its own, and the lane pass reads a body's. A belt is an + // interval inside its slot, the authored width the widest it may be. var outer = 0f; for (var k = 0; k < children.Count; k++) { var c = children[k]; + c.SlotInner = Math.Max(lo, outer * q); + c.SlotOuter = k + 1 < children.Count ? Math.Min(upper, children[k + 1].Distance / q) : upper; if (!c.Belt) { outer = c.Distance; continue; } - var inner = Math.Max(lo, outer * q); - var outerLimit = k + 1 < children.Count ? Math.Min(upper, children[k + 1].Distance / q) : upper; + var inner = c.SlotInner; + var outerLimit = c.SlotOuter; c.Distance = (inner + outerLimit) / 2; var half = Math.Max(0f, Math.Min(c.Settings.AsteroidBeltWidth.Evaluate(c.Distance), outerLimit - inner) / 2); c.BeltHalfWidth = half; @@ -765,6 +830,11 @@ public class GeneratorPlanet public float BeltHalfWidth; public bool Empty = false; public bool Belt = false; + // The interval Place gives a child between its previous sibling's spacing and the next sibling's spacing or the window; zero + // on a ring member, which has no slot. + public float SlotInner, SlotOuter; + // A childless light body, not a binary or root rosette member, that could have become a belt (set by GenerateEntities). + public bool BeltCandidate; // A member of a rosette's ring, which Place puts on one orbit round its bodyless parent. public bool Ring = false; public List Children = new List(); // Planets orbiting this one are referred to as children diff --git a/tests/Aetheria.Shared.Tests/BeltShapes.cs b/tests/Aetheria.Shared.Tests/BeltShapes.cs index 79f24ddfc..5322b1f0a 100644 --- a/tests/Aetheria.Shared.Tests/BeltShapes.cs +++ b/tests/Aetheria.Shared.Tests/BeltShapes.cs @@ -1,8 +1,10 @@ +using System.Collections.Generic; using System.Linq; using GameCult.Caching; -// The belt shapes a test reads back from a built zone. A swarm's rail is a copy of its host's rail (same Parent, Distance and -// Phase), never a slot Place laid out, so the tests that walk placed orbits leave copied rails out through this one helper. +// The belt shapes a test reads back from a built zone. A swarm's or a lane's rail is a copy of its host's rail (same Parent, +// Distance and Phase), never a slot Place laid out, so the tests that walk placed orbits leave copied rails out through this one +// helper. public static class BeltShapes { public static bool CopiedRail(Zone zone, CultRecordKey orbit) @@ -12,4 +14,19 @@ public static class BeltShapes return zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).Select(b => zone.Orbits[b.Orbit.Key].Data) .Any(o => o.Parent.Key.Equals(rail.Parent.Key) && o.Distance == rail.Distance && o.Phase == rail.Phase); } + + // Every laned belt with its rail and its host: a belt on a copied rail that does not ride the rail (a swarm does), whose host + // is the body orbit the rail copies. + public static IEnumerable<(Orbit Host, Orbit Rail, AsteroidBelt Belt)> Lanes(Zone zone) + { + foreach (var (key, belt) in zone.AsteroidBelts.Where(b => !b.Value.Data.RidesRail)) + { + var railKey = zone.Planets[key].Orbit.Key; + if (!CopiedRail(zone, railKey)) continue; + var rail = zone.Orbits[railKey]; + var host = zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).Select(b => zone.Orbits[b.Orbit.Key]) + .Single(o => o.Data.Parent.Key.Equals(rail.Data.Parent.Key) && o.Data.Distance == rail.Data.Distance && o.Data.Phase == rail.Data.Phase); + yield return (host, rail, belt); + } + } } diff --git a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs index ae1e8538c..a5ab3ebc3 100644 --- a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs +++ b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs @@ -88,7 +88,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture Placed(Zone zone) + internal static HashSet Placed(Zone zone) { var placed = new HashSet(); foreach (var body in zone.Planets.Values.Where(b => !BeltShapes.CopiedRail(zone, b.Orbit.Key))) @@ -96,7 +96,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture ChildrenOf(Zone zone, CultRecordKey parent, HashSet placed) => + internal static List ChildrenOf(Zone zone, CultRecordKey parent, HashSet placed) => placed.Where(k => zone.Orbits[k].Data.Parent.IsSet() && zone.Orbits[k].Data.Parent.Key.Equals(parent)) .OrderBy(k => zone.Orbits[k].Data.Distance).ToList(); @@ -455,17 +455,6 @@ public sealed class ChildrenWithinReachTests : IClassFixture AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("PlanetSettings"); - // A belt's field kind is chosen at generation and saved with it (Mining Cut 3, Q16 B): every belt the generator writes, - // annulus or swarm, carries one before any Zone reads it (finding ensure-unpinned). - [Fact] - public void EveryBeltIsKindedAtGeneration() - { - _out.WriteLine($"belts {_zones.Belts}, unkinded at generation {_zones.UnkindedAtGeneration}"); - Assert.True(_zones.All.Any(z => z.Zone.AsteroidBelts.Values.Any(b => b.Data.RidesRail)), "no swarm in the sample"); - Assert.True(_zones.Belts > 20, $"only {_zones.Belts} belts generated"); - Assert.Equal(0, _zones.UnkindedAtGeneration); - } - // Two belts side by side under one parent (production never draws them; follow-up belt-after-belt-edge-pin): the second // belt's slot starts a sibling spacing outside the first belt's outer edge, not its rail, so their rock intervals keep a spacing. [Fact] @@ -489,6 +478,8 @@ public sealed class ChildrenWithinReachTests : IClassFixture +{ + private const uint First = 1, Last = 6; + private const float Tolerance = 1e-3f; + + public sealed class Samples : IDisposable + { + public readonly ChildrenWithinReachTests.Zones Authored = new ChildrenWithinReachTests.Zones(First, Last, null); + public readonly ChildrenWithinReachTests.Zones Laneless = new ChildrenWithinReachTests.Zones(First, Last, z => z.LaneProbability = 0); + + public void Dispose() + { + Authored.Dispose(); + Laneless.Dispose(); + } + } + + private readonly Samples _samples; + private readonly ITestOutputHelper _out; + + public LanedBeltTests(Samples samples, ITestOutputHelper output) + { + _samples = samples; + _out = output; + } + + private static CultRecordKey KeyOf(Zone zone, Orbit orbit) => zone.Orbits.First(o => o.Value == orbit).Key; + + private static BodyData BodyAt(Zone zone, Orbit orbit) => + zone.Planets.Values.Single(b => !(b is AsteroidBeltData) && zone.Orbits[b.Orbit.Key] == orbit); + + // The lane's half-width, spelled from the settings: LaneBodyRadii of the host's body radius. + private float HalfWidth(Zone zone, float hostMass) => _samples.Authored.Settings.LaneBodyRadii * zone.Settings.BodyRadius.Evaluate(hostMass); + + [Fact] + public void ALaneMovesNothingElseInTheZone() + { + var a = _samples.Laneless.All; + var b = _samples.Authored.All; + Assert.Equal(a.Count, b.Count); + var lanes = 0; + for (var n = 0; n < a.Count; n++) + { + Assert.Equal(a[n].Seed, b[n].Seed); + Assert.Empty(BeltShapes.Lanes(a[n].Zone)); + var zone = b[n].Zone; + var laned = BeltShapes.Lanes(zone).ToList(); + lanes += laned.Count; + bool SkipOrbit(CultRecordKey k) => laned.Any(l => l.Rail == zone.Orbits[k]); + bool SkipBody(BodyData body) => laned.Any(l => l.Belt.Data == body); + Assert.Equal(ZoneFingerprint.Of(new[] { a[n].Zone }, _ => false, _ => false, true), ZoneFingerprint.Of(new[] { zone }, SkipOrbit, SkipBody, true)); + } + _out.WriteLine($"zones {b.Count}, lanes {lanes}"); + Assert.True(lanes > 0, $"no lane in seeds {First}-{Last}"); + } + + [Fact] + public void ALaneIsEmptyAndBothSidesHoldRocks() + { + var s = _samples.Authored.Settings; + var q = 1 / (1 - GravityField.Reach / 2); + var lanes = 0; + foreach (var (seed, zone) in _samples.Authored.All) + { + var placed = ChildrenWithinReachTests.Placed(zone); + foreach (var (host, _, belt) in BeltShapes.Lanes(zone)) + { + lanes++; + Assert.False(belt.Data.RidesRail); + Assert.Equal(0f, belt.Data.Mass); + var hostMass = BodyAt(zone, host).Mass; + var d = host.Data.Distance; + var e = HalfWidth(zone, hostMass); + Assert.Equal((int) s.AsteroidCount.Evaluate(hostMass * d), belt.Data.Asteroids.Length); + + // The host's slot, spelled from its siblings, its parent's Roche margin and its parent's window. + var parent = host.Data.Parent.Key; + var sibling = ChildrenWithinReachTests.ChildrenOf(zone, parent, placed); + var at = sibling.IndexOf(KeyOf(zone, host)); + Assert.True(at >= 0 && at < sibling.Count - 1, $"seed {seed}: a lane host is its parent's outermost child"); + var parentBody = zone.Planets.Values.FirstOrDefault(p => !(p is AsteroidBeltData) && p.Orbit.Key.Equals(parent)); + var roche = parentBody == null ? 0f : 1.5f * zone.Settings.RocheLimit.Evaluate(parentBody.Mass); + var innerBound = Math.Max(roche, at > 0 ? zone.Orbits[sibling[at - 1]].Data.Distance * q : 0f); + var grand = zone.Orbits[parent].Data.Parent; + var window = grand.IsSet() + ? GravityField.Gamma * GravityField.Reach * zone.Orbits[parent].Data.Distance / Math.Max(GravityField.Beta, 1 + GravityField.Reach) + : float.PositiveInfinity; + var outerBound = Math.Min(window, zone.Orbits[sibling[at + 1]].Data.Distance / q); + + var rocks = belt.Data.Asteroids.Select(r => r.Distance).ToList(); + var inner = rocks.Where(r => r < d).ToList(); + var outer = rocks.Where(r => r > d).ToList(); + _out.WriteLine($"seed {seed}: lane at {d} half-width {e}, slot [{innerBound}, {outerBound}], rocks {inner.Count} inside, {outer.Count} outside"); + Assert.True(rocks.All(r => Math.Abs(r - d) >= e - 1e-5f * d), $"seed {seed}: a rock sits {rocks.Min(r => Math.Abs(r - d))} from a host at {d}, inside a lane of {e}"); + Assert.True(inner.Count > 0 && outer.Count > 0, $"seed {seed}: a lane at {d} holds {inner.Count} rocks inside and {outer.Count} outside"); + Assert.True(d - e - innerBound >= e * (1 - Tolerance), $"seed {seed}: the inner band [{innerBound}, {d - e}] is narrower than the lane's half-width {e}"); + Assert.True(outerBound - d - e >= e * (1 - Tolerance), $"seed {seed}: the outer band [{d + e}, {outerBound}] is narrower than the lane's half-width {e}"); + Assert.True(inner.Min() >= innerBound * (1 - Tolerance), $"seed {seed}: a rock at {inner.Min()} reaches inside the slot's inner bound {innerBound}"); + Assert.True(outer.Max() <= outerBound * (1 + Tolerance), $"seed {seed}: a rock at {outer.Max()} reaches past the slot's outer bound {outerBound}"); + } + } + Assert.True(lanes > 0, $"no lane in seeds {First}-{Last}"); + } + + [Fact] + public void ALaneHostIsALightBodyWithNoOtherShape() + { + var s = _samples.Authored.Settings; + var lanes = 0; + foreach (var (seed, zone) in _samples.Authored.All) + { + var laned = BeltShapes.Lanes(zone).ToList(); + foreach (var (host, _, _) in laned) + { + lanes++; + var body = BodyAt(zone, host); + Assert.True(body.GetType() == typeof(PlanetData) && body.Mass < s.BeltMassCeiling, $"seed {seed}: a lane host is a {body.GetType().Name} of mass {body.Mass}"); + var key = KeyOf(zone, host); + Assert.DoesNotContain(zone.Orbits.Values, o => o.Data.Parent.IsSet() && o.Data.Parent.Key.Equals(key)); + // A swarm's rail is a copy of its host's too: no lane host carries one. + Assert.DoesNotContain(zone.AsteroidBelts.Where(b => b.Value.Data.RidesRail).Select(b => zone.Orbits[zone.Planets[b.Key].Orbit.Key].Data), + r => r.Parent.Key.Equals(host.Data.Parent.Key) && r.Distance == host.Data.Distance && r.Phase == host.Data.Phase); + } + // One belt shape per parent: an annulus (a belt on a placed slot) or a lane, never both, never two lanes. + var annuli = zone.AsteroidBelts.Keys.Select(k => zone.Planets[k].Orbit.Key).Where(o => !BeltShapes.CopiedRail(zone, o)) + .Select(o => zone.Orbits[o].Data.Parent.Key); + var doubled = annuli.Concat(laned.Select(l => l.Host.Data.Parent.Key)).GroupBy(p => p).Count(g => g.Count() > 1); + Assert.True(doubled == 0, $"seed {seed}: {doubled} parents carry two belt shapes"); + } + Assert.True(lanes > 0, $"no lane in seeds {First}-{Last}"); + } + + // A belt's field kind is chosen at generation and saved with it (Mining Cut 3, Q16 B): every belt the generator writes, + // annulus, swarm or lane, carries one before any Zone reads it (finding ensure-unpinned). + [Fact] + public void EveryBeltIsKindedAtGeneration() + { + var zones = _samples.Authored; + _out.WriteLine($"belts {zones.Belts}, unkinded at generation {zones.UnkindedAtGeneration}"); + Assert.True(zones.All.Any(z => z.Zone.AsteroidBelts.Values.Any(b => b.Data.RidesRail)), "no swarm in the sample"); + Assert.True(zones.All.Any(z => BeltShapes.Lanes(z.Zone).Any()), "no lane in the sample"); + Assert.True(zones.Belts > 20, $"only {zones.Belts} belts generated"); + Assert.Equal(0, zones.UnkindedAtGeneration); + } +} diff --git a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs index d5428481a..0c7e7487a 100644 --- a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs +++ b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs @@ -98,7 +98,8 @@ public class TrojanSwarmTests } // The widened digests (ZoneFingerprint with PopulatedRadius) were computed on Yggdrasil over the generator of cut laned-belts - // C1 (one belt extent rule) with swarms off, over the zones of each seed: a zone with no swarm is the base zone, and a zone with + // C1 (one belt extent rule) with swarms off, over the zones of each seed; both samples draw no lane, so the base is the zone + // with neither shape: a zone with no swarm is the base zone, and a zone with // swarms is the base zone plus them. The per-zone comparison leaves PopulatedRadius out, since a swarm's outer rock may grow it. private static readonly Dictionary BaseDigest = new Dictionary { @@ -113,8 +114,8 @@ public class TrojanSwarmTests [Fact] public void ASwarmMovesNothingElseInTheZone() { - using var swarmless = new ChildrenWithinReachTests.Zones(First, Last, z => z.TrojanProbability = 0); - using var live = new ChildrenWithinReachTests.Zones(First, Last, null); + using var swarmless = new ChildrenWithinReachTests.Zones(First, Last, z => { z.TrojanProbability = 0; z.LaneProbability = 0; }); + using var live = new ChildrenWithinReachTests.Zones(First, Last, z => z.LaneProbability = 0); Assert.Equal(swarmless.All.Count, live.All.Count); int swarms = 0, grew = 0; foreach (var seed in BaseDigest.Keys)