From 92d6153c2e60120233afe4e02e94214be4d4de87 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 16:28:42 +0200
Subject: [PATCH 1/3] Trojan swarms: a belt on a copy of its host's rail whose
rocks ride the rail's period
Rulings trojans-first, trojans-ride-host-period-with-stations. AsteroidBeltData
key 11 RidesRail; AsteroidBelt.Extent is the one belt extent rule (replaces
Zone's inline farthest-rock loop and the Math.Max at the well node); a swarm's
Periods are its rail's period. The generator draws swarms from a stream of its
own (TrojanStream), so no other draw in the zone moves. Tuning defaults are the
spec's proposals; the code default TrojanProbability 0 draws none. Existing
annulus tests skip swarm rails. Builds.
Co-Authored-By: Claude Sonnet 5.5
---
Assets/Resources/Settings.asset | 4 +-
Assets/Scripts/ServerShared/Settings.cs | 8 ++++
Assets/Scripts/ServerShared/Zone.cs | 16 ++++---
Assets/Scripts/ServerShared/ZoneData.cs | 6 +++
Assets/Scripts/ServerShared/ZoneGenerator.cs | 44 ++++++++++++++++++-
.../ChildrenWithinReachTests.cs | 10 ++---
.../StationPlacementTests.cs | 14 +++---
7 files changed, 83 insertions(+), 19 deletions(-)
diff --git a/Assets/Resources/Settings.asset b/Assets/Resources/Settings.asset
index f4c21ce13..78735dab3 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:95d1ba64331895cc66862010f94a8cd4598ec9dd211a83b14b6091ac948afd56
-size 12327
+oid sha256:7a0a0d2e7b8367e7c402975e5683434068f99b8c13c848764bbac39b43886411
+size 12477
diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs
index 2f329e316..980828e53 100644
--- a/Assets/Scripts/ServerShared/Settings.cs
+++ b/Assets/Scripts/ServerShared/Settings.cs
@@ -126,6 +126,14 @@ public class ZoneGenerationSettings
public ExponentialCurve AsteroidCount;
public ExponentialLerp AsteroidRotationSpeed;
+ // Trojan swarms (ruling trojans-ride-host-period-with-stations): the chance a planet or gas giant with a body for a parent
+ // carries one, each swarm's length along the orbit (turns) and radial half-width (a fraction of the host's distance), and
+ // the rocks round each of L4 and L5 by host distance. 0 here: a settings object built in code draws none.
+ public float TrojanProbability = 0f;
+ public float TrojanArc = .07f;
+ public float TrojanHalfWidth = .06f;
+ public ExponentialCurve TrojanCount = new ExponentialCurve { Exponent = 1f, Multiplier = .04f, Constant = 8f };
+
public float ResourceDensityMinimum = .1f;
public float ResourceDensityMaximum = 1.5f;
diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs
index 4db63be46..f68d3a1a9 100644
--- a/Assets/Scripts/ServerShared/Zone.cs
+++ b/Assets/Scripts/ServerShared/Zone.cs
@@ -212,13 +212,11 @@ public class Zone
foreach (var body in PlanetInstances)
if (body.Value.WellStrength > 0)
wellMass.TryAdd(Planets[body.Key].Orbit.Key, body.Value.BodyData.Mass);
- // A belt's rail carries its asteroids, which spread beyond the rail's own distance: its extent is the outermost.
var beltExtent = new Dictionary();
foreach (var belt in AsteroidBelts)
{
- var extent = 0f;
- foreach (var asteroid in belt.Value.Data.Asteroids) extent = Math.Max(extent, asteroid.Distance);
- beltExtent[Planets[belt.Key].Orbit.Key] = extent;
+ var rail = Planets[belt.Key].Orbit.Key;
+ beltExtent[rail] = belt.Value.Extent(Orbits[rail].Data.Distance);
}
var wellNodes = new List();
foreach (var orbit in Orbits)
@@ -226,7 +224,7 @@ public class Zone
var data = orbit.Value.Data;
wellNodes.Add(new WellNode(orbit.Key, data.Parent.Key, data.Distance, data.Phase,
wellMass.TryGetValue(orbit.Key, out var mass) ? mass : 0f,
- beltExtent.TryGetValue(orbit.Key, out var reach) ? Math.Max(reach, data.Distance) : data.Distance,
+ beltExtent.TryGetValue(orbit.Key, out var reach) ? reach : data.Distance,
data.FixedPosition + OrbitData.Evaluate(frac(data.Phase)) * data.Distance)); // a root's stationary place: Offset at an infinite period
}
Wells = WellTree.Build(wellNodes, settings, pack.Radius);
@@ -239,7 +237,9 @@ public class Zone
var beltOrbit = Planets[belt.Key].Orbit.Key;
var beltParent = Orbits[beltOrbit].Data.Parent.Key;
for (var i = 0; i < belt.Value.Periods.Length; i++)
- belt.Value.Periods[i] = PeriodAround(beltParent, belt.Value.Data.Asteroids[i].Distance, beltOrbit);
+ belt.Value.Periods[i] = belt.Value.Data.RidesRail
+ ? Orbits[beltOrbit].Period // a swarm's rocks ride their rail (trojans-ride-host-period-with-stations)
+ : PeriodAround(beltParent, belt.Value.Data.Asteroids[i].Distance, beltOrbit);
}
PopulatedRadius = ComputePopulatedRadius();
@@ -882,6 +882,10 @@ public class AsteroidBelt
Radius = data.Asteroids.Max(a => a.Distance);
}
+ // What the belt's rail adds to its parent's L_F (WellTree): an annulus reaches its outermost rock, whose period reads the
+ // field there; a swarm riding its rail reaches only the rail, whose period every rock shares.
+ public float Extent(float railDistance) => Data.RidesRail ? railDistance : Math.Max(Radius, railDistance);
+
// Cut 1 (docs/mining-cut.md): the formula of Zone.cs:269-271 verbatim, `time` in double as `_time` was used.
// The only place a chunk pose is computed; no per-tick copy survives it. Cut 2: size is no longer read from
// per-belt storage -- Zone owns wear now, so the caller (Zone.ChunkPose/EvaluateBelt) computes it through
diff --git a/Assets/Scripts/ServerShared/ZoneData.cs b/Assets/Scripts/ServerShared/ZoneData.cs
index dd098862f..646347af6 100644
--- a/Assets/Scripts/ServerShared/ZoneData.cs
+++ b/Assets/Scripts/ServerShared/ZoneData.cs
@@ -105,6 +105,12 @@ public class AsteroidBeltData : BodyData
// it on first load through FieldKinds.Ensure, the same function generation uses.
[JsonProperty("kind"), Key(10)]
public CultRecordRef Kind;
+
+ // A Trojan swarm (ruling trojans-ride-host-period-with-stations): every rock rides the belt's rail at the rail's period, so
+ // the swarm keeps its shape. False on a belt from before this key (MessagePack leaves a missing trailing key at its default),
+ // which keeps each rock on its own distance's period: an annulus.
+ [JsonProperty("ridesRail"), Key(11)]
+ public bool RidesRail;
}
// Mining Cut 3 (docs/mining-cut-refresh.md, Q11): a kind of debris field, an authored catalog record. A chunk's
diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs
index c0c8cf16f..4d4c2613e 100644
--- a/Assets/Scripts/ServerShared/ZoneGenerator.cs
+++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs
@@ -49,7 +49,8 @@ public static class ZoneGenerator
var pack = new ZonePack();
var cache = itemManager.ItemData;
- var random = new Random(galaxyZone.Name.StableHash() ^ (uint) pcg3d(galaxyZone.Position).x);
+ var seed = galaxyZone.Name.StableHash() ^ (uint) pcg3d(galaxyZone.Position).x;
+ var random = new Random(seed);
var density = saturate(galaxy.Background.CloudDensity(galaxyZone.Position)/2);
pack.Radius = zoneSettings.ZoneRadius.Evaluate(density);
@@ -214,6 +215,29 @@ public static class ZoneGenerator
return cache.Upsert(planetData);
}).ToList();
+ // Trojan swarms (rulings trojans-first, trojans-ride-host-period-with-stations): a belt on a copy of its host's rail, its
+ // 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);
+ foreach (var host in planets.Where(p => p.Parent != null && !p.Parent.Empty && !p.Empty && !p.Belt && p.Distance > 0
+ && p.Mass > zoneSettings.PlanetMass && p.Mass <= zoneSettings.SunMass))
+ {
+ if (swarmRandom.NextFloat() >= zoneSettings.TrojanProbability) continue;
+ var hostOrbit = orbitMap[host];
+ var rail = new OrbitData { Parent = hostOrbit.Parent, Distance = hostOrbit.Distance, Phase = hostOrbit.Phase };
+ pack.Orbits.Add(cache.Upsert(rail));
+ var swarm = new AsteroidBeltData
+ {
+ Orbit = cache.RefOf(rail), RidesRail = true,
+ Asteroids = TrojanRocks(zoneSettings, ref swarmRandom, rail.Distance, rail.Phase)
+ };
+ BodyData swarmBody = swarm;
+ var swarmKey = cache.Upsert(swarmBody).Key;
+ swarm.Name = swarmKey.Value.Substring(0, 8);
+ FieldKinds.Ensure(swarmKey, swarm, cache);
+ pack.Planets.Add(cache.Upsert(swarmBody));
+ }
+
// 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.
var wellNodes = planets.Select(p => new WellNode(cache.RefOf(orbitMap[p]).Key, p.Parent == null ? default : cache.RefOf(orbitMap[p.Parent]).Key,
@@ -577,6 +601,24 @@ public static class ZoneGenerator
// limits out; a massive child of a body also far enough that its own pull peak beats CuspMargin times its parent's pull there;
// siblings sit SiblingSpacing of a reach apart. Every other number is a GravityField constant.
public const float RocheMargin = 1.5f;
+
+ private const uint TrojanStream = 0x9E3779B9u;
+
+ // A swarm's rocks: alternately round L4 and L5 of the rail at `distance` and `phase`, each within half an arc of its point
+ // along the orbit and a half-width of the rail across it, thickest at the point.
+ public static Asteroid[] TrojanRocks(ZoneGenerationSettings settings, ref Random random, float distance, float phase)
+ {
+ var rocks = new Asteroid[2 * (int) settings.TrojanCount.Evaluate(distance)];
+ for (var i = 0; i < rocks.Length; i++)
+ rocks[i] = new Asteroid
+ {
+ Distance = distance * (1 + random.NextFloat() * (random.NextFloat() - .5f) * 2 * settings.TrojanHalfWidth),
+ Phase = frac(phase + (i % 2 == 0 ? 1f : -1f) / 6f + random.NextFloat() * (random.NextFloat() - .5f) * settings.TrojanArc),
+ Size = random.NextFloat(),
+ RotationSpeed = settings.AsteroidRotationSpeed.Evaluate(random.NextFloat())
+ };
+ return rocks;
+ }
public const float CuspMargin = 1.5f;
public const float SiblingSpacing = .5f;
diff --git a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs
index 6f6dcb2b4..22646a129 100644
--- a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs
+++ b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs
@@ -79,11 +79,11 @@ public sealed class ChildrenWithinReachTests : IClassFixture BodyMass(Zone zone) =>
zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).GroupBy(b => b.Orbit.Key).ToDictionary(g => g.Key, g => g.First().Mass);
- // The generated orbits: every body's and its ancestors', which leaves out stations, turrets and Lagrange copies.
+ // The generated orbits: every body's and its ancestors', which leaves out stations, turrets, Lagrange copies and swarm rails (a copy of the host's rail, like a Lagrange copy).
private static HashSet Placed(Zone zone)
{
var placed = new HashSet();
- foreach (var body in zone.Planets.Values)
+ foreach (var body in zone.Planets.Values.Where(b => !(b is AsteroidBeltData { RidesRail: true })))
for (var key = body.Orbit.Key; placed.Add(key) && zone.Orbits[key].Data.Parent.IsSet();) key = zone.Orbits[key].Data.Parent.Key;
return placed;
}
@@ -332,7 +332,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture !b.Value.Data.RidesRail))
{
belts++;
var orbit = zone.Planets[beltKey].Orbit.Key;
@@ -374,7 +374,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture zone.Planets[b.Key].Orbit.Key, b => b.Value.Data.Asteroids);
+ var beltAt = zone.AsteroidBelts.Where(b => !b.Value.Data.RidesRail).ToDictionary(b => zone.Planets[b.Key].Orbit.Key, b => b.Value.Data.Asteroids);
foreach (var (orbit, rocks) in beltAt)
{
var parent = zone.Orbits[orbit].Data.Parent.Key;
@@ -424,7 +424,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture !b.Value.Data.RidesRail))
{
var rocks = belt.Data.Asteroids;
if (rocks.Length < 2) continue;
diff --git a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs
index 437e7c1b7..0157f6a70 100644
--- a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs
+++ b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs
@@ -81,6 +81,10 @@ public sealed class StationPlacementTests : IDisposable
private static float Turns(float turns) => turns - (float) Math.Floor(turns);
+ // A Trojan swarm's rail is a copy of its host's, not a station's source.
+ private static bool SwarmRail(Zone zone, CultRecordKey orbit) =>
+ zone.Planets.Values.Any(b => b is AsteroidBeltData { RidesRail: true } && b.Orbit.Key.Equals(orbit));
+
[Fact]
public void AStationSitsAtItsPlanetsL4OrL5()
{
@@ -96,7 +100,7 @@ public sealed class StationPlacementTests : IDisposable
foreach (var station in zone.Entities.OfType().Where(e => TypeOf(built.Items, e) == HullType.Station))
{
var orbit = zone.Orbits[station.OrbitData];
- var sources = zone.Orbits.Where(o => !ridden.Contains(o.Key) && SameRing(o.Value, orbit)).ToList();
+ var sources = zone.Orbits.Where(o => !ridden.Contains(o.Key) && !SwarmRail(zone, o.Key) && SameRing(o.Value, orbit)).ToList();
if (sources.Count == 0)
{
// The tutorial entrance's one exception: a station on its own orbit round a root node, beyond the node's ring
@@ -156,7 +160,7 @@ public sealed class StationPlacementTests : IDisposable
foreach (var station in Orbitals(built).Where(e => TypeOf(built.Items, e) == HullType.Station))
{
var orbit = zone.Orbits[station.OrbitData];
- var sources = zone.Orbits.Where(o => !ridden.Contains(o.Key) && SameRing(o.Value, orbit)).ToList();
+ var sources = zone.Orbits.Where(o => !ridden.Contains(o.Key) && !SwarmRail(zone, o.Key) && SameRing(o.Value, orbit)).ToList();
if (sources.Count == 0) continue; // the tutorial entrance's own orbit round a root
stations++;
Assert.Contains(bodyOrbits, key => key.Equals(sources[0].Key));
@@ -173,12 +177,12 @@ public sealed class StationPlacementTests : IDisposable
var placed = new HashSet();
foreach (var body in zone.Planets.Values)
for (var key = body.Orbit.Key; placed.Add(key) && zone.Orbits[key].Data.Parent.IsSet();) key = zone.Orbits[key].Data.Parent.Key;
- var beltExtent = zone.AsteroidBelts.ToDictionary(b => zone.Planets[b.Key].Orbit.Key, b => b.Value.Data.Asteroids.Select(a => a.Distance).DefaultIfEmpty(0f).Max());
+ var beltExtent = zone.AsteroidBelts.ToDictionary(b => zone.Planets[b.Key].Orbit.Key, b => b.Value.Extent(zone.Orbits[zone.Planets[b.Key].Orbit.Key].Data.Distance));
var nodes = placed.Select(k =>
{
var d = zone.Orbits[k].Data;
return new WellNode(k, d.Parent.Key, d.Distance, d.Phase, mass.TryGetValue(k, out var m) ? m : 0f,
- beltExtent.TryGetValue(k, out var e) ? Math.Max(e, d.Distance) : d.Distance, d.FixedPosition + OrbitData.Evaluate(frac(d.Phase)) * d.Distance);
+ beltExtent.TryGetValue(k, out var e) ? e : d.Distance, d.FixedPosition + OrbitData.Evaluate(frac(d.Phase)) * d.Distance);
}).ToList();
var wells = WellTree.Build(nodes, zone.Settings, zone.Pack.Radius);
return Math.Max(wells.Support(orbit), 1.5f * zone.Settings.RocheLimit.Evaluate(mass[orbit]));
@@ -353,7 +357,7 @@ public sealed class StationPlacementTests : IDisposable
foreach (var station in Orbitals(built).Where(e => TypeOf(built.Items, e) == HullType.Station))
{
var orbit = zone.Orbits[station.OrbitData];
- var strays = zone.Orbits.Count(o => !ridden.Contains(o.Key) && SameRing(o.Value, orbit));
+ var strays = zone.Orbits.Count(o => !ridden.Contains(o.Key) && !SwarmRail(zone, o.Key) && SameRing(o.Value, orbit));
Assert.True(strays == 1, $"seed {built.Seed}: a station's ring holds {strays} orbits no entity rides, not just its source's");
stations++;
}
From 9cdd885af06e02a2f72729acf8d3df9abcf82c8c Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 16:28:44 +0200
Subject: [PATCH 2/3] Trojan swarms: tests for the period rule, stations, the
unmoved zone and the save format
ZoneFingerprint pins a swarmless zone to digests computed at 67517f40 over seeds
1-6. Builds.
Co-Authored-By: Claude Sonnet 5.5
---
.../Aetheria.Shared.Tests/TrojanSwarmTests.cs | 150 ++++++++++++++++++
.../Aetheria.Shared.Tests/ZoneFingerprint.cs | 37 +++++
2 files changed, 187 insertions(+)
create mode 100644 tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs
create mode 100644 tests/Aetheria.Shared.Tests/ZoneFingerprint.cs
diff --git a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs
new file mode 100644
index 000000000..6691d6380
--- /dev/null
+++ b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs
@@ -0,0 +1,150 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using CultMath;
+using GameCult.Caching;
+using GameCult.Caching.MessagePack;
+using MessagePack;
+using Xunit;
+using Xunit.Abstractions;
+using static CultMath.math;
+using float2 = CultMath.float2;
+
+// Trojan swarms (rulings trojans-first, trojans-ride-host-period-with-stations; map B60, B61): a belt on a copy of its host's
+// rail whose rocks round L4 and L5 ride the rail's period, so the swarm keeps its shape, adds nothing to the well tree, and
+// leaves every other draw in the zone where it was.
+public class TrojanSwarmTests
+{
+ private const uint First = 1, Last = 6;
+ private readonly ITestOutputHelper _out;
+ public TrojanSwarmTests(ITestOutputHelper output) => _out = output;
+
+ private static bool IsSwarm(BodyData body) => body is AsteroidBeltData { RidesRail: true };
+
+ private static IEnumerable<(Orbit Host, Orbit Rail, AsteroidBelt Swarm)> Swarms(Zone zone)
+ {
+ foreach (var (key, belt) in zone.AsteroidBelts.Where(b => b.Value.Data.RidesRail))
+ {
+ var rail = zone.Orbits[zone.Planets[key].Orbit.Key];
+ 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);
+ }
+ }
+
+ private static HashSet SwarmRails(Zone zone) =>
+ zone.AsteroidBelts.Where(x => x.Value.Data.RidesRail).Select(x => zone.Planets[x.Key].Orbit.Key).ToHashSet();
+
+ private static float Turn(float2 p) => frac((float) (Math.Atan2(p.y, p.x) / (2 * Math.PI)));
+ private static float Wrap(float t) => t - (float) Math.Round(t);
+
+ [Fact]
+ public void ASwarmKeepsItsShapeOverAnHour()
+ {
+ using var zones = new ChildrenWithinReachTests.Zones(First, Last, null);
+ var s = zones.Settings;
+ int swarms = 0, l4 = 0, l5 = 0;
+ var drift = 0f;
+ foreach (var (seed, zone) in zones.All)
+ foreach (var (host, rail, swarm) in Swarms(zone))
+ {
+ swarms++;
+ var d = host.Data.Distance;
+ Assert.Equal(2 * (int) s.TrojanCount.Evaluate(d), swarm.Data.Asteroids.Length);
+ int sideA = 0, sideB = 0;
+ for (var i = 0; i < swarm.Data.Asteroids.Length; i++)
+ {
+ var rock = swarm.Data.Asteroids[i];
+ Assert.True(Math.Abs(rock.Distance / d - 1) <= s.TrojanHalfWidth * 1.0001f, $"seed {seed}: a rock sits {rock.Distance} from a rail at {d}");
+ float Offset(double t) => Wrap(Turn(swarm.Pose(i, t, float2.zero, 1).xy) - Turn(OrbitData.Evaluate((float) frac(t / host.Period + host.Data.Phase))));
+ var at0 = Offset(0);
+ var side = at0 > 0 ? 1f / 6 : -1f / 6;
+ if (side > 0) { l4++; sideA++; } else { l5++; sideB++; }
+ Assert.True(Math.Abs(at0 - side) <= s.TrojanArc / 2 + 1e-4f, $"seed {seed}: a rock sits {at0} turns from its host");
+ drift = Math.Max(drift, Math.Abs(Wrap(Offset(3600) - at0)));
+ }
+ Assert.Equal(sideA, sideB);
+ }
+ _out.WriteLine($"swarms {swarms}, rocks at L4 {l4}, L5 {l5}, largest drift against the host over an hour {drift} turns");
+ Assert.True(swarms >= 10, $"only {swarms} swarms in seeds {First}-{Last}");
+ Assert.True(l4 > 0 && l5 > 0);
+ Assert.True(drift < 1e-3f, $"a swarm rock drifts {drift} turns against its host in an hour");
+ }
+
+ [Fact]
+ public void StationsStillLandOnSwarmHosts()
+ {
+ using var zones = new ChildrenWithinReachTests.Zones(First, Last, null);
+ var onSwarmHost = 0;
+ foreach (var (seed, zone) in zones.All)
+ {
+ var hosts = Swarms(zone).Select(x => x.Host).ToHashSet();
+ foreach (var station in zone.Entities.OfType())
+ {
+ var orbit = zone.Orbits[station.OrbitData].Data;
+ if (hosts.Any(h => h.Data.Parent.Key.Equals(orbit.Parent.Key) && Math.Abs(h.Data.Distance - orbit.Distance) < .01f)) onSwarmHost++;
+ }
+ }
+ _out.WriteLine($"orbital entities on a swarm host's rail {onSwarmHost}");
+ Assert.True(onSwarmHost > 0, "no station or turret sits on a swarm host's rail");
+ }
+
+ // The digests were computed at 67517f40, before this cut, over the zones of each seed with the authored settings: a zone
+ // with no swarm is the base zone, and a zone with swarms is the base zone plus them.
+ private static readonly Dictionary BaseDigest = new Dictionary
+ {
+ [1] = "02a2bef42b5d4aeaf1e7004d0ab26620ee570ced95115abf98226ab4e0db23eb",
+ [2] = "de231f392e5724ecdce7e746957633bef2724252147ecbb5830db047b635aead",
+ [3] = "5c33fe9f748d6b0b34a7bec923216c7f9bbdfdf8f326c59b77fde9ec96ca2bcb",
+ [4] = "585284386657e4714ad325cb053a78853c5bcaf7eb1a02cbd4826b0e86ad3593",
+ [5] = "4a1aeecad09c61ff7daa43eda40a9c0109dc4b6833439b8400d8c72d939950c8",
+ [6] = "7fd3c5844144b73faf05cd6a53fe92274e4455f38da4aaca4624ea13d96a12c3",
+ };
+
+ [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);
+ Assert.Equal(swarmless.All.Count, live.All.Count);
+ int swarms = 0, grew = 0;
+ foreach (var seed in BaseDigest.Keys)
+ {
+ var a = swarmless.All.Where(x => x.Seed == seed).Select(x => x.Zone).ToList();
+ var b = live.All.Where(x => x.Seed == seed).Select(x => x.Zone).ToList();
+ Assert.Empty(a.SelectMany(z => z.AsteroidBelts.Where(x => x.Value.Data.RidesRail)));
+ Assert.Equal(BaseDigest[seed], ZoneFingerprint.Of(a, _ => false, _ => false));
+ string Swarmed(Zone z) => ZoneFingerprint.Of(new[] { z }, SwarmRails(z).Contains, IsSwarm);
+ for (var n = 0; n < a.Count; n++)
+ {
+ Assert.Equal(ZoneFingerprint.Of(new[] { a[n] }, _ => false, _ => false), Swarmed(b[n]));
+ swarms += SwarmRails(b[n]).Count;
+ Assert.True(b[n].PopulatedRadius >= a[n].PopulatedRadius, $"seed {seed}: a swarm shrank the populated radius");
+ if (b[n].PopulatedRadius > a[n].PopulatedRadius) grew++;
+ }
+ }
+ _out.WriteLine($"zones {live.All.Count}, swarms {swarms}, populated radius grown by a swarm's outer rock in {grew}");
+ Assert.True(swarms > 0);
+ }
+
+ // A pack saved before key 11 existed holds the belt as an 11-slot array; it reads as the annulus it was.
+ [Fact]
+ public void ABeltSavedBeforeTheFlagIsAnAnnulus()
+ {
+ var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(ZonePack).Assembly);
+ var saved = MessagePackSerializer.Serialize(new AsteroidBeltData { Mass = 5, Asteroids = new[] { new Asteroid { Distance = 3, Phase = .2f } } }, options);
+ Assert.Equal(0x9C, saved[0]); // a fixarray of 12 slots, keys 0-11
+ Assert.Equal(0xC2, saved[^1]); // its last slot, RidesRail, false
+ var old = saved[..^1];
+ old[0] = 0x9B;
+ var belt = MessagePackSerializer.Deserialize(old, options);
+ Assert.False(belt.RidesRail);
+ Assert.Equal(3f, belt.Asteroids[0].Distance);
+ Assert.Equal(7f, new AsteroidBelt(belt).Extent(7f));
+ Assert.Equal(3f, new AsteroidBelt(belt).Extent(2f));
+ var swarm = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(
+ new AsteroidBeltData { Asteroids = belt.Asteroids, RidesRail = true }, options), options);
+ Assert.True(swarm.RidesRail);
+ Assert.Equal(2f, new AsteroidBelt(swarm).Extent(2f));
+ }
+}
diff --git a/tests/Aetheria.Shared.Tests/ZoneFingerprint.cs b/tests/Aetheria.Shared.Tests/ZoneFingerprint.cs
new file mode 100644
index 000000000..3abc9ebc1
--- /dev/null
+++ b/tests/Aetheria.Shared.Tests/ZoneFingerprint.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using CultMath;
+using GameCult.Caching;
+using static CultMath.math;
+using float2 = CultMath.float2;
+
+// A digest of what a zone's generation and build decide, so a test can pin it to a value computed at an earlier commit:
+// every orbit's (Distance, Phase, Period, Reach, Support), every body's (type, Mass, rail Distance, belt rocks), the entity
+// count and GetHeight at three points round each non-belt body. Cache keys and names are left out; they are minted, not decided.
+public static class ZoneFingerprint
+{
+ public static string Of(IEnumerable zones, Func skipOrbit, Func skipBody)
+ {
+ var text = new StringBuilder();
+ foreach (var zone in zones) text.Append(Describe(zone, skipOrbit, skipBody)).Append('\n');
+ return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text.ToString()))).ToLowerInvariant();
+ }
+
+ private static string Describe(Zone z, Func skipOrbit, Func skipBody)
+ {
+ string F(float v) => v.ToString("R", System.Globalization.CultureInfo.InvariantCulture);
+ var orbits = z.Orbits.Where(o => !skipOrbit(o.Key))
+ .Select(o => $"{F(o.Value.Data.Distance)},{F(o.Value.Data.Phase)},{F(o.Value.Period)},{F(z.Wells.Reach(o.Key))},{F(z.Wells.Support(o.Key))}")
+ .OrderBy(x => x, StringComparer.Ordinal);
+ var bodies = z.Planets.Values.Where(b => !skipBody(b))
+ .Select(b => $"{b.GetType().Name},{F(b.Mass)},{F(z.Orbits[b.Orbit.Key].Data.Distance)}," +
+ (b is AsteroidBeltData belt ? string.Join("|", belt.Asteroids.Select(r => $"{F(r.Distance)}/{F(r.Phase)}/{F(r.Size)}")) : ""))
+ .OrderBy(x => x, StringComparer.Ordinal);
+ var heights = z.Planets.Values.Where(b => !(b is AsteroidBeltData)).Select(b => z.Orbits[b.Orbit.Key].Position)
+ .SelectMany(p => new[] { float2(0, 0), float2(37, 0), float2(0, -211) }.Select(o => F(z.GetHeight(p + o))));
+ return string.Join(";", orbits) + "#" + string.Join(";", bodies) + "#" + z.Entities.Count() + "#" + string.Join(",", heights);
+ }
+}
From de3ecd9bb03e92aa38923415551a19c3a1527b52 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:42:44 +0200
Subject: [PATCH 3/3] Trojan swarm tests: host class, rock side parity, width
and arc reach
Stryker survivors on the generator showed the host-mass gate, the side of each
rock and the width/arc scale were unpinned. Builds.
Co-Authored-By: Claude Sonnet 5.5
---
tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs
index 6691d6380..8500ff983 100644
--- a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs
+++ b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs
@@ -44,22 +44,28 @@ public class TrojanSwarmTests
using var zones = new ChildrenWithinReachTests.Zones(First, Last, null);
var s = zones.Settings;
int swarms = 0, l4 = 0, l5 = 0;
- var drift = 0f;
+ float drift = 0f, widest = 0f, longest = 0f;
foreach (var (seed, zone) in zones.All)
foreach (var (host, rail, swarm) in Swarms(zone))
{
swarms++;
var d = host.Data.Distance;
+ // The host is a planet or a gas giant: heavier than a moon, no heavier than a sun.
+ var hostMass = zone.Planets.Values.Where(b => !(b is AsteroidBeltData) && zone.Orbits[b.Orbit.Key] == host).Select(b => b.Mass).Single();
+ Assert.True(hostMass > s.PlanetMass && hostMass <= s.SunMass, $"seed {seed}: a swarm host of mass {hostMass}");
Assert.Equal(2 * (int) s.TrojanCount.Evaluate(d), swarm.Data.Asteroids.Length);
int sideA = 0, sideB = 0;
for (var i = 0; i < swarm.Data.Asteroids.Length; i++)
{
var rock = swarm.Data.Asteroids[i];
Assert.True(Math.Abs(rock.Distance / d - 1) <= s.TrojanHalfWidth * 1.0001f, $"seed {seed}: a rock sits {rock.Distance} from a rail at {d}");
+ widest = Math.Max(widest, Math.Abs(rock.Distance / d - 1));
float Offset(double t) => Wrap(Turn(swarm.Pose(i, t, float2.zero, 1).xy) - Turn(OrbitData.Evaluate((float) frac(t / host.Period + host.Data.Phase))));
var at0 = Offset(0);
var side = at0 > 0 ? 1f / 6 : -1f / 6;
if (side > 0) { l4++; sideA++; } else { l5++; sideB++; }
+ Assert.Equal(i % 2 == 0, side > 0); // even rocks round L4 (a sixth of a turn ahead), odd round L5
+ longest = Math.Max(longest, Math.Abs(at0 - side));
Assert.True(Math.Abs(at0 - side) <= s.TrojanArc / 2 + 1e-4f, $"seed {seed}: a rock sits {at0} turns from its host");
drift = Math.Max(drift, Math.Abs(Wrap(Offset(3600) - at0)));
}
@@ -68,6 +74,8 @@ public class TrojanSwarmTests
_out.WriteLine($"swarms {swarms}, rocks at L4 {l4}, L5 {l5}, largest drift against the host over an hour {drift} turns");
Assert.True(swarms >= 10, $"only {swarms} swarms in seeds {First}-{Last}");
Assert.True(l4 > 0 && l5 > 0);
+ Assert.True(widest > s.TrojanHalfWidth / 2, $"the widest rock sits {widest} off its rail, half-width {s.TrojanHalfWidth}");
+ Assert.True(longest > s.TrojanArc / 4, $"the farthest rock sits {longest} turns from its point, half-arc {s.TrojanArc / 2}");
Assert.True(drift < 1e-3f, $"a swarm rock drifts {drift} turns against its host in an hour");
}