Aetheria/tests/Aetheria.Shared.Tests/ScenarioTests.cs
Meta Vens 2c065937ee Merge origin/eureka/aetheria-release-pirates-record (8e51016f) into master
AetherDb Program.cs: both sides appended a command (pd-gear/mine-launcher, pirates-faction);
kept all, one usage line. GameData/Aetheria.cc: master's blob kept here, pirates-faction
apply replayed on it in the next commit (follow-up catalog-blob-merge-order).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 21:37:22 +02:00

583 lines
31 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.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using CultMath;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// Scenarios S2 (docs/scenarios-cut.md, R): every new run is a scenario. The fixture is RunStartTests': a scratch copy
// of the shipped catalog, the authored settings and a prelude galaxy at a fixed seed.
public sealed partial class RunStartTests
{
// A test's own scenario: a script given inline, staged into the fixture's arena or launched whole.
private sealed class Scripted : Scenario
{
private readonly bool _ambient;
private readonly Action<ScenarioStage> _stage;
private readonly uint _seed;
private readonly Func<GalaxyStage, Galaxy> _generate;
public Scripted(bool ambient, Action<ScenarioStage> stage, uint seed = GalaxySeed, Func<GalaxyStage, Galaxy> generate = null)
{
_ambient = ambient;
_stage = stage;
_seed = seed;
_generate = generate ?? (galaxy => galaxy.Prelude());
}
public override string Name => "Scripted";
public override string Brief => "A test's own scenario.";
public override uint Seed => _seed;
public override bool Ambient => _ambient;
public override Galaxy Generate(GalaxyStage stage) => _generate(stage);
public override void Stage(ScenarioStage stage) => _stage(stage);
}
private static IEnumerable<Scenario> EveryScenario => Scenarios.Modes.Concat(Scenarios.Development).Concat(Scenarios.Tests);
// The galaxy inputs the menu hands to RunStart, read from the authored settings.
private GalaxyStage Inputs(Func<uint> clock = null)
{
var authored = AuthoredSettings.Load(FindRepoRoot());
return new GalaxyStage(
authored.Read<SectorGenerationSettings>("SectorGenerationSettings"),
authored.Read<SectorBackgroundSettings>("SectorBackgroundSettings"),
authored.Read<TutorialGenerationSettings>("TutorialGenerationSettings"),
authored.Read<SectorBackgroundSettings>("TutorialBackgroundSettings"),
authored.Read<NameGeneratorSettings>("NameGeneratorSettings"),
_cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(_root, "Narrative")), _ => { }, null, clock);
}
// What MainMenu.Launch and StartGame do with a scenario: its galaxy, its arena, then staging. beforeStaging stands in
// for whatever the game runs between the arena and staging.
private (Galaxy galaxy, Zone arena, RunStart.Staged staged, List<string> failures) Launch(Scenario scenario, GalaxyStage inputs = null, Action beforeStaging = null)
{
var galaxy = RunStart.Generate(scenario, inputs ?? Inputs());
var arena = new Zone(_items, _planetSettings, RunStart.GenerateArena(_items, _zoneSettings, galaxy, scenario), galaxy.Entrance, galaxy);
beforeStaging?.Invoke();
var failures = new List<string>();
var staged = RunStart.Stage(_items, arena, scenario, _startingHull, _tutorialSettings, failures);
return (galaxy, arena, staged, failures);
}
private RunStart.Staged Stage(Zone zone, Scenario scenario)
{
var failures = new List<string>();
var staged = RunStart.Stage(_items, zone, scenario, _startingHull, _tutorialSettings, failures);
Assert.True(failures.Count == 0, string.Join("; ", failures));
Assert.NotNull(staged);
return staged;
}
// Staging that must fail: it returns null and admits nothing. Returns the failures.
private List<string> Refused(Zone zone, Scenario scenario)
{
var entities = zone.Entities.ToList();
var agents = zone.Agents.ToList();
var failures = new List<string>();
Assert.Null(RunStart.Stage(_items, zone, scenario, _startingHull, _tutorialSettings, failures));
SafeAssert.Unchanged(zone, entities, agents);
return failures;
}
// The rot test: every scenario New Game lists generates its galaxy and stages against the live catalog and the
// authored settings with no failure. A script naming a design the catalog lost fails here, by name.
[Fact]
public void EveryScenarioStages()
{
Assert.Equal(EveryScenario.Count(), EveryScenario.Select(scenario => scenario.Name).Distinct().Count());
foreach (var scenario in EveryScenario)
{
var (_, arena, staged, failures) = Launch(scenario, Inputs(() => GalaxySeed));
Assert.True(failures.Count == 0, $"{scenario.Name}: {string.Join("; ", failures)}");
Assert.True(staged.Player.IsPlayerShip, $"{scenario.Name}: the player is not flagged");
SafeAssert.In(arena, staged.Player, $"{scenario.Name}: the player is admitted");
foreach (var entity in staged.Entities) SafeAssert.In(arena, entity, $"{scenario.Name}: {entity.Name} is admitted");
Assert.False(string.IsNullOrWhiteSpace(scenario.Brief), $"{scenario.Name} has no brief");
}
}
// A run starts with the gameplay settings' starting credits unless its scenario sets another amount.
[Fact]
public void StagedCreditsAreTheSettingsUnlessTheScenarioSets()
{
var plain = Stage(Arena(true), new Scripted(true, stage => stage.Player(stage.Bare("Djinni"), float2(0, 0))));
Assert.Equal(_items.GameplaySettings.StartingCredits, plain.Credits);
var poor = Stage(Arena(true), new Scripted(true, stage =>
{
stage.Credits = 1234;
stage.Player(stage.Bare("Djinni"), float2(0, 0));
}));
Assert.Equal(1234, poor.Credits);
}
// The two game modes are what New Game did: the tutorial a prelude galaxy and a starting-hull ship of the
// protagonist faction at the entrance's origin; the main galaxy a main galaxy and a starting-hull ship there. Both
// arenas keep their generated ships.
[Fact]
public void ModesMatchOldNewGame()
{
Assert.Equal("Longinus", _startingHull);
var tutorial = Launch(new TutorialGalaxy(), Inputs(() => GalaxySeed));
Assert.Empty(tutorial.failures);
Assert.True(tutorial.galaxy.IsPrelude, "the tutorial is a prelude galaxy");
Assert.Equal(_startingHull, tutorial.staged.Player.HullData.Name);
Assert.Same(tutorial.galaxy.ResolveFaction(_tutorialSettings.ProtagonistFaction), tutorial.staged.Player.Faction);
Assert.Equal(float3.zero, tutorial.staged.Player.Position);
Assert.Empty(tutorial.staged.Entities);
Assert.Contains(tutorial.arena.Pack.Entities, entity => entity is ShipPack { IsPlayerShip: false });
var main = Launch(new MainGalaxy(), Inputs(() => GalaxySeed));
Assert.Empty(main.failures);
Assert.False(main.galaxy.IsPrelude, "the main galaxy is not a prelude");
Assert.Equal(_startingHull, main.staged.Player.HullData.Name);
Assert.Equal(float3.zero, main.staged.Player.Position);
Assert.Empty(main.staged.Entities);
Assert.Contains(main.arena.Pack.Entities, entity => entity is ShipPack { IsPlayerShip: false });
}
// Everything a galaxy's layout fixes: each zone's name, position, owner, factions and links, the homes, the
// entrance, the exit and the background.
private static string Layout(Galaxy galaxy) =>
string.Join(";", galaxy.Zones.Select(zone =>
$"{zone.Name}@{zone.Position}:{zone.Owner?.Name}:[{string.Join(",", zone.Factions.Select(faction => faction.Name))}]" +
$":[{string.Join(",", zone.AdjacentZones.Select(adjacent => Array.IndexOf(galaxy.Zones, adjacent)).OrderBy(i => i))}]")) +
$" homes {string.Join(",", galaxy.HomeZones.Select(home => $"{home.Key.Name}={Array.IndexOf(galaxy.Zones, home.Value)}").OrderBy(home => home))}" +
$" entrance {Array.IndexOf(galaxy.Zones, galaxy.Entrance)} exit {Array.IndexOf(galaxy.Zones, galaxy.Exit)} noise {galaxy.Background.NoisePosition}";
// An arena's bodies and every entity in it, down to each item's design, maker, workmanship and role fills.
private string Layout(ZonePack arena) =>
$"{arena.Radius} {arena.Mass} planets [{string.Join(",", arena.Planets.Select(planet => _cache.Get(planet)).Select(body => $"{body.GetType().Name}:{body.Mass}"))}]" +
$" orbits [{string.Join(",", arena.Orbits.Select(orbit => _cache.Get(orbit).Distance))}]" +
$" entities [{string.Join(",", arena.Entities.Select(Gear))}]";
// What a launch staged: the player and everything placed, each where it is and with exactly what it carries.
private string Fits(RunStart.Staged staged) =>
string.Join(" | ", staged.Entities.Prepend(staged.Player).Select(entity => $"{entity.Name}@{entity.Position}:{Gear(EntitySerializer.Pack(entity))}"));
private string Gear(EntityPack pack) =>
$"{pack.GetType().Name}{(pack is ShipPack ship ? "@" + ship.Position : "")}:{string.Join("/", EntitySerializer.Items(pack).Select(Item))}";
private string Item(ItemInstance item)
{
var name = _items.GetData(item)?.Name;
if (!(item is CraftedItemInstance crafted)) return name;
var lot = _items.GetLot(crafted);
return $"{name}<{(lot.Origin as Attributed)?.Faction.Key.Value}>~{lot.Quality}" +
string.Concat((lot.Roles ?? new List<RoleFill>()).Select(fill => $"/{fill.Role}={fill.Quality}"));
}
// A fixed seed fixes the galaxy, its background, its arena and every fit, each item's maker and workmanship
// included, launch after launch in one session, whatever ran in between. Seed zero takes the clock, so two launches
// at two clock readings are two galaxies.
[Fact]
public void SeedRules()
{
foreach (var scenario in Scenarios.Tests)
{
Assert.NotEqual(0u, scenario.Seed);
var first = Launch(scenario);
Launch(new MainGalaxy(), Inputs(() => 4242));
_items.Random = new CultMath.Random(0xC0FFEE); // play draws from the run's item random
var second = Launch(scenario, beforeStaging: () => _items.Random = new CultMath.Random(0xBADC0DE));
Assert.True(first.failures.Count == 0, $"{scenario.Name}: {string.Join("; ", first.failures)}");
Assert.Equal(Layout(first.galaxy), Layout(second.galaxy));
Assert.Equal(Layout(first.arena.Pack), Layout(second.arena.Pack));
Assert.Equal(Fits(first.staged), Fits(second.staged));
}
var clock = new Queue<uint>(new[] { 1000u, 2000u });
var inputs = Inputs(() => clock.Dequeue());
var everyLaunchNew = new Scripted(true, _ => { }, seed: 0, generate: galaxy => galaxy.Main());
var a = RunStart.Generate(everyLaunchNew, inputs);
Assert.Equal(1000u, inputs.Seed);
var b = RunStart.Generate(everyLaunchNew, inputs);
Assert.Equal(2000u, inputs.Seed);
Assert.NotEqual(Layout(a), Layout(b));
}
// Generating a galaxy writes nothing a later one reads: a prelude leaves the catalog's faction influence as authored.
[Fact]
public void AGalaxyLeavesTheCatalogsFactionsAsAuthored()
{
var authored = _cache.GetAll<Faction>().ToDictionary(faction => faction, faction => faction.InfluenceDistance);
RunStart.Generate(new TutorialGalaxy(), Inputs());
RunStart.Generate(new MainGalaxy(), Inputs());
Assert.All(authored, pair => Assert.Equal(pair.Value, pair.Key.InfluenceDistance));
}
// Every zone is linked into one graph. These seeds left zones with no link at all (the vendored float-precision
// convex-hull triangulation dropped them), so the constructor threw KeyNotFoundException placing homes (prelude)
// or the entrance (main). Each galaxy's links are a subset of its zone positions' Delaunay edges, and the
// triangulation agrees with a brute-force empty-circumcircle oracle.
[Theory]
[InlineData(103u, true)]
[InlineData(134u, true)]
[InlineData(137u, true)]
[InlineData(173u, true)]
[InlineData(227u, true)]
[InlineData(285u, true)]
[InlineData(34u, false)]
public void EveryZoneIsLinked(uint seed, bool prelude)
{
var galaxy = RunStart.Generate(new Scripted(true, _ => { }, seed, stage => prelude ? stage.Prelude() : stage.Main()), Inputs());
Assert.All(galaxy.Zones, zone => Assert.Equal(galaxy.Zones.Length, zone.Distance.Count));
var positions = galaxy.Zones.Select(zone => zone.Position).ToArray();
var edges = Delaunay.Edges(positions);
Assert.Equal(BruteForceDelaunay(positions), edges);
var delaunay = new HashSet<(int, int)>(edges);
for (var i = 0; i < galaxy.Zones.Length; i++)
foreach (var adjacent in galaxy.Zones[i].AdjacentZones)
{
var j = Array.IndexOf(galaxy.Zones, adjacent);
Assert.Contains((Math.Min(i, j), Math.Max(i, j)), delaunay);
}
}
// Every triangle whose circumcircle holds no other point, as sorted edges.
private static List<(int a, int b)> BruteForceDelaunay(float2[] points)
{
var edges = new SortedSet<(int a, int b)>();
for (var i = 0; i < points.Length; i++)
for (var j = i + 1; j < points.Length; j++)
for (var k = j + 1; k < points.Length; k++)
{
double ax = points[i].x, ay = points[i].y, bx = points[j].x, by = points[j].y, cx = points[k].x, cy = points[k].y;
var d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
if (d == 0) continue;
double a2 = ax * ax + ay * ay, b2 = bx * bx + by * by, c2 = cx * cx + cy * cy;
var ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d;
var uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d;
var r2 = (ax - ux) * (ax - ux) + (ay - uy) * (ay - uy);
var empty = true;
for (var m = 0; m < points.Length && empty; m++)
if (m != i && m != j && m != k)
{
double dx = points[m].x - ux, dy = points[m].y - uy;
empty = dx * dx + dy * dy >= r2;
}
if (!empty) continue;
edges.Add((i, j));
edges.Add((j, k));
edges.Add((i, k));
}
return edges.ToList();
}
// A scenario naming one unknown design admits nothing, not even the parts that built, and names the design.
[Fact]
public void StagingIsAllOrNothing()
{
var failures = Refused(Arena(true), new Scripted(true, stage =>
{
stage.Player(stage.Bare("Djinni"), float2(0, 0));
stage.Place(stage.Generated("Longinus"), float2(300, 0), stance: ScenarioStance.Hostile, piloted: true);
stage.Place(stage.Fit("Longinus", ("No Such Gun", int2(1, 8), ItemRotation.None)), float2(-300, 0));
}));
Assert.Contains("No Such Gun", Assert.Single(failures));
}
// A stance holds both ways between an entity and the player. A piloted entity has exactly one agent; an unpiloted
// one has none, and nothing steers it. (It is not frozen: the zone's gravity still moves it, as it moves any ship,
// so the rule is observed on its steering inputs.)
[Fact]
public void StanceAndPilot()
{
var zone = Arena(true);
var staged = Stage(zone, new Scripted(true, stage =>
{
stage.Player(stage.Bare("Djinni"), float2(0, 0));
stage.Place(stage.Generated("Longinus"), float2(300, 0), stance: ScenarioStance.Hostile, piloted: true);
stage.Place(stage.Bare("Longinus"), float2(-300, 0), stance: ScenarioStance.Neutral, piloted: false);
}));
var player = staged.Player;
var hostile = staged.Entities[0];
var neutral = staged.Entities[1];
Assert.True(player.IsHostileTo(hostile) && hostile.IsHostileTo(player), "hostile both ways");
Assert.False(player.IsHostileTo(neutral) || neutral.IsHostileTo(player), "neutral both ways");
// Admission activates: the live hostility each side tracks (what targeting and grudges read) agrees.
Assert.True(player.EntityHostility[hostile] && hostile.EntityHostility[player]);
Assert.False(player.EntityHostility[neutral] || neutral.EntityHostility[player]);
Assert.Equal(1, AgentsOf(zone, hostile));
Assert.Equal(0, AgentsOf(zone, neutral));
Assert.Equal(0, AgentsOf(zone, player));
var look = neutral.Aim;
for (var tick = 0; tick < 50; tick++)
{
zone.Update(.1f);
Assert.Equal(float2.zero, ((Ship) neutral).MovementDirection);
Assert.Equal(look, neutral.Aim);
}
Assert.Equal(0, AgentsOf(zone, neutral));
}
// A stance lasts the sitting: the player docking and undocking leaves it standing both ways. It goes with an entity
// gone for good: once an entity is destroyed, the player holds no stance toward it.
[Fact]
public void AStanceSurvivesDockingButNotDestruction()
{
var zone = Arena(true);
var staged = Stage(zone, new Scripted(true, stage =>
{
stage.Player(stage.Bare("Djinni"), float2(0, 0));
stage.Place(stage.Bare("Longinus"), float2(300, 0), stance: ScenarioStance.Hostile);
stage.Place(stage.Bare("Longinus"), float2(-300, 0), stance: ScenarioStance.Hostile);
}));
var player = staged.Player;
var hostile = staged.Entities[0];
var doomed = staged.Entities[1];
var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0);
Assert.NotNull(station.TryDock(player));
SafeAssert.NotIn(zone, player, "a docked ship is out of the zone");
Assert.True(station.TryUndock(player));
Assert.True(player.IsHostileTo(hostile) && hostile.IsHostileTo(player), "the stance was lost over docking");
Assert.True(player.EntityHostility[hostile] && hostile.EntityHostility[player], "the live hostility was lost over docking");
// Bare fits carry no faction, so without its stance the derived rule is not hostile
Assert.True(player.Faction == null && doomed.Faction == null, "the fixture's ships are factionless");
Assert.True(player.IsHostileTo(doomed));
doomed.Hull.Durability = 0;
doomed.HullDamage.OnNext(1);
SafeAssert.NotIn(zone, doomed, "a destroyed entity leaves the zone");
Assert.False(player.IsHostileTo(doomed), "the stance toward a destroyed entity outlived it");
}
// Undocking admits the ship unpiloted: back in the zone, active (its live hostility is tracked), with no agent.
[Fact]
public void UndockingAdmitsWithNoAgent()
{
var zone = Arena(true);
var player = Stage(zone, new TutorialGalaxy()).Player;
var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0);
var agents = zone.Agents.Count;
Assert.NotNull(station.TryDock(player));
SafeAssert.NotIn(zone, player, "a docked ship is out of the zone");
Assert.True(station.TryUndock(player));
SafeAssert.In(zone, player, "an undocked ship is back in the zone");
Assert.Equal(agents, zone.Agents.Count);
Assert.Equal(0, AgentsOf(zone, player));
Assert.True(player.EntityHostility.ContainsKey(station), "an undocked ship is active");
}
// Ambient false keeps the stations and drops every generated ship and turret; ambient keeps them. The arena is the
// galaxy's entrance pack. The entrance holds a story station, placed as the narrative would place one, so a story
// station's own turrets are dropped too.
[Fact]
public void AQuietArenaHasNoGeneratedShipOrTurretButKeepsItsStations()
{
(int ships, int turrets, int stations) Census(ZonePack pack) => (
pack.Entities.OfType<ShipPack>().Count(),
pack.Entities.OfType<OrbitalEntityPack>().Count(e => ((HullData) _cache.Get(e.Hull.Data)).HullType == HullType.Turret),
pack.Entities.OfType<OrbitalEntityPack>().Count(e => ((HullData) _cache.Get(e.Hull.Data)).HullType == HullType.Station));
_galaxy.Entrance.Locations.Add(new LocationStory
{
Zone = _galaxy.Entrance, Name = "story", Type = LocationType.Station, Faction = _protagonist, Turrets = 2
});
var ambient = Census(RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scripted(true, _ => { })));
var quietPack = RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scripted(false, _ => { }));
var quiet = Census(quietPack);
Assert.Same(quietPack, _galaxy.Entrance.PackedContents);
Assert.True(ambient.ships > 0 && ambient.turrets > 0, $"an ambient arena generated {ambient}");
Assert.Equal(0, quiet.ships);
Assert.Equal(0, quiet.turrets);
Assert.True(quiet.stations > 0);
Assert.Equal(ambient.stations, quiet.stations);
Assert.Contains(quietPack.Entities.OfType<OrbitalEntityPack>(), station => station.Story == 0);
}
// A scenario's player is its fit (here a catalog preset), at the scripted place and heading, carrying its cargo.
[Fact]
public void AScenarioPlayerIsItsFitAtItsPlaceWithItsCargo()
{
var generator = new LoadoutGenerator(ref _items.Random, _items, null, null, null, 2);
var preset = Loadouts.Capture(_items, (Ship) EntitySerializer.Unpack(_items, null, generator.GenerateShipLoadout(hull => hull.Name == "Djinni")), "test djinni");
_cache.Upsert(preset);
// Two one-cell designs a manufacturer makes, by name, so the cargo is ordinary branded stock
var cargo = _cache.GetAll<GearData>()
.Where(design => design.Shape.Coordinates.Length == 1)
.Where(design => _cache.GetAll<FactionProductData>().Any(p => p.Design.Key.Equals(_cache.RefOf(design).Key)))
.OrderBy(design => design.Name, StringComparer.Ordinal)
.Take(2)
.ToList();
Assert.Equal(2, cargo.Count);
var zone = Arena(true);
var player = Stage(zone, new Scripted(true, stage =>
stage.Cargo(stage.Player(stage.Preset("test djinni"), float2(120, -40), float2(3, 4)), cargo.Select(design => design.Name).ToArray()))).Player;
Assert.Equal("Djinni", player.HullData.Name);
SafeAssert.In(zone, player, "the player is admitted");
Assert.Equal(float3(120, 0, -40), player.Position);
Assert.True(length(player.Direction - float2(.6f, .8f)) < 1e-5f, $"heading {player.Direction}");
Assert.Equal(
preset.Slots.Select(slot => (slot.Position, slot.Design.Key)).OrderBy(s => s.ToString()),
player.Equipment.Concat<EquippedItem>(player.CargoBays).Concat(player.DockingBays)
.Where(item => item != player.EquippedHull)
.Select(item => (item.Position, _cache.RefOf(_items.GetData(item.EquippableItem)).Key))
.OrderBy(s => s.ToString()));
Assert.Equal(cargo.Select(design => _cache.RefOf(design).Key.Value).OrderBy(k => k),
player.CargoBays.First().Cargo.Keys.Select(item => item.Data.Key.Value).OrderBy(k => k));
}
// A turret hull stages as an orbital entity with no orbit, which stays where it is put.
[Fact]
public void ATurretStagesAsAStationaryOrbitalEntity()
{
var zone = Arena(true);
var staged = Stage(zone, new Scripted(true, stage =>
{
stage.Player(stage.Bare("Djinni"), float2(0, 0));
stage.Place(stage.Bare(TurretHull().Name), float2(200, 50), stance: ScenarioStance.Hostile);
}));
var turret = Assert.IsType<OrbitalEntity>(staged.Entities[0]);
Assert.False(turret.OrbitData.IsSet());
SafeAssert.In(zone, turret, "the turret is admitted");
for (var tick = 0; tick < 20; tick++) zone.Update(.1f);
Assert.Equal(float2(200, 50), turret.Position.xz);
}
// A run has exactly one player, and it is a ship; a station is never staged, and a turret is never piloted. Each
// is a staging failure, named, with nothing admitted.
[Fact]
public void AMisplacedHullOrPlayerIsAStagingFailure()
{
var station = _cache.GetAll<HullData>().First(hull => hull.HullType == HullType.Station).Name;
var turret = TurretHull().Name;
foreach (var (script, expected) in new (Action<ScenarioStage>, string)[]
{
(stage => { stage.Player(stage.Bare("Djinni"), float2(0, 0)); stage.Place(stage.Bare(station), float2(200, 0)); }, $"{station} is a station hull"),
(stage => stage.Player(stage.Bare(turret), float2(0, 0)), $"player: {turret} is not a ship hull"),
(stage => { stage.Player(stage.Bare("Djinni"), float2(0, 0)); stage.Place(stage.Bare(turret), float2(200, 0), piloted: true); }, "cannot be piloted"),
(stage => stage.Place(stage.Bare("Djinni"), float2(200, 0)), "places no player"),
(stage => { stage.Player(stage.Bare("Djinni"), float2(0, 0)); stage.Player(stage.Bare("Djinni"), float2(9, 0)); }, "a second player"),
})
{
var failures = Refused(Arena(true), new Scripted(true, script));
Assert.Contains(failures, failure => failure.Contains(expected));
}
}
// Arcs' geometry: the bow hull sits inside every one of the player's mount arcs, the stern hull outside all of them,
// and the turret is hostile.
[Fact]
public void ArcsGeometry()
{
var staged = Stage(Arena(false), new Arcs());
var player = staged.Player;
var bow = staged.Entities[0];
var stern = staged.Entities[1];
var turret = Assert.IsType<OrbitalEntity>(staged.Entities[2]);
Assert.Equal((Arcs.Bow, Arcs.Stern, Arcs.Turret), (bow.Position.xz, stern.Position.xz, turret.Position.xz));
var mounts = player.Equipment.Where(item => item.Behaviors.OfType<Weapon>().Any()).ToList();
Assert.NotEmpty(mounts);
foreach (var mount in mounts)
{
Assert.True(FireControl.InArc(mount, bow.Position - player.Position), $"{mount.Data.Name} cannot bear on the bow hull");
Assert.False(FireControl.InArc(mount, stern.Position - player.Position), $"{mount.Data.Name} bears on the stern hull");
}
Assert.True(player.IsHostileTo(turret) && turret.IsHostileTo(player), "the turret is hostile");
Assert.False(player.IsHostileTo(bow) || player.IsHostileTo(stern), "the bare hulls are neutral");
}
// Q3 (operator, 2026-09-30): a design no product sells is a scenario test design and nothing else. A test design
// is one some scenario's staged entities carry, fitted or in cargo. These are the unsold designs none carries.
private List<string> UnsoldAndUnplaced(IEnumerable<RunStart.Staged> runs)
{
var sold = _cache.GetAll<FactionProductData>().Select(product => product.Design.Key).ToHashSet();
var placed = new HashSet<CultRecordKey>();
foreach (var entity in runs.SelectMany(run => run.Entities.Prepend(run.Player)))
{
foreach (var item in entity.Equipment.Concat<EquippedItem>(entity.CargoBays).Concat(entity.DockingBays))
placed.Add(_cache.RefOf(_items.GetData(item.EquippableItem)).Key);
foreach (var item in entity.CargoBays.SelectMany(bay => bay.Cargo.Keys)) placed.Add(item.Data.Key);
}
return _cache.GetAll<EquippableItemData>()
.Where(design => !sold.Contains(_cache.RefOf(design).Key) && !placed.Contains(_cache.RefOf(design).Key))
.Select(design => design.Name).OrderBy(name => name, StringComparer.Ordinal).ToList();
}
// The shipped catalog holds no unsold design that no scenario carries (today, none unsold at all). A new unsold
// design is flagged until a scenario fits it or carries it as cargo.
[Fact]
public void EveryUnsoldDesignIsAScenarioTestDesign()
{
var runs = EveryScenario.Select(scenario => Launch(scenario, Inputs(() => GalaxySeed)).staged).ToList();
Assert.Empty(UnsoldAndUnplaced(runs));
void Probe(string name)
{
var shape = new Shape(1, 1);
shape[int2(0, 0)] = true;
_cache.Upsert(new GearData { Name = name, Hardpoint = HardpointType.Tool, Shape = shape, Durability = 1 });
}
Probe("Census Fitted");
Probe("Census Carried");
Assert.Equal(new[] { "Census Carried", "Census Fitted" }, UnsoldAndUnplaced(runs));
runs.Add(Stage(Arena(false), new Scripted(false, stage =>
{
var ship = stage.Player(stage.Fit("Djinni", ("Census Fitted", int2(1, 6), ItemRotation.None), ("Store-All Plus", int2(3, 6), ItemRotation.None)), float2(0, 0));
stage.Cargo(ship, "Census Carried");
})));
Assert.Empty(UnsoldAndUnplaced(runs));
}
// Whether a run is the prelude is the galaxy's fact (Galaxy.IsPrelude); a save records it with no second owner.
[Fact]
public void SaveReadsPrelude()
{
var arena = Arena(true);
var (prelude, _) = RunSave.Capture(_cache, _galaxy, arena, null, new SavedActionBarBinding[0], 0);
Assert.True(prelude.IsTutorial);
var (main, _) = RunSave.Capture(_cache, MainSectorGalaxy(), arena, null, new SavedActionBarBinding[0], 0);
Assert.False(main.IsTutorial);
}
// PlayerSettings Key 2, the never-written tutorial flag, is retired: a player.cc written with it still loads, every other field intact.
[Fact]
public void OldPlayerSettingsLoad()
{
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(PlayerSettings).Assembly);
var buffer = new ArrayBufferWriter<byte>();
var writer = new MessagePackWriter(buffer);
writer.WriteArrayHeader(7);
writer.Write("Pilot");
writer.WriteNil(); // Key 1, retired before this cut
writer.Write(true); // Key 2, the retired tutorial flag
MessagePackSerializer.Serialize(ref writer, new Dictionary<string, string> { ["story"] = "hash" }, options);
MessagePackSerializer.Serialize(ref writer, new PlayerGameplaySettings { TemperatureUnit = TemperatureUnit.Kelvin, SignificantDigits = 5 }, options);
MessagePackSerializer.Serialize(ref writer, new PlayerInputSettings { ActionBarInputs = { "<Keyboard>/1" } }, options);
MessagePackSerializer.Serialize(ref writer, new PlayerGraphicsSettings { NebulaQuality = Quality.High, ShowAsteroidsInMinimap = true }, options);
writer.Flush();
var settings = MessagePackSerializer.Deserialize<PlayerSettings>(buffer.WrittenMemory, options);
Assert.Equal("Pilot", settings.Name);
Assert.Equal("hash", settings.HashedStoryFiles["story"]);
Assert.Equal((TemperatureUnit.Kelvin, 5), (settings.GameplaySettings.TemperatureUnit, settings.GameplaySettings.SignificantDigits));
Assert.Equal("<Keyboard>/1", Assert.Single(settings.InputSettings.ActionBarInputs));
Assert.Equal((Quality.High, true), (settings.GraphicsSettings.NebulaQuality, settings.GraphicsSettings.ShowAsteroidsInMinimap));
}
}