From 89a481fd16dac3320620e4e34ae9b49722a4fce1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 03:54:24 +0200 Subject: [PATCH] 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 --- Assets/Scripts/ServerShared/ZoneGenerator.cs | 27 ++- .../StationPlacementTests.cs | 190 +++++++++++++----- 2 files changed, 162 insertions(+), 55 deletions(-) diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 76c742b07..202e5e4e9 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -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 diff --git a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs index 1857b4ce6..8797133df 100644 --- a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs +++ b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs @@ -27,9 +27,11 @@ public sealed class StationPlacementTests : IDisposable private sealed record Built(uint Seed, Zone Zone, ItemManager Items); - private IEnumerable 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 GeneratedZones(Action 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"), 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("ZoneSettings"), galaxy, galaxyZone, galaxyZone == galaxy.Entrance && galaxy.IsPrelude, true); var zone = new Zone(items, authored.Read("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 Orbitals(Built built) => built.Zone.Entities.OfType().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 { 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().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(); - 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 { 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().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 TurretProducts(ItemManager items) => + items.ItemData.GetAll().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(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