Turret placement: turrets clear their inner neighbour by their own reach; tests for the tail, the story site, off-ring and no-turret zones

Each side of a station grows outward from it: a turret sits innerReach + 2 turretReach beyond the body inside it,
so turrets of differing reach clear each other (same arcs as before for one turret hull at k = -1, +1).
StationPlacementTests: shared flank check (no off-ring turret escapes it, 1e-4 relative arc), a narrative
station with five authored turrets and a second, larger turret hull, a zone with no turret hull, and a sweep
that no station or turret reaches another orbital. Not yet run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-16 03:54:24 +02:00
commit 89a481fd16
2 changed files with 159 additions and 52 deletions

View file

@ -269,16 +269,17 @@ public static class ZoneGenerator
return lagrangeOrbit;
}
// A turret co-orbits with its station, k arcs along the orbit (k is signed: behind the station when negative).
// One arc is the station's reach plus two of the turret's: the first turret's disc clears the station's by one
// turret radius, and turrets step by the same arc, which also clears each other (stationReach + 2 turretReach
// is more than 2 turretReach). Phase is in turns, so the arc length divides by the orbit's circumference.
void PlaceTurret(OrbitData orbit, LoadoutGenerator loadoutGenerator, int k, float stationReach)
// A turret co-orbits with its station, on one side of it (side is -1 behind, +1 ahead), clear of the body inside
// it on that side by one of the turret's own radii: its centre sits innerReach + 2 turretReach beyond that
// body's. Every body's reach counts, so turrets of differing reach clear each other. Phase is in turns, so the
// arc length divides by the orbit's circumference. Returns where the turret sits (signed arc from the station)
// and its reach, or null when there is no turret hull to generate, before any orbit is created.
(float arc, float reach)? PlaceTurret(OrbitData orbit, LoadoutGenerator loadoutGenerator, float side, float innerArc, float innerReach)
{
var turret = loadoutGenerator.GenerateTurretLoadout();
if (turret == null) return;
if (turret == null) return null;
var turretReach = (itemManager.GetData(turret.Hull) as HullData).Reach(itemManager.GameplaySettings.SchematicCellSize);
var arc = k * (stationReach + 2 * turretReach);
var arc = innerArc + side * (innerReach + 2 * turretReach);
var turretOrbit = new OrbitData
{
@ -289,13 +290,21 @@ public static class ZoneGenerator
pack.Orbits.Add(cache.Upsert(turretOrbit));
turret.Orbit = cache.RefOf(turretOrbit);
pack.Entities.Add(turret);
return (arc, turretReach);
}
// Turrets flank the station alternately: k runs -1, +1, -2, +2, ...
// Turrets flank the station alternately, behind then ahead, each side growing outward from the station.
void PlaceTurrets(OrbitData orbit, LoadoutGenerator loadoutGenerator, int count, float stationReach)
{
var arcs = new float[2];
var reaches = new[] { stationReach, stationReach };
for(int t=0; t<count; t++)
PlaceTurret(orbit, loadoutGenerator, (t / 2 + 1) * (t % 2 == 0 ? -1 : 1), stationReach);
{
var side = t % 2;
var placed = PlaceTurret(orbit, loadoutGenerator, side == 0 ? -1f : 1f, arcs[side], reaches[side]);
if (placed == null) return;
(arcs[side], reaches[side]) = placed.Value;
}
}
float StationReach(OrbitalEntityPack station) =>

View file

@ -27,9 +27,11 @@ public sealed class StationPlacementTests : IDisposable
private sealed record Built(uint Seed, Zone Zone, ItemManager Items);
private IEnumerable<Built> GeneratedZones()
// stock may rearrange the catalog before a galaxy is generated; storyTurrets > 0 gives every zone a story station that
// authors that many turrets (the '#turrets' tag), the way a narrative does.
private IEnumerable<Built> GeneratedZones(Action<ItemManager> stock = null, int storyTurrets = 0, uint seeds = 6, int zonesPerSeed = int.MaxValue)
{
for (uint seed = 1; seed <= 6; seed++)
for (uint seed = 1; seed <= seeds; seed++)
{
var dir = Path.Combine(_root, $"galaxy{seed}");
Directory.CreateDirectory(dir);
@ -46,8 +48,11 @@ public sealed class StationPlacementTests : IDisposable
authored.Read<NameGeneratorSettings>("NameGeneratorSettings"), cache, new PlayerSettings(),
Directory.CreateDirectory(Path.Combine(dir, "Narrative")), _ => { }, null, seed);
var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { });
foreach (var galaxyZone in galaxy.Zones)
stock?.Invoke(items);
foreach (var galaxyZone in galaxy.Zones.Take(zonesPerSeed))
{
if (storyTurrets > 0)
galaxyZone.Locations.Add(new LocationStory { Zone = galaxyZone, Name = "Test station", Type = LocationType.Station, Turrets = storyTurrets });
var pack = ZoneGenerator.GenerateZone(items, authored.Read<ZoneGenerationSettings>("ZoneSettings"), galaxy, galaxyZone,
galaxyZone == galaxy.Entrance && galaxy.IsPrelude, true);
var zone = new Zone(items, authored.Read<PlanetSettings>("PlanetSettings"), pack, galaxyZone, galaxy);
@ -101,57 +106,150 @@ public sealed class StationPlacementTests : IDisposable
Assert.True(ahead > 0 && behind > 0, $"the seeds put stations on one side only (ahead {ahead}, behind {behind})");
}
private static List<OrbitalEntity> Orbitals(Built built) => built.Zone.Entities.OfType<OrbitalEntity>().ToList();
// Every turret belongs to one station on its ring. Each side of a station grows outward from it: a turret's centre sits
// one body's reach (the one inside it) plus two of its own beyond that body's centre. Expectations are spelled from the
// zone's entities and their reaches, never from the generator's helpers. Returns the largest turret count of a station.
private static int AssertFlanks(Built built, ref int flanked, ref int mixedReach)
{
var orbitals = Orbitals(built);
var turrets = orbitals.Where(e => TypeOf(built.Items, e) == HullType.Turret).ToList();
var matched = 0;
var most = 0;
foreach (var station in orbitals.Where(e => TypeOf(built.Items, e) == HullType.Station))
{
var orbit = built.Zone.Orbits[station.OrbitData];
var mine = turrets.Where(t => SameRing(built.Zone.Orbits[t.OrbitData], orbit)).ToList();
matched += mine.Count;
if (mine.Count == 0) continue;
flanked++;
most = Math.Max(most, mine.Count);
var circumference = 2 * PI * orbit.Data.Distance;
// The arc is float phase arithmetic: its absolute error grows with the ring, so the tolerance does too.
var slack = circumference * 2e-7f;
var arcs = mine.Select(turret =>
{
var phase = Turns(built.Zone.Orbits[turret.OrbitData].Data.Phase - orbit.Data.Phase);
if (phase > .5f) phase -= 1;
return (turret, arc: phase * circumference);
}).ToList();
Assert.DoesNotContain(arcs, a => a.arc == 0f);
var behind = arcs.Where(a => a.arc < 0).OrderByDescending(a => a.arc).ToList();
var ahead = arcs.Where(a => a.arc > 0).OrderBy(a => a.arc).ToList();
// Turrets alternate behind then ahead, so behind holds the extra one when the count is odd.
Assert.Equal((mine.Count + 1) / 2, behind.Count);
Assert.Equal(mine.Count / 2, ahead.Count);
if (mine.Select(Contacts.Radius).Distinct().Count() > 1) mixedReach++;
foreach (var (side, sign) in new[] { (behind, -1f), (ahead, 1f) })
{
var innerArc = 0f;
var innerReach = Contacts.Radius(station);
foreach (var (turret, arc) in side)
{
var expected = innerArc + sign * (innerReach + 2 * Contacts.Radius(turret));
Assert.True(Math.Abs(arc - expected) <= 1e-4f * Math.Abs(expected) + slack,
$"seed {built.Seed}: a turret sits {arc} along its ring, not {expected}");
innerArc = expected;
innerReach = Contacts.Radius(turret);
}
}
var bodies = new List<OrbitalEntity> { station };
bodies.AddRange(mine);
for (var a = 0; a < bodies.Count; a++)
for (var b = a + 1; b < bodies.Count; b++)
Assert.True(length(bodies[a].Position.xz - bodies[b].Position.xz) > Contacts.Radius(bodies[a]) + Contacts.Radius(bodies[b]),
$"seed {built.Seed}: two bodies on one ring overlap");
}
// A turret off its station's ring is matched to no station and would be dropped from every check above.
Assert.True(matched == turrets.Count, $"seed {built.Seed}: {turrets.Count - matched} turrets sit on no station's ring");
return most;
}
[Fact]
public void TurretsFlankTheirStation()
{
var flanked = 0;
var pairsChecked = 0;
var mixed = 0;
foreach (var built in GeneratedZones())
{
var zone = built.Zone;
var orbitals = zone.Entities.OfType<OrbitalEntity>().ToList();
var turrets = orbitals.Where(e => TypeOf(built.Items, e) == HullType.Turret).ToList();
foreach (var station in orbitals.Where(e => TypeOf(built.Items, e) == HullType.Station))
{
var orbit = zone.Orbits[station.OrbitData];
var mine = turrets.Where(t => SameRing(zone.Orbits[t.OrbitData], orbit)).ToList();
if (mine.Count == 0) continue;
flanked++;
var stationReach = Contacts.Radius(station);
var circumference = 2 * PI * orbit.Data.Distance;
// The arc is float phase arithmetic: its absolute error grows with the ring, so the tolerance does too.
var slack = circumference * 2e-7f;
var ks = new List<int>();
foreach (var turret in mine)
{
var arcUnit = stationReach + 2 * Contacts.Radius(turret);
var phase = Turns(zone.Orbits[turret.OrbitData].Data.Phase - orbit.Data.Phase);
if (phase > .5f) phase -= 1;
var signedArc = phase * circumference;
var k = (int) Math.Round(signedArc / arcUnit);
Assert.True(k != 0, $"seed {built.Seed}: a turret sits on its station");
Assert.True(Math.Abs(signedArc - k * arcUnit) <= 1e-3f * arcUnit + slack,
$"seed {built.Seed}: a turret's arc {signedArc} is not a whole multiple ({k}) of {arcUnit}");
ks.Add(k);
}
var expected = Enumerable.Range(0, mine.Count).Select(t => (t / 2 + 1) * (t % 2 == 0 ? -1 : 1)).OrderBy(k => k);
Assert.Equal(expected, ks.OrderBy(k => k));
var bodies = new List<OrbitalEntity> { station };
bodies.AddRange(mine);
for (var a = 0; a < bodies.Count; a++)
for (var b = a + 1; b < bodies.Count; b++)
{
var apart = length(bodies[a].Position.xz - bodies[b].Position.xz);
Assert.True(apart > Contacts.Radius(bodies[a]) + Contacts.Radius(bodies[b]),
$"seed {built.Seed}: two bodies on one ring overlap ({apart} apart)");
pairsChecked++;
}
}
AssertFlanks(built, ref flanked, ref mixed);
// No station or turret reaches any other orbital entity.
var orbitals = Orbitals(built);
var placed = orbitals.Where(e => TypeOf(built.Items, e) is HullType.Station or HullType.Turret).ToList();
foreach (var body in placed)
foreach (var other in orbitals.Where(o => !ReferenceEquals(o, body)))
Assert.True(length(body.Position.xz - other.Position.xz) > Contacts.Radius(body) + Contacts.Radius(other),
$"seed {built.Seed}: a {TypeOf(built.Items, body)} overlaps a {TypeOf(built.Items, other)}");
}
Assert.True(flanked > 0, "the seeds generated no station with turrets");
Assert.True(pairsChecked > 0, "no pair was checked");
}
// A station that authors more turrets than the ambient two (a narrative's '#turrets' tag) takes the tail of the
// sequence, and turret hulls of differing reach each clear their neighbours: a second, larger turret hull is on offer.
[Fact]
public void ANarrativeStationsTurretsKeepFlankingAndClearEachOtherWhateverTheirReach()
{
const int authored = 5;
var flanked = 0;
var mixed = 0;
var most = 0;
foreach (var built in GeneratedZones(WithALargerTurretHull, authored, 3, 3))
most = Math.Max(most, AssertFlanks(built, ref flanked, ref mixed));
Assert.True(most == authored, $"no story station carried its {authored} authored turrets (most {most})");
Assert.True(mixed > 0, "no station carried turrets of differing reach");
}
// A zone with no turret hull on offer places no turret, and leaves no orbit behind for one.
[Fact]
public void ANoTurretZoneLeavesNoOrphanTurretOrbit()
{
var stations = 0;
foreach (var built in GeneratedZones(WithoutTurretHulls, 0, 3, 4))
{
var zone = built.Zone;
Assert.DoesNotContain(Orbitals(built), e => TypeOf(built.Items, e) == HullType.Turret);
var ridden = zone.Entities.OfType<OrbitalEntity>().Select(e => e.OrbitData).ToHashSet();
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));
Assert.True(strays == 1, $"seed {built.Seed}: a station's ring holds {strays} orbits no entity rides, not just its source's");
stations++;
}
}
Assert.True(stations > 0, "the seeds generated no station");
}
private static List<FactionProductData> TurretProducts(ItemManager items) =>
items.ItemData.GetAll<FactionProductData>().Where(p => items.ItemData.Get(p.Design) is HullData { HullType: HullType.Turret }).ToList();
// Priced at zero a design is not on offer, which is how the generator's own product filter drops it.
private static void WithoutTurretHulls(ItemManager items)
{
foreach (var product in TurretProducts(items)) ((HullData) items.ItemData.Get(product.Design)).Price = 0;
}
// The shipped catalog has one turret hull. A copy with a bigger solid shape (a larger reach) and its own product
// gives the generator two turret hulls to choose between.
private static void WithALargerTurretHull(ItemManager items)
{
var copyOf = typeof(object).GetMethod("MemberwiseClone", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
foreach (var product in TurretProducts(items))
{
var hull = (HullData) items.ItemData.Get(product.Design);
var larger = (HullData) copyOf.Invoke(hull, null);
larger.Name = hull.Name + " (large)";
larger.Shape = new Shape(hull.Shape.Width + 2, hull.Shape.Height + 2);
foreach (var cell in larger.Shape.AllCoordinates) larger.Shape[cell] = true;
var largerProduct = (FactionProductData) copyOf.Invoke(product, null);
largerProduct.Name = product.Name + " (large)";
largerProduct.Design = new CultRecordRef<CraftedItemData>(items.ItemData.Upsert(larger).Key);
items.ItemData.Upsert(largerProduct);
}
}
// The reach every spacing is built from: the farthest corner of any occupied cell from the centre of mass, scaled by