- ItemManager(uint seed, ...) refuses seed 0 and seeds Random from it. - RunStart.ItemSeed(galaxy, step) is public (formula unchanged), refuses a zero result; named steps ArenaStep, StageStep, ContinueStep and NoRunSeed. - Continue seeds the item stream from the saved run's seed (ContinueStep); a scene with no galaxy uses NoRunSeed. - Zone's unpack scatter draws SimulationDice.For(CombatSeed, ScatterStream, 1-based pack ordinal), never the item stream. - Loadout's scratch manager and AetherDb's catalog command take fixed seeds; every test and Editor construction gains 1u (mechanical rewrite). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
515 lines
31 KiB
C#
515 lines
31 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using CultMath;
|
|
using GameCult.Caching;
|
|
using Xunit;
|
|
using Xunit.Abstractions;
|
|
using static CultMath.math;
|
|
using float2 = CultMath.float2;
|
|
|
|
// Aetheria gravity, turret placement: a station sits at its planet's L4 or L5 (a sixth of a turn along the ring) and
|
|
// its turrets flank it along the same ring, spaced by hull reach so that no two bodies overlap. The zones here are
|
|
// the production generator's, ambient on, over several galaxies; every expectation is read back from the generated
|
|
// zone (its orbits, its hulls) and spelled from the rule, never from the generator's own helpers.
|
|
public sealed class StationPlacementTests : IDisposable
|
|
{
|
|
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-stationplacement-" + Guid.NewGuid().ToString("N"));
|
|
private readonly List<CultCache> _caches = new List<CultCache>();
|
|
private int _generation;
|
|
|
|
private readonly ITestOutputHelper _output;
|
|
|
|
public StationPlacementTests(ITestOutputHelper output)
|
|
{
|
|
_output = output;
|
|
Directory.CreateDirectory(_root);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var cache in _caches) cache.Dispose();
|
|
Directory.Delete(_root, true);
|
|
}
|
|
|
|
private sealed record Built(uint Seed, Zone Zone, ItemManager Items);
|
|
|
|
// 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,
|
|
Action<ZoneGenerationSettings> tune = null, bool entranceOnly = false)
|
|
{
|
|
for (uint seed = 1; seed <= seeds; seed++)
|
|
{
|
|
var dir = Path.Combine(_root, $"galaxy{seed}-{_generation++}");
|
|
Directory.CreateDirectory(dir);
|
|
var catalog = Path.Combine(dir, "Aetheria.cc");
|
|
File.Copy(TestCatalog.Repo, catalog);
|
|
var cache = new CultCache(TestCatalog.Registry());
|
|
_caches.Add(cache);
|
|
cache.AddBackingStore(new GameCult.Caching.MessagePack.SingleFileMessagePackBackingStore(catalog), AetheriaStores.CatalogTypes);
|
|
cache.AddBackingStore(new GameCult.Caching.MessagePack.SingleFileMessagePackBackingStore(Path.Combine(dir, "run.cc")), AetheriaStores.RunTypes);
|
|
var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot());
|
|
var settings = authored.Read<GameplaySettings>("GameplaySettings");
|
|
var galaxy = new Galaxy(authored.Read<TutorialGenerationSettings>("TutorialGenerationSettings"),
|
|
authored.Read<SectorBackgroundSettings>("TutorialBackgroundSettings"),
|
|
cache, new PlayerSettings(),
|
|
Directory.CreateDirectory(Path.Combine(dir, "Narrative")), _ => { }, null, seed);
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { });
|
|
stock?.Invoke(items);
|
|
foreach (var galaxyZone in entranceOnly ? new[] { galaxy.Entrance } : galaxy.Zones.Take(zonesPerSeed))
|
|
{
|
|
if (storyTurrets > 0)
|
|
galaxyZone.Locations.Add(new LocationStory { Zone = galaxyZone, Name = "Test station", Faction = galaxy.Factions[0], Type = LocationType.Station, Turrets = storyTurrets });
|
|
var zoneSettings = authored.Read<ZoneGenerationSettings>("ZoneSettings");
|
|
tune?.Invoke(zoneSettings);
|
|
var pack = ZoneGenerator.GenerateZone(items, zoneSettings, authored.Read<PlanetSettings>("PlanetSettings"), galaxy, galaxyZone,
|
|
galaxyZone == galaxy.Entrance && galaxy.IsPrelude, true);
|
|
var zone = new Zone(items, authored.Read<PlanetSettings>("PlanetSettings"), pack, galaxyZone, galaxy);
|
|
zone.Update(.02f);
|
|
yield return new Built(seed, zone, items);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static HullType TypeOf(ItemManager items, OrbitalEntity e) => (items.GetData(e.Hull) as HullData).HullType;
|
|
|
|
private static bool SameRing(Orbit a, Orbit b) =>
|
|
a.Data.Parent.IsSet() && b.Data.Parent.IsSet() && a.Data.Parent.Key.Equals(b.Data.Parent.Key) &&
|
|
Math.Abs(a.Data.Distance - b.Data.Distance) < .01f;
|
|
|
|
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()
|
|
{
|
|
var checkedStations = 0;
|
|
var rootStations = 0;
|
|
var ahead = 0;
|
|
var behind = 0;
|
|
foreach (var built in GeneratedZones())
|
|
{
|
|
var zone = built.Zone;
|
|
// The source is the one ring orbit no orbital entity rides: a planet's, or a bodyless node's the generator picked.
|
|
var ridden = zone.Entities.OfType<OrbitalEntity>().Select(e => e.OrbitData).ToHashSet();
|
|
foreach (var station in zone.Entities.OfType<OrbitalEntity>().Where(e => TypeOf(built.Items, e) == HullType.Station))
|
|
{
|
|
var orbit = zone.Orbits[station.OrbitData];
|
|
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
|
|
// group (or, for a body, its whole family), at no Lagrange point.
|
|
var rootKey = orbit.Data.Parent.Key;
|
|
Assert.False(zone.Orbits[rootKey].Data.Parent.IsSet(), $"seed {built.Seed}: a station with no source orbit does not orbit a root node");
|
|
var innermost = zone.Orbits.Where(o => o.Value.Data.Parent.IsSet() && o.Value.Data.Parent.Key.Equals(rootKey) && zone.Wells.Reach(o.Key) > 0f && !o.Key.Equals(station.OrbitData))
|
|
.Select(o => o.Value.Data.Distance).DefaultIfEmpty(0f).Min();
|
|
Assert.True(orbit.Data.Distance >= zone.Wells.Support(rootKey) * .999f || orbit.Data.Distance >= (1 + GravityField.RingReach) * GravityField.Beta * innermost * .999f,
|
|
$"seed {built.Seed}: a root-node station at {orbit.Data.Distance} sits inside its node's family");
|
|
rootStations++;
|
|
continue;
|
|
}
|
|
Assert.True(sources.Count == 1, $"seed {built.Seed}: a station shares its ring with {sources.Count} unridden orbits, not one");
|
|
Assert.Contains(zone.Planets.Values, b => b.Orbit.Key.Equals(sources[0].Key) && !(b is AsteroidBeltData));
|
|
var planet = sources[0].Value;
|
|
|
|
var offset = Turns(orbit.Data.Phase - planet.Data.Phase);
|
|
var isAhead = Math.Abs(offset - 1f / 6f) < 1e-5f;
|
|
var isBehind = Math.Abs(offset - 5f / 6f) < 1e-5f;
|
|
Assert.True(isAhead || isBehind, $"seed {built.Seed}: a station sits {offset} turns from its planet, not 1/6 or 5/6");
|
|
if (isAhead) ahead++; else behind++;
|
|
|
|
var parent = zone.Orbits[orbit.Data.Parent.Key].Position;
|
|
var toPlanet = planet.Position - parent;
|
|
var toStation = station.Position.xz - parent;
|
|
var angle = Math.Acos(Math.Clamp(dot(toPlanet, toStation) / (length(toPlanet) * length(toStation)), -1f, 1f)) * 180 / Math.PI;
|
|
Assert.True(Math.Abs(angle - 60) < .01, $"seed {built.Seed}: planet-parent-station is {angle} degrees, not 60");
|
|
checkedStations++;
|
|
}
|
|
}
|
|
Assert.True(checkedStations > 0, "the seeds generated no station");
|
|
Assert.True(rootStations == 0 || rootStations < checkedStations, "every station is a root-node fallback");
|
|
Assert.True(ahead > 0 && behind > 0, $"the seeds put stations on one side only (ahead {ahead}, behind {behind})");
|
|
}
|
|
|
|
// A binary under a body is a bodyless node; the station's source is never such a node, however many there are. The authored
|
|
// settings leave none standing (every binary's ring folds into its node), so the zones are wide and light enough for rings to stand,
|
|
// with every satellite a binary: that is the sample in which the candidate rule's exclusion of a bodyless node does any work.
|
|
[Fact]
|
|
public void AStationNeverSitsOnABinarysBodylessNode()
|
|
{
|
|
var binaries = 0;
|
|
var stations = 0;
|
|
foreach (var built in GeneratedZones(null, 0, 4, int.MaxValue, z =>
|
|
{
|
|
z.SatelliteCreationProbability = 1;
|
|
z.BinaryCreationProbability = 1;
|
|
z.ZoneRadius.Minimum *= 3; z.ZoneRadius.Maximum *= 3;
|
|
z.ZoneMass.Minimum *= .1f; z.ZoneMass.Maximum *= .1f;
|
|
}))
|
|
{
|
|
var zone = built.Zone;
|
|
var bodyOrbits = zone.Planets.Values.Select(b => b.Orbit.Key).ToHashSet();
|
|
binaries += zone.Orbits.Count(o => o.Value.Data.Parent.IsSet() && !bodyOrbits.Contains(o.Key) && zone.Wells.Reach(o.Key) > 0f);
|
|
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 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));
|
|
}
|
|
}
|
|
Assert.True(binaries > 5 && stations > 5, $"{binaries} bodyless nodes and {stations} stations: the seeds do not exercise the rule");
|
|
}
|
|
|
|
// The zone's own well tree counts a station's rail as a member of its root's family, so the extent the generator checked
|
|
// (the bodies' alone) is rebuilt here from the generated bodies' orbits and their ancestors.
|
|
private static float ExtentOfBodies(Zone zone, CultRecordKey orbit)
|
|
{
|
|
var mass = zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).GroupBy(b => b.Orbit.Key).ToDictionary(g => g.Key, g => g.First().Mass);
|
|
var placed = new HashSet<CultRecordKey>();
|
|
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.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) ? 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]));
|
|
}
|
|
|
|
// How far a body keeps others out: its family's support, or 1.5 Roche limits (tail-taming-law-r3-band-loose-cusp) if more.
|
|
private static float BaseExtent(Zone zone, CultRecordKey orbit)
|
|
{
|
|
var body = zone.Planets.Values.FirstOrDefault(b => b.Orbit.Key.Equals(orbit) && !(b is AsteroidBeltData));
|
|
return Math.Max(zone.Wells.Support(orbit), body == null ? 0f : 1.5f * zone.Settings.RocheLimit.Evaluate(body.Mass));
|
|
}
|
|
|
|
// Over 64 instants of the longest period in the zone, no station or turret centre is within 1.5 Roche limits plus its
|
|
// reach of any body other than its base (station-orbit-choice-residuals item 1).
|
|
[Fact]
|
|
public void NoStationOrTurretSitsOnAnUnrelatedBody()
|
|
{
|
|
var checkedEntities = 0;
|
|
foreach (var built in GeneratedZones())
|
|
{
|
|
var zone = built.Zone;
|
|
var longest = zone.Orbits.Values.Select(o => o.Period).Where(float.IsFinite).DefaultIfEmpty(1f).Max();
|
|
var bodies = zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).ToList();
|
|
foreach (var entity in Orbitals(built).Where(e => TypeOf(built.Items, e) is HullType.Station or HullType.Turret))
|
|
{
|
|
var orbit = zone.Orbits[entity.OrbitData];
|
|
checkedEntities++;
|
|
foreach (var body in bodies)
|
|
{
|
|
var bodyOrbit = zone.Orbits[body.Orbit.Key];
|
|
if (SameRing(bodyOrbit, orbit)) continue; // its base
|
|
if (IsAncestorBody(zone, orbit, body.Orbit.Key)) continue; // the body it orbits round: it keeps its distance by its own Roche margin
|
|
var margin = 1.5f * zone.Settings.RocheLimit.Evaluate(body.Mass) + Contacts.Radius(entity);
|
|
for (var k = 0; k < 64; k++)
|
|
{
|
|
var t = k / 64.0 * longest;
|
|
Assert.True(length(zone.OrbitPositionAt(entity.OrbitData, t) - zone.OrbitPositionAt(body.Orbit.Key, t)) >= margin * .999f,
|
|
$"seed {built.Seed}: a {TypeOf(built.Items, entity)} on a ring of {orbit.Data.Distance} passes within {margin} of an unrelated body (mass {body.Mass}, ring {bodyOrbit.Data.Distance}, same parent {bodyOrbit.Data.Parent.Key.Equals(orbit.Data.Parent.Key)}) at instant {k}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Assert.True(checkedEntities > 0, "the seeds generated no station");
|
|
}
|
|
|
|
private static bool IsAncestorBody(Zone zone, Orbit orbit, CultRecordKey bodyOrbit)
|
|
{
|
|
for (var parent = orbit.Data.Parent; parent.IsSet(); parent = zone.Orbits[parent.Key].Data.Parent)
|
|
if (parent.Key.Equals(bodyOrbit)) return true;
|
|
return false;
|
|
}
|
|
|
|
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.
|
|
// (A side whose next turret would reach the base body stops, so the sides need not stay level.)
|
|
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);
|
|
}
|
|
}
|
|
|
|
// No turret reaches its base body: the planet whose Lagrange orbit the station rides (the ring's one unridden orbit).
|
|
var ridden = built.Zone.Entities.OfType<OrbitalEntity>().Select(e => e.OrbitData).ToHashSet();
|
|
var source = built.Zone.Orbits.Where(o => !ridden.Contains(o.Key) && SameRing(o.Value, orbit)).Select(o => o.Key).ToList();
|
|
if (source.Count == 1)
|
|
{
|
|
var extent = BaseExtent(built.Zone, source[0]);
|
|
foreach (var turret in mine)
|
|
Assert.True(length(turret.Position.xz - built.Zone.Orbits[source[0]].Position) >= (extent + Contacts.Radius(turret)) * .999f,
|
|
$"seed {built.Seed}: a turret sits within the base body's extent {extent} and its reach of the base");
|
|
}
|
|
|
|
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 mixed = 0;
|
|
foreach (var built in GeneratedZones())
|
|
{
|
|
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");
|
|
}
|
|
|
|
// 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()
|
|
{
|
|
var flanked = 0;
|
|
var mixed = 0;
|
|
foreach (var authored in new[] { 3, 5, 9 })
|
|
{
|
|
var most = 0;
|
|
foreach (var built in GeneratedZones(WithALargerTurretHull, authored, 6, 6))
|
|
most = Math.Max(most, AssertFlanks(built, ref flanked, ref mixed));
|
|
Assert.True(most > 0 && most <= authored, $"the most turrets a station carried is {most} of {authored} authored");
|
|
}
|
|
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) && !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++;
|
|
}
|
|
}
|
|
Assert.True(stations > 0, "the seeds generated no station");
|
|
}
|
|
|
|
|
|
// A side of a station grows outward until its next turret would sit within the base body's extent plus that turret's reach of
|
|
// the base (story-turret-tail-clearance). With a single wide turret hull on offer and sixteen story turrets a station
|
|
// reaches the base: a side that placed fewer than its half stopped, and its would-be next turret, spelled from the zone, is
|
|
// inside that distance. Ambient stations carry at most two turrets, so only a station with three or more is a story one.
|
|
[Fact]
|
|
public void ATurretTailStopsAtItsBaseBody()
|
|
{
|
|
const int turrets = 16;
|
|
int stations = 0, ambiguous = 0, sides = 0, stops = 0, nearBase = 0;
|
|
foreach (var built in GeneratedZones(WithOnlyAWiderTurretHull, turrets, 6))
|
|
{
|
|
var zone = built.Zone;
|
|
var orbitals = Orbitals(built);
|
|
var ridden = orbitals.Select(e => e.OrbitData).ToHashSet();
|
|
foreach (var station in orbitals.Where(e => TypeOf(built.Items, e) == HullType.Station))
|
|
{
|
|
var orbit = zone.Orbits[station.OrbitData];
|
|
var mine = orbitals.Where(e => TypeOf(built.Items, e) == HullType.Turret && SameRing(zone.Orbits[e.OrbitData], orbit)).ToList();
|
|
var source = zone.Orbits.Where(o => !ridden.Contains(o.Key) && SameRing(o.Value, orbit)).Select(o => o.Key).ToList();
|
|
if (mine.Count < 3 || source.Count != 1) continue;
|
|
var extent = BaseExtent(zone, source[0]);
|
|
var reach = Contacts.Radius(mine[0]);
|
|
var circumference = 2 * PI * orbit.Data.Distance;
|
|
var arcs = mine.Select(t => { var turn = Turns(zone.Orbits[t.OrbitData].Data.Phase - orbit.Data.Phase); return (turn > .5f ? turn - 1 : turn) * circumference; }).ToList();
|
|
// Half the ring away a turret's side is ambiguous (its signed arc wraps). A belt in the base's family has an extent the
|
|
// generator spelled from its authored width and the zone reads from its rocks, so the stop distance is not known to the
|
|
// test. Neither station is read.
|
|
bool InFamily(CultRecordKey key) { for (; zone.Orbits[key].Data.Parent.IsSet(); key = zone.Orbits[key].Data.Parent.Key) if (key.Equals(source[0])) return true; return key.Equals(source[0]); }
|
|
if (arcs.Any(a => Math.Abs(a) > .45f * circumference) || zone.AsteroidBelts.Any(b => InFamily(zone.Planets[b.Key].Orbit.Key))) { ambiguous++; continue; }
|
|
stations++;
|
|
foreach (var sign in new[] { -1f, 1f })
|
|
{
|
|
var side = arcs.Where(a => a * sign > 0).OrderBy(a => a * sign).ToList();
|
|
sides++;
|
|
foreach (var placed in side)
|
|
{
|
|
var at = 2 * orbit.Data.Distance * Math.Abs((float) Math.Sin(PI * (orbit.Data.Phase + placed / circumference - zone.Orbits[source[0]].Data.Phase)));
|
|
Assert.True(at >= (extent + reach) * .999f, $"seed {built.Seed}: a turret sits {at} from its base, inside {extent + reach}");
|
|
}
|
|
var wouldBe = (side.Count == 0 ? 0f : side[side.Count - 1]) + sign * ((side.Count == 0 ? Contacts.Radius(station) : reach) + 2 * reach);
|
|
var phase = orbit.Data.Phase + wouldBe / circumference;
|
|
var chord = 2 * orbit.Data.Distance * Math.Abs((float) Math.Sin(PI * (phase - zone.Orbits[source[0]].Data.Phase)));
|
|
if (side.Count >= turrets / 2) continue;
|
|
stops++;
|
|
Assert.True(chord < (extent + reach) * (1 + 1e-3f), $"seed {built.Seed}: a side stopped at {side.Count} turrets with its next turret still {chord} from the base, outside {extent + reach}");
|
|
if (chord >= .8f * (extent + reach)) nearBase++;
|
|
}
|
|
}
|
|
}
|
|
_output.WriteLine($"story stations {stations} (not read: {ambiguous}), sides {sides}, sides that stopped {stops}, of them with the next turret at 0.8 to 1 of the stop distance {nearBase}");
|
|
Assert.True(stops > 0 && nearBase > 0, $"{stops} stops, {nearBase} in the 0.8 band: the seeds do not exercise the rule");
|
|
}
|
|
|
|
// The entrance always gets a station. With no candidate orbit (a station hull so large that no rail clears a massive sibling
|
|
// by its reach) it orbits a body root outside that root's extent, not at a Lagrange point.
|
|
[Fact]
|
|
public void ATutorialEntranceWithNoCandidateOrbitsItsBodyRootOutsideItsExtent()
|
|
{
|
|
var fired = 0;
|
|
foreach (var built in GeneratedZones(WithAnUnofferedHugeStationHull, 0, 20, 1,
|
|
z => { z.RosetteProbability = 0; z.BinaryCreationProbability = 0; z.SatelliteCreationProbability = 0; }, true))
|
|
{
|
|
var zone = built.Zone;
|
|
var station = Orbitals(built).FirstOrDefault(e => TypeOf(built.Items, e) == HullType.Station);
|
|
if (station == null) continue;
|
|
var orbit = zone.Orbits[station.OrbitData];
|
|
var root = orbit.Data.Parent.IsSet() ? orbit.Data.Parent.Key : default;
|
|
if (!orbit.Data.Parent.IsSet() || zone.Orbits[root].Data.Parent.IsSet() || !zone.Planets.Values.Any(b => !(b is AsteroidBeltData) && b.Orbit.Key.Equals(root))) continue;
|
|
if (zone.Planets.Values.Any(b => SameRing(zone.Orbits[b.Orbit.Key], orbit))) continue;
|
|
fired++;
|
|
var extent = ExtentOfBodies(zone, root);
|
|
Assert.True(Math.Abs(orbit.Data.Distance - extent) <= 1e-4f * extent, $"seed {built.Seed}: the entrance station orbits its root at {orbit.Data.Distance}, not at its extent {extent}");
|
|
}
|
|
_output.WriteLine($"entrance zones where the fallback seated a station at a body root: {fired}");
|
|
Assert.True(fired > 0, "no entrance zone of seeds 1-20 took the fallback for a body root");
|
|
}
|
|
|
|
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) => AddATurretHull(items, 2, false);
|
|
|
|
// Only the wider hull is on offer: the shipped turret is priced at zero once the wider copy has its price.
|
|
private static void WithOnlyAWiderTurretHull(ItemManager items) => AddATurretHull(items, 6, true);
|
|
|
|
// A station hull with no product, so nothing builds it, but wide enough that its reach is the largest in the catalog.
|
|
private static void WithAnUnofferedHugeStationHull(ItemManager items)
|
|
{
|
|
var copyOf = typeof(object).GetMethod("MemberwiseClone", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
|
var hull = items.ItemData.GetAll<HullData>().First(h => h.HullType == HullType.Station);
|
|
var huge = (HullData) copyOf.Invoke(hull, null);
|
|
huge.Name = hull.Name + " (huge)";
|
|
huge.Shape = new Shape(20000, 1);
|
|
foreach (var cell in huge.Shape.AllCoordinates) huge.Shape[cell] = true;
|
|
items.ItemData.Upsert(huge);
|
|
}
|
|
|
|
private static void AddATurretHull(ItemManager items, int extraCells, bool only)
|
|
{
|
|
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 + extraCells, hull.Shape.Height + extraCells);
|
|
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);
|
|
if (only) hull.Price = 0;
|
|
}
|
|
}
|
|
|
|
// The reach every spacing is built from: the farthest corner of any occupied cell from the centre of mass, scaled by
|
|
// the cell size. Spelled here from that definition over the shipped station and turret hulls.
|
|
[Fact]
|
|
public void AHullsReachIsItsFarthestCorner()
|
|
{
|
|
var seen = new HashSet<string>();
|
|
foreach (var built in GeneratedZones().Take(6))
|
|
foreach (var entity in built.Zone.Entities.OfType<OrbitalEntity>())
|
|
{
|
|
var hull = built.Items.GetData(entity.Hull) as HullData;
|
|
if (!seen.Add(hull.Name)) continue;
|
|
var com = hull.Shape.CenterOfMass;
|
|
var farthest = 0f;
|
|
foreach (var c in hull.Shape.Coordinates)
|
|
foreach (var dx in new[] { -.5f, .5f })
|
|
foreach (var dy in new[] { -.5f, .5f })
|
|
farthest = Math.Max(farthest, (float) Math.Sqrt(Math.Pow(c.x + dx - com.x, 2) + Math.Pow(c.y + dy - com.y, 2)));
|
|
var cell = built.Items.GameplaySettings.SchematicCellSize;
|
|
Assert.True(Math.Abs(hull.Reach(cell) - farthest * cell) <= 1e-4f * farthest * cell, $"hull {hull.Name}: reach {hull.Reach(cell)} is not {farthest * cell}");
|
|
Assert.True(hull.Reach(cell) > 0f);
|
|
}
|
|
Assert.True(seen.Count >= 2, "the zones held fewer than a station hull and a turret hull");
|
|
}
|
|
}
|