Gravity: laned belts, a belt on a copy of a light body's rail filling its slot less a visual lane

Place records every child's slot; GenerateEntities marks belt candidates. After the swarm pass, a lane pass on its own
stream gives a candidate that kept its body, has a band at least the lane's half-width on both sides, carries no swarm and
whose parent has no other belt shape, an annulus on a copy of its rail: rocks over [slot inner, D - E] and [D + E, slot
outer], E = LaneBodyRadii body radii. ZoneGenerationSettings gains LaneProbability (0 in code, 0.5 authored) and
LaneBodyRadii (5). LanedBeltTests pins it; EveryBeltIsKindedAtGeneration moves there and asserts lanes; BeltShapes.Lanes
lands with it; both TrojanSwarmTests fixtures draw no lane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-21 22:42:49 +02:00
commit f102b082fd
7 changed files with 266 additions and 26 deletions

BIN
Assets/Resources/Settings.asset (Stored with Git LFS)

Binary file not shown.

View file

@ -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;

View file

@ -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<GeneratorPlanet>();
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<GeneratorPlanet>(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<GeneratorPlanet> Children = new List<GeneratorPlanet>(); // Planets orbiting this one are referred to as children

View file

@ -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);
}
}
}

View file

@ -88,7 +88,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture<ChildrenWithinReach
// The generated orbits: every body's and its ancestors', which leaves out stations, turrets, Lagrange copies and the copied
// rails swarms and lanes ride (a copy of the host's rail, like a Lagrange copy; BeltShapes.CopiedRail).
private static HashSet<CultRecordKey> Placed(Zone zone)
internal static HashSet<CultRecordKey> Placed(Zone zone)
{
var placed = new HashSet<CultRecordKey>();
foreach (var body in zone.Planets.Values.Where(b => !BeltShapes.CopiedRail(zone, b.Orbit.Key)))
@ -96,7 +96,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture<ChildrenWithinReach
return placed;
}
private static List<CultRecordKey> ChildrenOf(Zone zone, CultRecordKey parent, HashSet<CultRecordKey> placed) =>
internal static List<CultRecordKey> ChildrenOf(Zone zone, CultRecordKey parent, HashSet<CultRecordKey> 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<ChildrenWithinReach
private static PlanetSettings AuthoredPlanetSettings() => AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read<PlanetSettings>("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<ChildrenWithinReach
var secondInner = second.Distance - second.BeltHalfWidth;
_out.WriteLine($"first {first.Distance} +- {first.BeltHalfWidth}, second {second.Distance} +- {second.BeltHalfWidth}");
Assert.True(firstOuter * q <= secondInner * (1 + 1e-4f), $"the second belt's inner edge {secondInner} is within a spacing of the first's outer edge {firstOuter}");
// The slot Place records for the second belt starts at that edge (Place is the only writer of a slot).
Assert.True(Math.Abs(second.SlotInner - firstOuter * q) <= 1e-4f * second.SlotInner, $"the second belt's slot starts at {second.SlotInner}, not {firstOuter * q}");
}
// A rosette's ring round a root with no reach limit of its own sits at half the root's 0.75 x radius window, 3/8 of the radius.

View file

@ -0,0 +1,155 @@
using System;
using System.Linq;
using GameCult.Caching;
using Xunit;
using Xunit.Abstractions;
// Laned belts (rulings belt-shapes-direction, lanes-two-sided-visual-margin; map B60): a belt candidate that kept its body carries
// an annulus on a copy of its rail that fills the body's slot less a lane LaneBodyRadii body radii a side, with rocks on both
// sides; it is drawn from a stream of its own and sits inside the slot, so it moves nothing else in the zone. Seeds 1-6, authored
// settings, against the same seeds drawing no lane.
public sealed class LanedBeltTests : IClassFixture<LanedBeltTests.Samples>
{
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);
}
}

View file

@ -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<uint, string> BaseDigest = new Dictionary<uint, string>
{
@ -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)