Ruling census-tests-read-the-owners. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
965 lines
61 KiB
C#
965 lines
61 KiB
C#
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using CultMath;
|
|
using GameCult.Caching;
|
|
using GameCult.Caching.MessagePack;
|
|
using Xunit;
|
|
using static CultMath.math;
|
|
using float3 = CultMath.float3;
|
|
using Random = CultMath.Random;
|
|
|
|
// Cut 1 (docs/locomotion-cut.md, "Restore the legacy thruster ship hulls to the live catalog"). Verifies the
|
|
// content restore-hulls landed, against the real shipped catalog, read-only: the hulls exist with the expected
|
|
// thruster hardpoint counts, every thruster hardpoint accepts a catalog thruster design through TryEquip at its
|
|
// own position, and the ship then moves and turns for a few ticks under MovementDirection/Turn on the
|
|
// thrust allocator -- proof of the restored content, not of a controller this cut does not touch.
|
|
public sealed class RestoredHullsTests
|
|
{
|
|
// Soul's finding #1 (this cut's fix batch 3): a hand-set GameplaySettings fixture left thermal radiation and
|
|
// conduction at their bare-class defaults (1/1/1) against the shipped 3 / 1e-8 / 0.01
|
|
// (Assets/Resources/Settings.asset), so equipped parts froze toward 0 K, performance collapsed to 0 from
|
|
// cold, and TorqueMultiplier defaulting to 0 on top of that made every thruster's yaw contribution silently
|
|
// zero (Thruster.Column applies Torque*Thrust*TorqueMultiplier/Mass) -- velocity moved but Direction never
|
|
// turned, which looks exactly like a content defect instead of a fixture gap. AuthoredSettings
|
|
// (tools/AetherDb/AuthoredSettings.cs) is the same headless YAML reader tools/AetherDb already uses to
|
|
// inspect Settings.asset outside Unity; this loads the real GameplaySettings the same way instead of
|
|
// hand-copying its fields a second time.
|
|
internal static GameplaySettings Settings() => AuthoredSettings.Load(FindRepoRoot()).Read<GameplaySettings>("GameplaySettings");
|
|
|
|
// Same composition FireControlCut7Tests.OpenReadOnlyRealCatalog uses: a registry scoped to the shipped
|
|
// assembly's own [CultDocument] types, so this test's own process-wide type registration never pollutes the
|
|
// real catalog's validation the way the shared, AppDomain-scanning CultCache() default would.
|
|
private static CultCache OpenReadOnlyRealCatalog(string catalogPath)
|
|
{
|
|
var registry = TestCatalog.Registry();
|
|
var cache = new CultCache(registry);
|
|
cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath, true), AetheriaStores.CatalogTypes);
|
|
return cache;
|
|
}
|
|
|
|
internal static string FindRepoRoot()
|
|
{
|
|
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
|
|
if (File.Exists(Path.Combine(dir.FullName, "Aetheria.Shared", "Aetheria.Shared.csproj")))
|
|
return dir.FullName;
|
|
throw new DirectoryNotFoundException("Run from inside the Aetheria repository.");
|
|
}
|
|
|
|
internal static CultCache OpenCatalog() => OpenReadOnlyRealCatalog(TestCatalog.Repo);
|
|
|
|
// Shared tutorial-galaxy construction settings (Assets/Resources/Settings.asset's own values), factored out
|
|
// so every test that needs a real tutorial Galaxy -- not just EntranceZoneAlwaysGetsADockedStationAcrossSeeds
|
|
// -- builds it the same way instead of re-inlining the same ~20 fields.
|
|
internal static PlanetSettings AuthoredPlanetSettings() => AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read<PlanetSettings>("PlanetSettings");
|
|
|
|
internal static ZoneGenerationSettings TutorialZoneSettings() => new ZoneGenerationSettings
|
|
{
|
|
MassFloor = 1, SunMass = 5000, GasGiantMass = 1000, PlanetMass = 100,
|
|
SatellitePasses = 5, SatelliteCreationMassFloor = 100, SatelliteCreationProbability = .25f,
|
|
BinaryCreationProbability = .1f, RosetteProbability = .1f,
|
|
ZoneRadius = new ExponentialLerp { Exponent = 1.5f, Minimum = 1000, Maximum = 10000 },
|
|
ZoneMass = new ExponentialLerp { Exponent = 1.5f, Minimum = 10000, Maximum = 500000 },
|
|
SubZoneCount = new ExponentialLerp { Exponent = 1.5f, Minimum = 0, Maximum = 8 },
|
|
ZoneBoundaryRadius = .9f, BeltProbability = .25f, BeltMassCeiling = 500,
|
|
AsteroidBeltWidth = new ExponentialCurve { Exponent = .666f, Multiplier = 5, Constant = 50 },
|
|
AsteroidCount = new ExponentialCurve { Exponent = .5f, Multiplier = 1, Constant = 11 },
|
|
AsteroidRotationSpeed = new ExponentialLerp { Exponent = 2, Minimum = .1f, Maximum = .5f },
|
|
SunColorSaturation = .85f, SunSecondaryColorDistance = .33f, SunLightSaturation = .5f, SunFogTintSaturation = .55f,
|
|
GasGiantBandCount = new ExponentialLerp { Exponent = 2, Minimum = 5, Maximum = 8 },
|
|
GasGiantBandColorSeparation = .25f, GasGiantBandAltColorChance = .25f,
|
|
GasGiantBandSaturation = new ExponentialLerp { Exponent = .5f, Minimum = 0, Maximum = .8f },
|
|
GasGiantBandBrightness = new ExponentialLerp { Exponent = .25f, Minimum = .25f, Maximum = 1 },
|
|
NameData = new string[0],
|
|
};
|
|
|
|
internal static TutorialGenerationSettings TutorialGalaxySettings() => new TutorialGenerationSettings
|
|
{
|
|
ProtagonistFaction = "Miss", AntagonistFaction = "Zhe", BufferFaction = "Luc",
|
|
NeutralFactions = new[] { "Aero", "Finch" }, QuestFaction = "Adras", LinkDensity = .5f, ZoneCount = 64,
|
|
};
|
|
|
|
internal static SectorBackgroundSettings TutorialBackgroundSettings() => new SectorBackgroundSettings
|
|
{
|
|
NoiseAmplitude = 1, NoiseOffset = .3f, NoiseGain = .7f, NoiseLacunarity = 2, NoiseFrequency = .1f,
|
|
NoisePosition = 536.5106f, CloudExponent = 10, CloudAmplitude = .01f,
|
|
};
|
|
|
|
// A station's orbit is a copy of its source planet's own (Parent, Distance) (ZoneGenerator.CreateLagrangeOrbit),
|
|
// so it always matches that one planet. It matches MORE than one planet only when the source planet shares its
|
|
// distance with a sibling under the same parent -- exactly what ZoneGenerator's potentialLagrangePoints filter
|
|
// excludes. An entrance station whose orbit matches more than one planet orbit this way could therefore only
|
|
// have come from the widened candidate set, never from the ordinary (non-rosette) Lagrange selection.
|
|
//
|
|
// Soul's finding (this cut's fix batch 3): counting planet orbits as pack.Orbits.Count minus the
|
|
// OrbitalEntityPack count (the previous version of this method) over-counts whenever a station or turret
|
|
// loadout generator returns null -- CreateLagrangeOrbit already appended that orbit to pack.Orbits before the
|
|
// null check (ZoneGenerator.cs) skips adding the entity, so the subtraction no longer matches. Deriving the
|
|
// set from pack.Planets' own Orbit refs instead (Census.cs's own approach) sidesteps that entirely: every
|
|
// real planet, including an ordinary rosette member, names its own orbit directly. Only a rosette ROOT is
|
|
// excluded this way (it gets no BodyData/PlanetData entry at all), which is fine here -- a root never shares
|
|
// its own Parent's distance with itself, so it was never a sibling candidate for `orbit` in the first place.
|
|
private static bool IsRosetteMember(CultCache cache, ZonePack pack, OrbitData orbit)
|
|
{
|
|
if (!orbit.Parent.IsSet()) return false;
|
|
var planetOrbits = pack.Planets.Select(p => cache.Get(cache.Get(p).Orbit)).ToArray();
|
|
return planetOrbits.Count(o => o.Parent.IsSet() && o.Parent.Key.Equals(orbit.Parent.Key) && abs(o.Distance - orbit.Distance) < .1f) > 1;
|
|
}
|
|
|
|
// The zone a test needs is chosen by the property its claim is about, from the galaxy the generator rolled, because
|
|
// which zone has that property follows the noise kernel (CultMath 0.3.0 re-rolled "EAC-7089" and "EAC-2733" away).
|
|
// A planet orbit is a genuine Lagrange candidate when it is the only planet at its distance under its parent --
|
|
// exactly what ZoneGenerator's potentialLagrangePoints filter keeps.
|
|
private static OrbitData[] ParentedPlanetOrbits(CultCache cache, ZonePack pack) =>
|
|
pack.Planets.Select(p => cache.Get(cache.Get(p).Orbit)).Where(o => o.Parent.IsSet()).ToArray();
|
|
|
|
private static bool HasGenuineLagrangeCandidate(OrbitData[] planetOrbits) =>
|
|
planetOrbits.Any(o => planetOrbits.Count(c => c.Parent.Key.Equals(o.Parent.Key) && abs(c.Distance - o.Distance) < .1f) == 1);
|
|
|
|
// The first seed, and in its tutorial galaxy the first non-entrance zone, that has planets every one of which is a
|
|
// rosette member: no genuine Lagrange candidate, so only the entrance's widened candidate set can seat a station
|
|
// there. Such a zone is rare, so seeds are tried in order until one has it.
|
|
// The scan is the cost (tens of seconds), and it depends only on the generator, so both tests share one result
|
|
// through a lazy computed under its own scratch run store.
|
|
private static readonly Lazy<(uint seed, string zoneName)> NoLagrangeCandidateZoneScan = new(ScanForNoLagrangeCandidateZone);
|
|
|
|
private static (uint seed, string zoneName) NoLagrangeCandidateZone() => NoLagrangeCandidateZoneScan.Value;
|
|
|
|
private static (uint seed, string zoneName) ScanForNoLagrangeCandidateZone()
|
|
{
|
|
var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-nolagrange-scan-{Guid.NewGuid():N}.cc");
|
|
try
|
|
{
|
|
using var cache = OpenReadOnlyRealCatalogWithScratchRun(TestCatalog.Repo, scratchRun);
|
|
return ScanForNoLagrangeCandidateZone(cache);
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(scratchRun)) File.Delete(scratchRun);
|
|
}
|
|
}
|
|
|
|
private static (uint seed, string zoneName) ScanForNoLagrangeCandidateZone(CultCache cache)
|
|
{
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { });
|
|
for (var seed = 1u; seed <= 64u; seed++)
|
|
{
|
|
var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed);
|
|
foreach (var zone in galaxy.Zones.Where(z => !ReferenceEquals(z, galaxy.Entrance)))
|
|
{
|
|
var orbits = ParentedPlanetOrbits(cache, ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), AuthoredPlanetSettings(), galaxy, zone, isTutorial: false, ambient: false));
|
|
if (orbits.Length > 0 && !HasGenuineLagrangeCandidate(orbits)) return (seed, zone.Name);
|
|
}
|
|
}
|
|
Assert.Fail("fixture: no tutorial galaxy at seeds 1-64 has a zone whose planets are all rosette members.");
|
|
return default;
|
|
}
|
|
|
|
// ZoneGenerator.GenerateZone writes OrbitData/BodyData (run-store types, AetheriaStores.RunTypes), so a
|
|
// catalog-only cache has no home for them. A scratch run file, deleted after the seed that used it.
|
|
private static CultCache OpenReadOnlyRealCatalogWithScratchRun(string catalogPath, string runPath)
|
|
{
|
|
var registry = TestCatalog.Registry();
|
|
var cache = new CultCache(registry);
|
|
cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath, true), AetheriaStores.CatalogTypes);
|
|
cache.AddBackingStore(new SingleFileMessagePackBackingStore(runPath), AetheriaStores.RunTypes);
|
|
return cache;
|
|
}
|
|
|
|
// Headless asset-reference rule: every hull's Prefab, every ThrusterData.ParticlesPrefab, and every
|
|
// non-empty Schematic names a GUID that actually exists under Assets/ (a .meta file carries it). Unity's
|
|
// EngineAssetCheck enforces this too, but only in-editor; this is the same check run headless against the
|
|
// shipped catalog, so a fabricated or dropped GUID fails a build that never opens Unity. Soul's own probe
|
|
// (commit message, this cut's fix batch) found Djinni.Prefab fabricated and all five restored thrusters'
|
|
// ParticlesPrefab null; this test is that probe, committed so it can be rerun and mutated.
|
|
[Fact]
|
|
public void EveryAssetReferenceResolvesToAMetaGuid()
|
|
{
|
|
var assetGuids = new HashSet<string>(Directory.EnumerateFiles(Path.Combine(FindRepoRoot(), "Assets"), "*.meta", SearchOption.AllDirectories)
|
|
.Select(f => File.ReadLines(f).FirstOrDefault(l => l.StartsWith("guid: "))?.Substring("guid: ".Length).Trim())
|
|
.Where(g => g != null));
|
|
|
|
using var cache = OpenCatalog();
|
|
var missing = new List<string>();
|
|
// A hull names one body (ShipAuthoringStore.RequireOneBody): a Unity prefab, or a mod ship's visual record, which
|
|
// must be in the catalog under its own mod-ship key.
|
|
foreach (var hull in cache.GetAll<HullData>())
|
|
{
|
|
if (hull.Visual.IsSet())
|
|
{
|
|
var visual = cache.Get(hull.Visual);
|
|
if (visual == null || !hull.Visual.Key.Equals(ShipModCatalog.AuthoringKey(visual.Id)))
|
|
missing.Add($"{hull.Name}.Visual = {hull.Visual.Key.Value}");
|
|
if (!string.IsNullOrWhiteSpace(hull.Prefab))
|
|
missing.Add($"{hull.Name} names both Prefab {hull.Prefab} and Visual {hull.Visual.Key.Value}");
|
|
}
|
|
else if (!assetGuids.Contains(hull.Prefab ?? ""))
|
|
missing.Add($"{hull.Name}.Prefab = {hull.Prefab ?? "null"}");
|
|
}
|
|
foreach (var design in cache.GetAll<EquippableItemData>())
|
|
{
|
|
foreach (var thruster in design.Behaviors.OfType<ThrusterData>())
|
|
if (!assetGuids.Contains(thruster.ParticlesPrefab ?? ""))
|
|
missing.Add($"{design.Name}.ParticlesPrefab = {thruster.ParticlesPrefab ?? "null"}");
|
|
if (!string.IsNullOrEmpty(design.Schematic) && !assetGuids.Contains(design.Schematic))
|
|
missing.Add($"{design.Name}.Schematic = {design.Schematic}");
|
|
}
|
|
|
|
Assert.True(missing.Count == 0, "Dangling asset references:\n" + string.Join("\n", missing));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Longinus", 4)]
|
|
[InlineData("Djinni", 8)]
|
|
public void RestoredHullExistsWithExpectedThrusterHardpointCount(string name, int expectedThrusterHardpoints)
|
|
{
|
|
using var cache = OpenCatalog();
|
|
var hull = cache.GetByName<HullData>(name);
|
|
Assert.NotNull(hull);
|
|
Assert.Equal(HullType.Ship, hull.HullType);
|
|
Assert.Equal(expectedThrusterHardpoints, hull.Hardpoints.Count(h => h.Type == HardpointType.Thruster));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Longinus")]
|
|
[InlineData("Djinni")]
|
|
public void EveryThrusterHardpointAcceptsACatalogThrusterAtItsOwnPosition(string name)
|
|
{
|
|
using var cache = OpenCatalog();
|
|
var hull = cache.GetByName<HullData>(name);
|
|
var thrusterDesigns = cache.GetAll<GearData>().Where(g => g.Hardpoint == HardpointType.Thruster).ToArray();
|
|
Assert.NotEmpty(thrusterDesigns);
|
|
|
|
var settings = Settings();
|
|
var ledger = new ProvenanceLedger();
|
|
for (var i = 1; i <= hull.Hardpoints.Count + 1; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = .5f };
|
|
var items = new ItemManager(1u, cache, ledger, settings, _ => { });
|
|
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null);
|
|
var hullItem = new EquippableItem { Data = cache.RefOf<ItemData>(hull), Durability = hull.Durability, Lot = 1 };
|
|
var ship = new Ship(items, zone, hullItem, settings.DefaultEntitySettings);
|
|
|
|
var lot = 2;
|
|
foreach (var hardpoint in hull.Hardpoints.Where(h => h.Type == HardpointType.Thruster))
|
|
{
|
|
var cells = hardpoint.Shape.Coordinates.Length;
|
|
var design = thrusterDesigns.FirstOrDefault(hardpoint.IsFilledBy);
|
|
Assert.True(design != null, $"{name}'s {hardpoint.Transform ?? hardpoint.Type.ToString()} hardpoint at {hardpoint.Position} has no fitting catalog thruster design (needs {cells} cells).");
|
|
var gearItem = new EquippableItem { Data = cache.RefOf<ItemData>(design), Durability = design.Durability, Lot = lot++ };
|
|
Assert.True(ship.TryEquip(gearItem, hardpoint.Position),
|
|
$"{name}'s {hardpoint.Transform ?? hardpoint.Type.ToString()} hardpoint at {hardpoint.Position} refused {design.Name} via TryEquip.");
|
|
}
|
|
}
|
|
|
|
// The dock intro dereferences the ship's VelocityLimit unguarded (ActionGameManager), so every ship hull the catalog
|
|
// sells (LoadoutGenerator.SoldHulls) carries exactly one, with a top speed that is a finite positive number. After the retirement
|
|
// (ruling retire-longinusx) the shipped ship hulls are the Longinus and the Djinni.
|
|
[Fact]
|
|
public void EveryShippedShipHullHasATopSpeed()
|
|
{
|
|
using var cache = OpenCatalog();
|
|
var ships = LoadoutGenerator.SoldHulls(cache).Where(h => h.HullType == HullType.Ship).OrderBy(h => h.Name, StringComparer.Ordinal).ToArray();
|
|
Assert.Equal(new[] { "Djinni", "Longinus" }, ships.Select(h => h.Name));
|
|
foreach (var hull in ships)
|
|
{
|
|
Assert.Single(hull.Behaviors.OfType<VelocityLimitData>());
|
|
var limit = BuildThrustedShip(cache, hull.Name).GetBehavior<VelocityLimit>().Limit;
|
|
Assert.True(float.IsFinite(limit) && limit > 0f, $"{hull.Name}'s top speed is {limit}");
|
|
}
|
|
}
|
|
|
|
// For a fixture that pins flight, not brownout: the thrusters draw no power. A ship with every thruster firing asks
|
|
// for more than its reactor gives, and a brownout scales the thrusters' thrust unevenly (follow-up
|
|
// allocator-brownout-columns), so the turn or heading a test reads would be the brownout's.
|
|
internal static void FreeThrusterPower(CultCache cache)
|
|
{
|
|
foreach (var gear in cache.GetAll<GearData>())
|
|
foreach (var thruster in gear.Behaviors.OfType<ThrusterData>())
|
|
thruster.EnergyUsage = new PerformanceStat { Min = 0, Max = 0 };
|
|
}
|
|
|
|
// A restored hull with every thruster hardpoint (and its reactor, and the first hardpoint of each type in
|
|
// alsoFill) filled, activated and warmed up: the rotation thrusters the helm tests need. The caller owns the cache.
|
|
internal static Ship BuildThrustedShip(CultCache cache, string name, params HardpointType[] alsoFill)
|
|
{
|
|
var hull = cache.GetByName<HullData>(name);
|
|
var thrusterDesigns = cache.GetAll<GearData>().Where(g => g.Hardpoint == HardpointType.Thruster).ToArray();
|
|
|
|
var settings = Settings();
|
|
var ledger = new ProvenanceLedger();
|
|
for (var i = 1; i <= hull.Hardpoints.Count + 1; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = .5f };
|
|
var items = new ItemManager(1u, cache, ledger, settings, _ => { });
|
|
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null);
|
|
var hullItem = new EquippableItem { Data = cache.RefOf<ItemData>(hull), Durability = hull.Durability, Lot = 1 };
|
|
var ship = new Ship(items, zone, hullItem, settings.DefaultEntitySettings);
|
|
|
|
var lot = 2;
|
|
foreach (var hardpoint in hull.Hardpoints.Where(h => h.Type == HardpointType.Thruster))
|
|
{
|
|
var design = thrusterDesigns.First(hardpoint.IsFilledBy);
|
|
var gearItem = new EquippableItem { Data = cache.RefOf<ItemData>(design), Durability = design.Durability, Lot = lot++ };
|
|
Assert.True(ship.TryEquip(gearItem, hardpoint.Position));
|
|
}
|
|
|
|
// A reactor: Racing Drive (one of Cut 1's own restored thrusters, docs/locomotion-cut.md's own note on it)
|
|
// carries a real EnergyUsage draw unlike its four siblings, so an unpowered ship reads as though only
|
|
// some fitting thrusters produce thrust -- an artifact of which design TryEquip's own hardpoint search
|
|
// happens to land on, not of the restored content. Every hull here also has a Reactor hardpoint; a real
|
|
// ship always carries one, so this fixture should too.
|
|
// Types named by the caller (a radiator, say) are filled the same way, one hardpoint each; a ship cannot be
|
|
// equipped once it is active.
|
|
foreach (var type in new[] { HardpointType.Reactor }.Concat(alsoFill))
|
|
{
|
|
var filled = hull.Hardpoints.FirstOrDefault(h => h.Type == type);
|
|
if (filled == null) continue;
|
|
var filler = cache.GetAll<GearData>().First(filled.IsFilledBy);
|
|
var fillerItem = new EquippableItem { Data = cache.RefOf<ItemData>(filler), Durability = filler.Durability, Lot = lot++ };
|
|
Assert.True(ship.TryEquip(fillerItem, filled.Position));
|
|
}
|
|
|
|
zone.Admit(ship, piloted: false);
|
|
ship.Position = float3.zero;
|
|
zone.Update(0f); // warm-up: resolves equipment stats before the driven ticks read them
|
|
return ship;
|
|
}
|
|
|
|
// "The ship then moves and turns... for a few ticks" (docs/locomotion-cut.md verification list). Equips
|
|
// every thruster hardpoint with a fitting design, then drives MovementDirection/Turn the same way
|
|
// ActionGameManager's player-input path does (docs' own consumer audit) and checks Entity.Velocity and
|
|
// Entity.Direction both move over a few ticks under the thrust allocator.
|
|
[Theory]
|
|
[InlineData("Longinus")]
|
|
[InlineData("Djinni")]
|
|
public void RestoredHullMovesAndTurnsUnderMovementAndTurn(string name)
|
|
{
|
|
using var cache = OpenCatalog();
|
|
var ship = BuildThrustedShip(cache, name);
|
|
var zone = ship.Zone;
|
|
|
|
var startVelocity = ship.Velocity;
|
|
var startDirection = ship.Direction;
|
|
|
|
ship.MovementDirection = float2(0, 1); // full forward, same axis ActionGameManager.Input.Player.Move drives
|
|
ship.Turn = 1; // a turn command, so the allocator's yaw thrusters have work to do
|
|
for (var i = 0; i < 10; i++) zone.Update(1f / 60f);
|
|
|
|
Assert.NotEqual(startVelocity, ship.Velocity);
|
|
Assert.NotEqual(startDirection, ship.Direction);
|
|
}
|
|
|
|
// Operator ruling, 2026-09-25 (docs/locomotion-cut.md, "Tutorial station"): "What we're testing is going to
|
|
// be the tutorial level, right? Let's drop a station in there." The tutorial's entrance zone always gets a
|
|
// generated faction station with a docking bay, deterministically -- not merely likely. This REPLACES
|
|
// TutorialZoneGenerationCanProduceADockedStation, which never used the tutorial construction path at all: it
|
|
// built an ad hoc single-zone SavedGame/Galaxy and asserted a docking bay showed up in at least one of 25
|
|
// zone-name variants, which is a claim about the BACKGROUND station path in general, not about the entrance
|
|
// zone Galaxy.cs actually picks (lowest-influence, unowned -- the one case ZoneGenerator.cs's own stationCount
|
|
// roll produces 0 on essentially every seed, docs' own probe: 30 of 30). This test builds the real tutorial
|
|
// Galaxy exactly as Assets/Resources/Settings.asset configures it and ActionGameManager.StartGame ->
|
|
// PopulateLevel(CurrentGalaxy.Entrance) generates it, across seeds, and requires a docked station in every
|
|
// run. It must fail without ZoneGenerator's tutorial-entrance override.
|
|
//
|
|
// Also requires that the NORMAL case (a zone with a real Lagrange candidate, the common case across 25 seeds)
|
|
// seats its station on a genuine, non-rosette Lagrange orbit rather than the widened fallback -- the widened
|
|
// candidate set is for the rare zone with none, not a replacement for ordinary placement.
|
|
[Fact]
|
|
public void EntranceZoneAlwaysGetsADockedStationAcrossSeeds()
|
|
{
|
|
var zoneSettings = TutorialZoneSettings();
|
|
var tutorialSettings = TutorialGalaxySettings();
|
|
var background = TutorialBackgroundSettings();
|
|
var gameData = TestCatalog.Repo;
|
|
var sawGenuineLagrangeOrbit = false;
|
|
for (uint seed = 1; seed <= 25; seed++)
|
|
{
|
|
var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-station-{Guid.NewGuid():N}.cc");
|
|
try
|
|
{
|
|
using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun);
|
|
var galaxy = new Galaxy(tutorialSettings, background, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed);
|
|
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { });
|
|
var pack = ZoneGenerator.GenerateZone(items, zoneSettings, AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true);
|
|
|
|
var dockedStation = pack.Entities.OfType<OrbitalEntityPack>()
|
|
.FirstOrDefault(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0);
|
|
Assert.True(dockedStation != null, $"seed {seed}: the tutorial entrance zone generated no station with a docking bay.");
|
|
|
|
if (!IsRosetteMember(cache, pack, cache.Get(dockedStation.Orbit)))
|
|
sawGenuineLagrangeOrbit = true;
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(scratchRun)) File.Delete(scratchRun);
|
|
}
|
|
}
|
|
Assert.True(sawGenuineLagrangeOrbit, "none of the 25 seeds seated the entrance station on a genuine (non-rosette) Lagrange orbit.");
|
|
}
|
|
|
|
// Cut 1 fix batch 3 (F6, N1): a per-seed strengthening of the "across seeds" test above. That test only
|
|
// requires a genuine Lagrange orbit at least once in 25 seeds; it says nothing about a seed where a genuine
|
|
// candidate DID exist but the station landed on the widened fallback anyway (which the widening condition's
|
|
// own isTutorialEntrance-and-empty-candidates guard should make impossible). This pins the normal case
|
|
// directly, seed by seed: whenever the entrance zone actually has a genuine (non-rosette) Lagrange candidate,
|
|
// its station must be seated on one.
|
|
[Fact]
|
|
public void EntranceStationSitsOnAGenuineLagrangeOrbitWheneverOneExists()
|
|
{
|
|
var zoneSettings = TutorialZoneSettings();
|
|
var tutorialSettings = TutorialGalaxySettings();
|
|
var background = TutorialBackgroundSettings();
|
|
var gameData = TestCatalog.Repo;
|
|
var sawGenuineCandidate = false;
|
|
for (uint seed = 1; seed <= 25; seed++)
|
|
{
|
|
var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-normalcase-{Guid.NewGuid():N}.cc");
|
|
try
|
|
{
|
|
using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun);
|
|
var galaxy = new Galaxy(tutorialSettings, background, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed);
|
|
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { });
|
|
var pack = ZoneGenerator.GenerateZone(items, zoneSettings, AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true);
|
|
|
|
var planetOrbits = pack.Planets.Select(p => cache.Get(cache.Get(p).Orbit)).Where(o => o.Parent.IsSet()).ToArray();
|
|
var hasGenuineCandidate = planetOrbits.Any(o => planetOrbits.Count(c => c.Parent.Key.Equals(o.Parent.Key) && abs(c.Distance - o.Distance) < .1f) == 1);
|
|
if (!hasGenuineCandidate) continue;
|
|
sawGenuineCandidate = true;
|
|
|
|
var dockedStation = pack.Entities.OfType<OrbitalEntityPack>()
|
|
.FirstOrDefault(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0);
|
|
Assert.True(dockedStation != null, $"seed {seed}: the tutorial entrance zone generated no station with a docking bay.");
|
|
Assert.False(IsRosetteMember(cache, pack, cache.Get(dockedStation.Orbit)),
|
|
$"seed {seed}: a genuine Lagrange candidate existed, but the station sat on the widened (rosette) fallback anyway.");
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(scratchRun)) File.Delete(scratchRun);
|
|
}
|
|
}
|
|
Assert.True(sawGenuineCandidate, "none of the 25 seeds had a genuine Lagrange candidate at all; too few runs to trust this result.");
|
|
}
|
|
|
|
// Cut 1 fix batch 3 (F5): the throw ZoneGenerator.cs's own entrance-station override raises
|
|
// (ZoneGenerator.cs's "every planet in the zone is a parentless root" case, fixed in this same batch, F3) is
|
|
// reachable through authored settings, not just a hypothetical: a real seed-1 tutorial entrance with
|
|
// RosetteProbability 0 (no rosettes to widen into) and MassFloor far above every possible child mass (no
|
|
// satellite ever clears the floor, so every planet stays a parentless root) leaves the entrance with no
|
|
// candidate orbit at all, even after widening.
|
|
[Fact]
|
|
public void ThrowReachableWithAuthoredSettings()
|
|
{
|
|
var gameData = TestCatalog.Repo;
|
|
var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-throw-{Guid.NewGuid():N}.cc");
|
|
try
|
|
{
|
|
using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun);
|
|
var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u);
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { });
|
|
|
|
var zoneSettings = TutorialZoneSettings();
|
|
zoneSettings.RosetteProbability = 0;
|
|
zoneSettings.MassFloor = 1e9f;
|
|
// One subzone: the zone's only body is a parentless root. (Several roots give the station a root to orbit.)
|
|
zoneSettings.SubZoneCount = new ExponentialLerp { Exponent = 1, Minimum = 0, Maximum = 0 };
|
|
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
ZoneGenerator.GenerateZone(items, zoneSettings, AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true));
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(scratchRun)) File.Delete(scratchRun);
|
|
}
|
|
}
|
|
|
|
// Cut 1 fix batch 3 (F6, N3): a multi-root, heavily-widened entrance via authored settings -- RosetteProbability
|
|
// 1 (every subzone becomes a rosette) and a fixed SubZoneCount of 8 (several independent rosette systems, each
|
|
// its own root, never each other's sibling) with satellite/binary creation turned off so as few non-rosette
|
|
// orbits form as this generator's own unconditional "captured planet" step (ZoneGenerator.cs's ExpandRosette
|
|
// caller always adds at least one) allows. Verified directly against seed 1's real entrance: this leaves 14
|
|
// distinct parents among its planets, not the single root a boring system would have. Whatever candidate
|
|
// selection actually runs on a topology this fragmented -- ordinary or widened -- every resulting station
|
|
// orbit must still be a real, positioned orbit: a set Parent and a positive Distance, never the zero-distance,
|
|
// parentless orbit the pre-F4 fallback used to synthesize.
|
|
[Fact]
|
|
public void MultiRootEntranceStationOrbitsAreAlwaysParentedWithPositiveDistance()
|
|
{
|
|
var gameData = TestCatalog.Repo;
|
|
var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-multiroot-{Guid.NewGuid():N}.cc");
|
|
try
|
|
{
|
|
using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun);
|
|
var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u);
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { });
|
|
|
|
var zoneSettings = TutorialZoneSettings();
|
|
zoneSettings.RosetteProbability = 1;
|
|
zoneSettings.SubZoneCount = new ExponentialLerp { Exponent = 1, Minimum = 8, Maximum = 8 };
|
|
zoneSettings.SatelliteCreationProbability = 0;
|
|
zoneSettings.BinaryCreationProbability = 0;
|
|
|
|
var pack = ZoneGenerator.GenerateZone(items, zoneSettings, AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true);
|
|
|
|
var roots = pack.Planets.Select(p => cache.Get(cache.Get(p).Orbit)).Count(o => !o.Parent.IsSet());
|
|
Assert.True(roots > 1, $"this fixture no longer produces a multi-root entrance (only {roots} root); it no longer tests the fragmented-topology case.");
|
|
|
|
var stations = pack.Entities.OfType<OrbitalEntityPack>()
|
|
.Where(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station).ToArray();
|
|
Assert.NotEmpty(stations);
|
|
foreach (var station in stations)
|
|
{
|
|
var orbit = cache.Get(station.Orbit);
|
|
Assert.True(orbit.Parent.IsSet(), "a station's orbit has no Parent.");
|
|
Assert.True(orbit.Distance > 0, $"a station's orbit Distance is not positive: {orbit.Distance}.");
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(scratchRun)) File.Delete(scratchRun);
|
|
}
|
|
}
|
|
|
|
// Cut 1 fix batch 3 (F6, N9): a station-offset pin. CreateLagrangeOrbit (ZoneGenerator.cs) builds the
|
|
// station's orbit as a COPY of its source planet's (Parent, Distance) but offsets Phase by
|
|
// PI/3 * sign(random...) -- a real angular displacement, not the same orbital slot. Must fail if that offset
|
|
// is ever dropped (a mutant that seats the station directly on the planet's own orbit object): the station's
|
|
// Phase must differ from every planet's Phase among those sharing its own (Parent, Distance), across seeds.
|
|
[Fact]
|
|
public void EntranceStationPhaseDiffersFromEveryPlanetAtItsOwnDistanceAcrossSeeds()
|
|
{
|
|
var zoneSettings = TutorialZoneSettings();
|
|
var tutorialSettings = TutorialGalaxySettings();
|
|
var background = TutorialBackgroundSettings();
|
|
var gameData = TestCatalog.Repo;
|
|
|
|
var checkedAny = false;
|
|
for (uint seed = 1; seed <= 25; seed++)
|
|
{
|
|
var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-offset-{Guid.NewGuid():N}.cc");
|
|
try
|
|
{
|
|
using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun);
|
|
var galaxy = new Galaxy(tutorialSettings, background, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed);
|
|
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { });
|
|
var pack = ZoneGenerator.GenerateZone(items, zoneSettings, AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true);
|
|
|
|
var dockedStation = pack.Entities.OfType<OrbitalEntityPack>()
|
|
.FirstOrDefault(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0);
|
|
if (dockedStation == null) continue;
|
|
var stationOrbit = cache.Get(dockedStation.Orbit);
|
|
if (!stationOrbit.Parent.IsSet()) continue;
|
|
|
|
var siblingPlanets = pack.Planets.Select(p => cache.Get(cache.Get(p).Orbit))
|
|
.Where(o => o.Parent.IsSet() && o.Parent.Key.Equals(stationOrbit.Parent.Key) && abs(o.Distance - stationOrbit.Distance) < .1f)
|
|
.ToArray();
|
|
if (siblingPlanets.Length == 0) continue;
|
|
checkedAny = true;
|
|
|
|
foreach (var planetOrbit in siblingPlanets)
|
|
Assert.True(abs(planetOrbit.Phase - stationOrbit.Phase) > 1e-3f,
|
|
$"seed {seed}: the docked station's Phase ({stationOrbit.Phase}) matches a planet's own Phase at the same (Parent, Distance) -- it is sitting on the planet's own orbital slot instead of an offset one.");
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(scratchRun)) File.Delete(scratchRun);
|
|
}
|
|
}
|
|
|
|
Assert.True(checkedAny, "no seed produced a docked entrance station with an identifiable source planet at its own (Parent, Distance); too few runs to trust this result.");
|
|
}
|
|
|
|
// Speed cap (docs/locomotion-cut.md Cut 1 verification): Longinus's restored VelocityLimitData caps it at
|
|
// the legacy TopSpeed, 100 (100..100) -- asserted as the literal from the legacy record rather than read
|
|
// back out of the same catalog value being tested, so a mutated TopSpeed (e.g. 1000) can't also move the
|
|
// bound this test checks against.
|
|
//
|
|
// Cut 1 fix batch 3 (Soul's finding #1): the comment this replaces claimed the run went "long enough to
|
|
// approach its drag/thrust balance well past 100" while forcing OverrideShutdown on the hull and both
|
|
// firing thrusters for the whole scenario -- a fixture compensating for RestoredHullsTests.Settings() (then
|
|
// hand-set, thermal radiation/conduction frozen at 1/1/1 against the shipped 3 / 1e-8 / 0.01) making every
|
|
// part freeze toward 0 K and shut down within about a second on real physics. Settings() now loads the real
|
|
// GameplaySettings (AuthoredSettings, above), so OverrideShutdown is no longer needed to keep the ship
|
|
// flying: a real, unforced Longinus holds thrust (and the hull's own VelocityLimit) running well past the
|
|
// window this test drives.
|
|
//
|
|
// Real settings also retired the old +-10 tolerance's premise. On real physics the tick order
|
|
// (Entity: a tick's thrust-driven velocity is checked against VelocityLimit's cap on the FOLLOWING tick)
|
|
// settles into a steady state at the cap plus one tick's own forward acceleration, not at the literal cap --
|
|
// measured here at ~103.3 for this seed's generated loadout, not the ~110 an unconstrained +-10 tolerance
|
|
// would also accept. +4 keeps that real headroom on the ceiling side. The floor is tighter (-3): a
|
|
// materially nerfed cap (Soul's own catalog mutant, TopSpeed 100 -> 92) still produces the same kind of
|
|
// steady state, just a lower one (~95), which -3 catches but +-10 or a +4 ceiling would not.
|
|
//
|
|
// Must fail when VelocityLimitData is removed (docs' own probe: Longinus reaches 122 without it), when
|
|
// TopSpeed is mutated away from 100, and when VelocityLimit's own trigger check is loosened (a "+6 slack"
|
|
// mutant pushes this same steady state to ~109, over the +4 ceiling).
|
|
[Fact]
|
|
public void LonginusNeverExceedsItsRestoredTopSpeedUnderFullThrust()
|
|
{
|
|
const float LegacyTopSpeed = 100f; // Longinus's legacy-record TopSpeed (100..100)
|
|
|
|
using var cache = OpenCatalog();
|
|
var hull = cache.GetByName<HullData>("Longinus");
|
|
var settings = Settings();
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { });
|
|
// ItemManager.Random defaults to a DateTime.Now-seeded instance; without pinning it here, the item
|
|
// qualities LoadoutGenerator/EntitySerializer roll through it (not through the `random` below, which
|
|
// only drives which designs get picked) would make this test's own measured speed non-deterministic.
|
|
items.Random = new Random(1u);
|
|
var random = new Random(1);
|
|
var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f);
|
|
var pack = generator.GenerateShipLoadout(candidate => candidate == hull);
|
|
Assert.NotNull(pack);
|
|
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null);
|
|
var ship = (Ship) EntitySerializer.Unpack(items, zone, pack);
|
|
|
|
zone.Admit(ship, piloted: false);
|
|
ship.Position = float3.zero;
|
|
zone.Update(0f);
|
|
|
|
ship.MovementDirection = float2(0, 1); // full forward
|
|
|
|
// Soul measured this loadout's forward thrusters crossing 386 K (thermal shutdown) around 7.3 s under
|
|
// real settings; 5 s keeps every tick inside the window where thrust runs continuously and unforced, so
|
|
// the cap -- not a coasting or shut-down ship -- is what the assertions below are pinning.
|
|
const int ticks = 60 * 5;
|
|
var lastSpeed = 0f;
|
|
for (var i = 0; i < ticks; i++)
|
|
{
|
|
zone.Update(1f / 60f);
|
|
var speed = length(ship.Velocity);
|
|
lastSpeed = speed;
|
|
Assert.True(speed <= LegacyTopSpeed + 4f, $"tick {i}: speed {speed} exceeded the legacy top speed {LegacyTopSpeed} by more than the real tick-order headroom.");
|
|
}
|
|
|
|
Assert.True(lastSpeed >= LegacyTopSpeed - 3f, $"final speed {lastSpeed} fell too far below the legacy top speed {LegacyTopSpeed}; the cap looks nerfed.");
|
|
}
|
|
|
|
// Loadout generation (docs/locomotion-cut.md Cut 1 verification): LoadoutGenerator, filtered to each
|
|
// restored hull, must fill every one of its thruster hardpoints -- the check that the restored designs and
|
|
// their FactionProductData/roles are authored correctly, not just present.
|
|
[Theory]
|
|
[InlineData("Longinus")]
|
|
[InlineData("Djinni")]
|
|
public void LoadoutGeneratorFillsEveryThrusterHardpointAcrossSeeds(string name)
|
|
{
|
|
using var cache = OpenCatalog();
|
|
var hull = cache.GetByName<HullData>(name);
|
|
var thrusterHardpoints = hull.Hardpoints.Where(h => h.Type == HardpointType.Thruster).ToArray();
|
|
var settings = Settings();
|
|
|
|
for (uint seed = 1; seed <= 10; seed++)
|
|
{
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { });
|
|
var random = new Random(seed);
|
|
var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f);
|
|
var pack = generator.GenerateShipLoadout(candidate => candidate == hull);
|
|
Assert.True(pack != null, $"{name}: seed {seed} produced no loadout.");
|
|
|
|
foreach (var hardpoint in thrusterHardpoints)
|
|
{
|
|
var filled = pack.Equipment.Any(e => e.position.Equals(hardpoint.Position) &&
|
|
(cache.Get(e.item.Data) as GearData)?.Hardpoint == HardpointType.Thruster);
|
|
Assert.True(filled, $"{name}: seed {seed}'s {hardpoint.Transform ?? hardpoint.Type.ToString()} hardpoint at {hardpoint.Position} got no thruster.");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Builds the real tutorial galaxy for `seed`, then re-targets its Entrance to `entranceZoneName` via the
|
|
// public SavedGame/SavedZone round trip (RunSave's own shape: Galaxy -> SavedZone[] -> Galaxy(cache,
|
|
// SavedGame, log)), without touching Galaxy's private Entrance backing field by reflection. Zone content
|
|
// (planets) is keyed off each zone's own Name/Position, not off which zone happens to be Entrance, so the
|
|
// re-targeted galaxy's chosen zone generates identically to the original -- letting a test pick, by name, any
|
|
// real zone from a real galaxy (e.g. one already known to have no Lagrange candidate) and make it the Entrance.
|
|
private static Galaxy BuildGalaxyWithForcedEntrance(CultCache cache, uint seed, string entranceZoneName)
|
|
{
|
|
var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed);
|
|
var target = galaxy.Zones.Single(z => z.Name == entranceZoneName);
|
|
|
|
var factions = galaxy.Factions;
|
|
var savedZones = galaxy.Zones.Select(zone => new SavedZone
|
|
{
|
|
Name = zone.Name,
|
|
Position = zone.Position,
|
|
AdjacentZones = zone.AdjacentZones.Select(az => Array.IndexOf(galaxy.Zones, az)).ToArray(),
|
|
Factions = zone.Factions.Select(f => Array.IndexOf(factions, f)).ToArray(),
|
|
Contents = zone.PackedContents,
|
|
Owner = zone.Owner == null ? -1 : Array.IndexOf(factions, zone.Owner),
|
|
}).ToArray();
|
|
var keys = savedZones.Select((_, i) => new CultRecordKey($"restoredhullstests-forced-entrance-zone-{i}")).ToArray();
|
|
cache.Commit(batch => { for (var i = 0; i < keys.Length; i++) batch.Upsert(typeof(SavedZone), savedZones[i], keys[i]); });
|
|
|
|
var game = new SavedGame
|
|
{
|
|
Zones = keys.Select(k => new CultRecordRef<SavedZone>(k)).ToArray(),
|
|
Factions = factions.Select(f => cache.RefOf(f)).ToArray(),
|
|
HomeZones = galaxy.HomeZones.ToDictionary(x => Array.IndexOf(factions, x.Key), x => Array.IndexOf(galaxy.Zones, x.Value)),
|
|
BossZones = galaxy.BossZones.ToDictionary(x => Array.IndexOf(factions, x.Key), x => Array.IndexOf(galaxy.Zones, x.Value)),
|
|
Entrance = Array.IndexOf(galaxy.Zones, target),
|
|
Exit = -1,
|
|
Relationships = factions.Select(f => galaxy.FactionRelationships[f]).ToArray(),
|
|
DiscoveredZones = Array.Empty<int>(),
|
|
Background = galaxy.Background,
|
|
IsTutorial = true,
|
|
};
|
|
return new Galaxy(cache, game, _ => { });
|
|
}
|
|
|
|
// Cut 1 fix batch 2 (Soul's finding #2): the tutorial entrance's widened candidate set. A real tutorial
|
|
// galaxy has a zone (NoLagrangeCandidateZone finds one; under CultMath 0.2.x it was seed 3's "EAC-7089"), whose planets are entirely one rosette with no captured satellites at all: 4
|
|
// planets sharing one Distance under one Parent, and nothing else -- potentialLagrangePoints is empty for it
|
|
// (verified directly against this exact fixture, not carried over from Soul's own forced-fallback probe, whose
|
|
// own candidate check undercounted planet orbits by using pack.Planets.Count -- which excludes Empty rosette
|
|
// roots -- and so misidentified some zones, including its originally-reported "EAC-3208", as candidate-less
|
|
// when they were not). Re-targeted here to be the Entrance through BuildGalaxyWithForcedEntrance above. Its
|
|
// docked station can therefore only have come from the widened candidate set (every parented planet orbit,
|
|
// not just non-rosette ones), never from a synthesized, unparented fallback orbit. Pins: still docked, and
|
|
// turrets sit on a real orbit (a real Parent, a finite Phase).
|
|
//
|
|
// Cut 1 fix batch 3 (F7): the Distance check this comment used to describe as "near their station" was
|
|
// vacuous -- PlaceTurret (ZoneGenerator.cs) sets a turret orbit's Distance from `orbit.Distance` directly,
|
|
// unconditionally, the same as CreateLagrangeOrbit does for the station itself, so every turret's Distance
|
|
// equals the station's by construction regardless of placement; the check could never fail. What actually
|
|
// makes a turret "near" its station is Phase: PlaceTurrets' own `dist = t/2` gives each turret a small
|
|
// angular nudge (`20 * dist / orbit.Distance`) off the station's own Phase, not a placement anywhere else on
|
|
// the orbit. Replaced with a bound on that real offset, generous over PlaceTurrets' own formula.
|
|
[Fact]
|
|
public void TutorialEntranceWithNoLagrangeCandidateStillSeatsAStationWithFiniteNearbyTurrets()
|
|
{
|
|
var gameData = TestCatalog.Repo;
|
|
var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-widened-{Guid.NewGuid():N}.cc");
|
|
try
|
|
{
|
|
using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun);
|
|
var (seed, zoneName) = NoLagrangeCandidateZone();
|
|
var galaxy = BuildGalaxyWithForcedEntrance(cache, seed, zoneName);
|
|
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { });
|
|
var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true);
|
|
|
|
var station = pack.Entities.OfType<OrbitalEntityPack>()
|
|
.FirstOrDefault(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0);
|
|
Assert.True(station != null, "the known no-Lagrange-candidate entrance zone generated no docked station.");
|
|
var stationOrbit = cache.Get(station.Orbit);
|
|
|
|
// Confirms this fixture still exercises the widened path (a regression here would mean the fixture no
|
|
// longer probes what this test claims to probe).
|
|
// The zone has no body alone on its ring, so the station orbits the root node beyond its ring group.
|
|
var rootOrbit = cache.Get(stationOrbit.Parent);
|
|
Assert.False(rootOrbit.Parent.IsSet(), "the entrance station no longer orbits a root node -- this fixture no longer tests the no-candidate path.");
|
|
var ring = pack.Planets.Select(p => cache.Get(cache.Get(p).Orbit)).Where(o => o.Parent.IsSet() && o.Parent.Key.Equals(stationOrbit.Parent.Key)).Select(o => o.Distance).DefaultIfEmpty(0f).Min();
|
|
Assert.True(stationOrbit.Distance >= (1 + GravityField.RingReach) * GravityField.Beta * ring * .999f, $"the entrance station at {stationOrbit.Distance} sits inside its node's ring group of radius {ring}.");
|
|
|
|
var turrets = pack.Entities.OfType<OrbitalEntityPack>()
|
|
.Where(e => (cache.Get(e.Hull.Data) as HullData)?.HullType != HullType.Station).ToArray();
|
|
Assert.NotEmpty(turrets);
|
|
// Generous over PlaceTurrets' own max |distanceMultiplier| ((turrets.Length - 1) / 2): a real bound,
|
|
// not the vacuous Distance-always-matches check this replaces.
|
|
var maxPhaseOffset = 20f * turrets.Length / stationOrbit.Distance;
|
|
foreach (var turret in turrets)
|
|
{
|
|
var turretOrbit = cache.Get(turret.Orbit);
|
|
Assert.True(turretOrbit.Parent.IsSet(), "a turret's orbit has no Parent.");
|
|
Assert.False(float.IsNaN(turretOrbit.Phase) || float.IsInfinity(turretOrbit.Phase), $"a turret's orbit Phase is not finite: {turretOrbit.Phase}.");
|
|
var phaseDelta = abs(turretOrbit.Phase - stationOrbit.Phase);
|
|
Assert.True(phaseDelta <= maxPhaseOffset,
|
|
$"a turret's Phase ({turretOrbit.Phase}) is not near the station's own Phase ({stationOrbit.Phase}): delta {phaseDelta} > {maxPhaseOffset}.");
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(scratchRun)) File.Delete(scratchRun);
|
|
}
|
|
}
|
|
|
|
// Cut 1 fix batch 2 (Soul's finding #2, leak check): the entrance override must not leak to (a) other
|
|
// tutorial zones or (b) a non-tutorial game's own entrance zone. Both are pinned against a seed-1 galaxy --
|
|
// the widened-candidate test above uses a different fixture, NoLagrangeCandidateZone's -- so this test and the one
|
|
// below it stand or fall on this one known fixture, not that one.
|
|
[Fact]
|
|
public void EntranceOverrideDoesNotLeakToOtherTutorialZonesOrNonTutorialGames()
|
|
{
|
|
var gameData = TestCatalog.Repo;
|
|
var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-leak-{Guid.NewGuid():N}.cc");
|
|
try
|
|
{
|
|
using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun);
|
|
var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u);
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { });
|
|
|
|
int StationCount(GalaxyZone zone, bool isTutorial) =>
|
|
ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), AuthoredPlanetSettings(), galaxy, zone, isTutorial)
|
|
.Entities.OfType<OrbitalEntityPack>()
|
|
.Count(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station);
|
|
|
|
// (a) A non-entrance tutorial zone that naturally rolls zero stations keeps that roll under
|
|
// isTutorial: true -- the floor must not leak past the galaxy.Entrance reference check.
|
|
// The zone is chosen by its natural roll: zero stations without the tutorial floor (isTutorial: false).
|
|
var otherZone = galaxy.Zones.First(z => !ReferenceEquals(z, galaxy.Entrance) && StationCount(z, isTutorial: false) == 0);
|
|
Assert.NotSame(galaxy.Entrance, otherZone);
|
|
Assert.Equal(0, StationCount(otherZone, isTutorial: true));
|
|
|
|
// (b) The entrance zone itself, generated for a non-tutorial game (isTutorial: false), keeps its
|
|
// natural roll too -- the floor must not leak past the isTutorial flag either.
|
|
Assert.Equal(0, StationCount(galaxy.Entrance, isTutorial: false));
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(scratchRun)) File.Delete(scratchRun);
|
|
}
|
|
}
|
|
|
|
// Cut 1 fix batch 2 (Soul's finding #2, baseStationCount): the entrance override raises stationCount (the
|
|
// orbit-selection floor) but must not also raise the enemyCount roll, which is keyed to baseStationCount
|
|
// (the pre-override value) precisely so the forced station doesn't buy the zone an extra enemy. Seed 1's
|
|
// entrance zone rolls baseStationCount 0 (confirmed via the isTutorial: false StationCount == 0 above) and
|
|
// the override raises stationCount to 1; this pins the resulting ship count at the value baseStationCount
|
|
// produces. It must fail if the enemy-count formula is changed to add stationCount instead (it would add a
|
|
// ship instead of zero).
|
|
[Fact]
|
|
public void ForcedTutorialEntranceStationDoesNotInflateEnemyCount()
|
|
{
|
|
var gameData = TestCatalog.Repo;
|
|
var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-enemycount-{Guid.NewGuid():N}.cc");
|
|
try
|
|
{
|
|
using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun);
|
|
var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u);
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { });
|
|
|
|
var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true);
|
|
var stationCount = pack.Entities.OfType<OrbitalEntityPack>().Count(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station);
|
|
// Soul's finding (this cut's fix batch 3): pack.Entities.OfType<ShipPack>().Count() also counts the
|
|
// zone's neutral wanderers (ZoneGenerator.cs's own EligibleWandererFactions spawn, keyed to
|
|
// ZoneGenerationSettings.NeutralWandererCount -- 2 by TutorialZoneSettings' own default, unrelated to
|
|
// this fixture's own enemy roll), so the old assertion of exactly 2 was pinning the wanderer count,
|
|
// not the enemy count baseStationCount is supposed to gate. The nearest faction here is the same one
|
|
// ZoneGenerator computes internally (galaxy.HomeZones' own distance-to-zone ordering); every ShipPack
|
|
// names its faction directly (EntityPack.Faction), so filtering on it separates the two spawns.
|
|
var nearestFaction = galaxy.Factions.MinBy(f => galaxy.HomeZones[f].Distance[galaxy.Entrance]);
|
|
var enemyShipCount = pack.Entities.OfType<ShipPack>().Count(s => cache.Get(s.Faction) == nearestFaction);
|
|
|
|
Assert.Equal(1, stationCount); // the override did raise the station count (from a natural roll of 0)
|
|
Assert.Equal(0, enemyShipCount); // the enemy roll stayed keyed to the pre-override baseStationCount of 0
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(scratchRun)) File.Delete(scratchRun);
|
|
}
|
|
}
|
|
|
|
// Cut 1 fix batch 3 (F6, N2): the entrance-only widening must not leak to a non-entrance zone that would
|
|
// otherwise get zero stations. NoLagrangeCandidateZone's zone (the same no-Lagrange fixture the widened-candidate test
|
|
// above uses, this time NOT retargeted as Entrance) rolls a real, nonzero station count on its own -- forced
|
|
// here by boosting one faction's presence there (HomeZone distance 0, InfluenceDistance 50, so the roll's
|
|
// floor(rand * (factionPresence+1)) is overwhelmingly likely nonzero) via the same public SavedGame round
|
|
// trip BuildGalaxyWithForcedEntrance uses, not by touching ZoneGenerator's private roll. Verified directly:
|
|
// temporarily widening every zone (dropping ZoneGenerator's isTutorialEntrance guard) turns this fixture's
|
|
// station count from 0 into 2, so a natural nonzero roll really is hiding behind the empty candidate set.
|
|
// Must fail if that guard is dropped for real.
|
|
private static Galaxy BuildGalaxyWithBoostedPresence(CultCache cache, uint seed, string zoneName)
|
|
{
|
|
var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed);
|
|
var target = galaxy.Zones.Single(z => z.Name == zoneName);
|
|
|
|
var factions = galaxy.Factions;
|
|
var savedZones = galaxy.Zones.Select(zone => new SavedZone
|
|
{
|
|
Name = zone.Name,
|
|
Position = zone.Position,
|
|
AdjacentZones = zone.AdjacentZones.Select(az => Array.IndexOf(galaxy.Zones, az)).ToArray(),
|
|
Factions = zone.Factions.Select(f => Array.IndexOf(factions, f)).ToArray(),
|
|
Contents = zone.PackedContents,
|
|
Owner = zone.Owner == null ? -1 : Array.IndexOf(factions, zone.Owner),
|
|
}).ToArray();
|
|
var keys = savedZones.Select((_, i) => new CultRecordKey($"restoredhullstests-boosted-presence-zone-{i}")).ToArray();
|
|
cache.Commit(batch => { for (var i = 0; i < keys.Length; i++) batch.Upsert(typeof(SavedZone), savedZones[i], keys[i]); });
|
|
|
|
var homeZones = galaxy.HomeZones.ToDictionary(x => Array.IndexOf(factions, x.Key), x => Array.IndexOf(galaxy.Zones, x.Value));
|
|
var boosted = Enumerable.Range(0, factions.Length).OrderByDescending(i => factions[i].InfluenceDistance).First();
|
|
homeZones[boosted] = Array.IndexOf(galaxy.Zones, target);
|
|
factions[boosted].InfluenceDistance = 50;
|
|
|
|
var game = new SavedGame
|
|
{
|
|
Zones = keys.Select(k => new CultRecordRef<SavedZone>(k)).ToArray(),
|
|
Factions = factions.Select(f => cache.RefOf(f)).ToArray(),
|
|
HomeZones = homeZones,
|
|
BossZones = galaxy.BossZones.ToDictionary(x => Array.IndexOf(factions, x.Key), x => Array.IndexOf(galaxy.Zones, x.Value)),
|
|
Entrance = Array.IndexOf(galaxy.Zones, galaxy.Entrance),
|
|
Exit = -1,
|
|
Relationships = factions.Select(f => galaxy.FactionRelationships[f]).ToArray(),
|
|
DiscoveredZones = Array.Empty<int>(),
|
|
Background = galaxy.Background,
|
|
IsTutorial = true,
|
|
};
|
|
return new Galaxy(cache, game, _ => { });
|
|
}
|
|
|
|
[Fact]
|
|
public void WideningDoesNotLeakToANonEntranceNoLagrangeZoneThatRollsAStation()
|
|
{
|
|
var gameData = TestCatalog.Repo;
|
|
var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-widen-leak-{Guid.NewGuid():N}.cc");
|
|
try
|
|
{
|
|
using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun);
|
|
var (seed, zoneName) = NoLagrangeCandidateZone();
|
|
var galaxy = BuildGalaxyWithBoostedPresence(cache, seed, zoneName);
|
|
var zone = galaxy.Zones.Single(z => z.Name == zoneName);
|
|
Assert.NotSame(galaxy.Entrance, zone);
|
|
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { });
|
|
var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), AuthoredPlanetSettings(), galaxy, zone, isTutorial: true);
|
|
|
|
// Confirms this fixture still has no genuine (non-rosette) Lagrange candidate -- a regression here
|
|
// would mean the fixture no longer probes what this test claims to probe.
|
|
var hasGenuineCandidate = HasGenuineLagrangeCandidate(ParentedPlanetOrbits(cache, pack));
|
|
Assert.False(hasGenuineCandidate, "the entrance-adjacent fixture zone now has a genuine Lagrange candidate; it no longer tests the no-Lagrange leak path.");
|
|
|
|
var stationCount = pack.Entities.OfType<OrbitalEntityPack>().Count(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station);
|
|
Assert.Equal(0, stationCount);
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(scratchRun)) File.Delete(scratchRun);
|
|
}
|
|
}
|
|
|
|
// Soul's finding #3 (asset identity): the existence-only check (EveryAssetReferenceResolvesToAMetaGuid) passes
|
|
// even when Djinni.Prefab is swapped for Longinus's -- both are real GUIDs somewhere under Assets/. This pins
|
|
// the actual identity: each restored reference names the specific legacy-record file it is supposed to, not
|
|
// merely some meta file that happens to exist. Must fail under Soul's own "djprefab" mutation (Djinni.Prefab
|
|
// set to Longinus's GUID).
|
|
[Fact]
|
|
public void RestoredAssetReferencesNameTheirOwnLegacyFileNotJustAnyExistingGuid()
|
|
{
|
|
string GuidOf(string assetPathUnderRepoRoot)
|
|
{
|
|
var meta = Path.Combine(FindRepoRoot(), assetPathUnderRepoRoot + ".meta");
|
|
return File.ReadLines(meta).First(l => l.StartsWith("guid: ")).Substring("guid: ".Length).Trim();
|
|
}
|
|
|
|
using var cache = OpenCatalog();
|
|
|
|
var djinniPrefabGuid = GuidOf("Assets/Content/Prefabs/Ships/Djinni.prefab");
|
|
var djinni = cache.GetByName<HullData>("Djinni");
|
|
Assert.Equal(djinniPrefabGuid, djinni.Prefab);
|
|
|
|
// Every restored thruster's particle effect is Thruster 1.prefab, by the legacy record (soul-dump.txt).
|
|
var thruster1Guid = GuidOf("Assets/Content/Prefabs/Thrusters/Thruster 1.prefab");
|
|
var restoredThrusterNames = new[] { "Burn Drive", "Large Drive", "Racing Drive", "Small Drive", "Rugged Drive", "High-Thrust Drive", "Medium Drive" };
|
|
foreach (var name in restoredThrusterNames)
|
|
{
|
|
var design = cache.GetAll<GearData>().First(g => g.Name == name);
|
|
var thrusterBehavior = design.Behaviors.OfType<ThrusterData>().Single();
|
|
Assert.Equal(thruster1Guid, thrusterBehavior.ParticlesPrefab);
|
|
}
|
|
}
|
|
}
|