Every new run is a scenario: the script harness, the tutorial and main galaxies as scenarios, the New Game submenu and six test arenas

A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.

New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-03 20:33:55 +02:00
commit d0782a7d14
31 changed files with 1173 additions and 611 deletions

View file

@ -106,6 +106,8 @@ public class ActionGameManager : MonoBehaviour
}
public static Galaxy CurrentGalaxy;
// The scenario a new run starts from: MainMenu.Launch sets it, and StartGame's new-run branch consumes it once.
public static Scenario PendingScenario;
public GameSettings Settings;
//public string StarterShipTemplate = "Longinus";
@ -796,14 +798,15 @@ public class ActionGameManager : MonoBehaviour
var saved = CultCache.GetGlobal<SavedGame>();
if (saved == null)
{
var scenario = PendingScenario ?? throw new InvalidOperationException("A new run needs a scenario; MainMenu.Launch sets one.");
PendingScenario = null;
SectorMap.QueueZoneReveal(CurrentGalaxy.Entrance.AdjacentZones.Prepend(CurrentGalaxy.Entrance));
// RunStart owns what a new run starts with (docs/scenarios-cut.md, 2.2); this only enters and binds.
RunStart.GenerateArena(ItemManager, Settings.ZoneSettings, CurrentGalaxy, null);
// RunStart owns what a new run starts with (docs/scenarios-cut.md, R.4); this only enters and binds.
RunStart.GenerateArena(ItemManager, Settings.ZoneSettings, CurrentGalaxy, scenario);
PopulateLevel(CurrentGalaxy.Entrance);
var failures = new List<string>();
var staged = RunStart.Stage(ItemManager, Zone, null, Settings.StartingHullName,
CurrentGalaxy.IsPrelude ? CurrentGalaxy.ResolveFaction(Settings.TutorialGenerationSettings.ProtagonistFaction) : null, failures)
?? throw new InvalidOperationException($"The new run could not stage: {string.Join("; ", failures)}");
var staged = RunStart.Stage(ItemManager, Zone, scenario, Settings.StartingHullName, Settings.TutorialGenerationSettings, failures)
?? throw new InvalidOperationException($"Scenario {scenario.Name} could not stage: {string.Join("; ", failures)}");
BindToEntity(staged.Player);
}
else

View file

@ -6,7 +6,7 @@ using GameCult.Caching.MessagePack;
// Aetheria's stores: one home store per type, composed here and nowhere else.
public static class AetheriaStores
{
public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout), typeof(Scenario) };
public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout) };
public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame), typeof(ProvenanceLedger) };
public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) };

View file

@ -0,0 +1,86 @@
using System;
using System.IO;
using GameCult.Caching;
using MessagePack;
using static CultMath.math;
using Random = CultMath.Random;
// The galaxy half of a scenario's vocabulary (docs/scenarios-cut.md, R.4): the two kinds of galaxy the game generates,
// at the scenario's seed. The menu hands it the generation inputs; RunStart.Generate sets the seed and hands it to
// Scenario.Generate. The background's noise position derives from the same seed, so a fixed seed fixes the whole
// galaxy, and a clock seed makes every launch a new one.
public sealed class GalaxyStage
{
private readonly SectorGenerationSettings _sector;
private readonly SectorBackgroundSettings _sectorBackground;
private readonly TutorialGenerationSettings _prelude;
private readonly SectorBackgroundSettings _preludeBackground;
private readonly NameGeneratorSettings _names;
private readonly CultCache _cache;
private readonly PlayerSettings _playerSettings;
private readonly DirectoryInfo _narrativeDirectory;
private readonly Action<string> _log;
private readonly Action<string> _progress;
private readonly Func<uint> _clock;
public GalaxyStage(
SectorGenerationSettings sector,
SectorBackgroundSettings sectorBackground,
TutorialGenerationSettings prelude,
SectorBackgroundSettings preludeBackground,
NameGeneratorSettings names,
CultCache cache,
PlayerSettings playerSettings,
DirectoryInfo narrativeDirectory,
Action<string> log,
Action<string> progress = null,
Func<uint> clock = null)
{
_sector = sector;
_sectorBackground = sectorBackground;
_prelude = prelude;
_preludeBackground = preludeBackground;
_names = names;
_cache = cache;
_playerSettings = playerSettings;
_narrativeDirectory = narrativeDirectory;
_log = log;
_progress = progress;
_clock = clock ?? (() => (uint) (DateTime.Now.Ticks % uint.MaxValue));
}
// The seed this generation runs at, never zero: the scenario's, or the clock's when the scenario's is zero.
public uint Seed { get; private set; }
internal void SeedFrom(Scenario scenario)
{
var clock = _clock();
Seed = scenario.Seed != 0 ? scenario.Seed : clock != 0 ? clock : 1;
}
// A main galaxy: the sector settings, factions drawn from the catalog, an exit to reach.
public Galaxy Main()
{
var background = Copy(_sectorBackground);
var random = new Random(Seed);
background.NoisePosition = random.NextFloat() * 1000;
return new Galaxy(_sector, background, _names, _cache, _log, _progress, Seed);
}
// A prelude galaxy: the tutorial settings and their fixed factions, centred in dense cloud.
public Galaxy Prelude()
{
var background = Copy(_preludeBackground);
var random = new Random(Seed);
var iteration = 1;
do
{
background.NoisePosition = random.NextFloat() * 1000;
_progress?.Invoke($"Finding Galaxy Position: iteration {iteration++}");
} while (background.CloudDensity(float2(.5f)) < .5f);
return new Galaxy(_prelude, background, _names, _cache, _playerSettings, _narrativeDirectory, _log, _progress, Seed);
}
private static SectorBackgroundSettings Copy(SectorBackgroundSettings settings) =>
MessagePackSerializer.Deserialize<SectorBackgroundSettings>(MessagePackSerializer.Serialize(settings));
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f5cf458f47074786bf11e72b5d93c545
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,131 +1,54 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CultMath;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// The owner of what a new run starts with (docs/scenarios-cut.md, 2.2): how the arena zone is generated, the player
// ship, and a scenario's other entities with their stance, pilot and cargo. A plain New Game is the absent-scenario
// case of the same path. Nothing else generates, unpacks or admits a new run's player; everything it stages joins the
// arena through Zone.Admit. It holds no Unity type.
// The owner of a new run (docs/scenarios-cut.md, R.4). Every new run is a scenario: RunStart generates its galaxy,
// generates its arena (the galaxy's entrance zone) under the scenario's Ambient rule, and admits what the scenario
// stages through Zone.Admit, all of it or none of it. A scenario only declares; nothing else generates a new run's
// galaxy, generates or admits its player, or chooses its hull or faction. It holds no Unity type.
public static class RunStart
{
public sealed class Staged
{
public Ship Player;
public List<Entity> Entities; // the scenario's entities, in its order; empty with no scenario
public List<Entity> Entities; // what the scenario placed besides the player, in its order
}
// The scenario's galaxy, at the scenario's seed or, when that is zero, the clock's.
public static Galaxy Generate(Scenario scenario, GalaxyStage stage)
{
stage.SeedFrom(scenario);
return scenario.Generate(stage) ?? throw new InvalidOperationException($"Scenario {scenario.Name} generated no galaxy.");
}
// The arena is the galaxy's entrance zone, generated under the scenario's Ambient rule; with no scenario, ambient.
public static ZonePack GenerateArena(ItemManager itemManager, ZoneGenerationSettings zoneSettings, Galaxy galaxy, Scenario scenario) =>
galaxy.Entrance.PackedContents = ZoneGenerator.GenerateZone(
itemManager, zoneSettings, galaxy, galaxy.Entrance, galaxy.IsPrelude, scenario?.Ambient ?? true);
itemManager, zoneSettings, galaxy, galaxy.Entrance, galaxy.IsPrelude, scenario.Ambient);
// The check entry: every failure staging this scenario would hit, found with no zone. With no galaxy there is no
// availability to filter by (LoadoutGenerator.IsAvailable), and a scenario's arena is a prelude, which offers every
// product anyway. It touches no store, so the menu can call it before it clears the saved run.
public static List<string> Check(ItemManager itemManager, Scenario scenario)
{
var failures = new List<string>();
if (scenario != null) Build(itemManager, null, scenario, _ => true, failures);
return failures;
}
// Stages a new run into its arena, all-or-nothing: null, with every failure listed and the zone unchanged, unless
// everything builds. With no scenario: a generated ship of the startingHull (any ship hull when it is empty) for
// defaultFaction, at the origin. With one: its player and entities, each at its authored position and heading with
// its cargo, each entity's stance set both ways with the player, and a Minion for each piloted one.
// Runs the scenario's Stage, then admits its player and everything it placed: each entity's stance set both ways
// with the player, a pilot for each piloted one. Returns null, with every failure listed and the arena unchanged,
// unless the whole scenario staged.
public static Staged Stage(ItemManager itemManager, Zone arena, Scenario scenario, string startingHull,
Faction defaultFaction, List<string> failures)
{
if (scenario == null)
{
var generator = new LoadoutGenerator(ref itemManager.Random, itemManager, arena.Galaxy, arena.GalaxyZone, defaultFaction, 2);
var pack = generator.GenerateShipLoadout(data => string.IsNullOrEmpty(startingHull) || data.Name == startingHull);
if (pack == null)
{
failures.Add($"player: no ship hull named {startingHull}");
return null;
}
var ship = (Ship) EntitySerializer.Unpack(itemManager, arena, pack);
ship.IsPlayerShip = true;
ship.Position = float3.zero;
arena.Admit(ship, piloted: false);
return new Staged { Player = ship, Entities = new List<Entity>() };
}
// Presets carry no faction, so availability is the galaxy's alone.
var availability = new LoadoutGenerator(ref itemManager.Random, itemManager, arena.Galaxy, arena.GalaxyZone, null, 2);
var built = Build(itemManager, arena, scenario, availability.IsAvailable, failures);
if (built == null) return null;
var (player, entities) = built.Value;
player.IsPlayerShip = true;
arena.Admit(player, piloted: false);
for (var i = 0; i < entities.Count; i++)
{
var hostile = scenario.Entities[i].Stance == ScenarioStance.Hostile;
entities[i].SetIff(player, hostile);
player.SetIff(entities[i], hostile);
arena.Admit(entities[i], scenario.Entities[i].Piloted);
}
return new Staged { Player = player, Entities = entities };
}
// Everything a scenario places, built and positioned but admitted nowhere; null when anything failed.
private static (Ship player, List<Entity> entities)? Build(ItemManager itemManager, Zone zone, Scenario scenario,
Predicate<FactionProductData> isAvailable, List<string> failures)
TutorialGenerationSettings tutorialGenerationSettings, List<string> failures)
{
var stage = new ScenarioStage(itemManager, arena, startingHull, tutorialGenerationSettings);
scenario.Stage(stage);
var reported = failures.Count;
Entity player = null;
if (scenario.Player == null) failures.Add("player: the scenario places no player");
else
{
player = Place(itemManager, zone, scenario.Player, "player", isAvailable, failures);
if (player != null && !(player is Ship)) failures.Add($"player: {player.Name} is not a ship hull");
}
var entities = new List<Entity>();
for (var i = 0; i < scenario.Entities.Count; i++)
{
var where = $"entity {i}";
var entity = Place(itemManager, zone, scenario.Entities[i], where, isAvailable, failures);
if (entity != null && scenario.Entities[i].Piloted && !(entity is Ship))
failures.Add($"{where}: {entity.Name} is not a ship hull, so it cannot be piloted");
entities.Add(entity);
}
return failures.Count > reported ? null : ((Ship) player, entities);
}
private static Entity Place(ItemManager itemManager, Zone zone, ScenarioShip placed, string where,
Predicate<FactionProductData> isAvailable, List<string> failures)
{
var loadout = itemManager.ItemData.Get(placed.Loadout);
if (loadout == null)
{
failures.Add($"{where}: preset {placed.Loadout.Key} is not in the catalog");
return null;
}
var reported = failures.Count;
var materialized = new List<string>();
var entity = Loadouts.Materialize(itemManager, zone, loadout, isAvailable, materialized);
failures.AddRange(materialized.Select(failure => $"{where} ({loadout.Name}): {failure}"));
var cargo = placed.Cargo.Select(design => Loadouts.Resolve(itemManager, design, isAvailable, $"{where} cargo", failures)).ToList();
failures.AddRange(stage.Failures);
if (!stage.PlayerPlaced)
failures.Add("player: the scenario places no player");
if (failures.Count > reported) return null;
entity.Position.xz = placed.Position;
if (lengthsq(placed.Direction) > 0) entity.Direction = normalize(placed.Direction);
var bay = entity.CargoBays.FirstOrDefault();
foreach (var build in cargo)
var player = stage.PlayerShip;
player.IsPlayerShip = true;
arena.Admit(player, piloted: false);
foreach (var placed in stage.Placed)
{
var item = build();
if (bay == null || !bay.TryStore(item))
failures.Add($"{where} cargo: no room for {itemManager.GetData(item).Name}");
var hostile = placed.Stance == ScenarioStance.Hostile;
placed.Entity.SetIff(player, hostile);
player.SetIff(placed.Entity, hostile);
arena.Admit(placed.Entity, placed.Piloted);
}
return failures.Count > reported ? null : entity;
return new Staged { Player = player, Entities = stage.Placed.Select(placed => placed.Entity).ToList() };
}
}

View file

@ -1,38 +1,27 @@
using System.Collections.Generic;
using GameCult.Caching;
using MessagePack;
using CultMath;
// A scenario is an arena whose conditions are present at spawn (docs/scenarios-cut.md): the player's ship and every
// other entity already fitted and placed. Authored catalog data, read at runtime; RunStart stages it into a new run.
// It sets conditions and nothing else: once staged, its entities are ordinary run state.
[CultDocument("aetheria.scenario", "1"), MessagePackObject]
public class Scenario
// A scenario is one way to set up a new run (docs/scenarios-cut.md, R): a script, compiled with the game, that says
// which galaxy to generate and what the run starts with. New Game lists them (Scenarios). RunStart runs one: it
// generates the galaxy through GalaxyStage, the arena under Ambient, and admits what Stage placed through a
// ScenarioStage. A scenario only declares; once staged, everything it placed is ordinary run state.
public abstract class Scenario
{
[CultName, Key(0)] public string Name;
[Key(1)] public string Brief; // the conditions in a line or two, shown in the menu
[Key(2)] public uint Seed; // the arena galaxy derives from it, so every launch has one layout
[Key(3)] public bool Ambient; // false: planets, orbits and stations, but no generated ship or turret
[Key(4)] public ScenarioShip Player;
[Key(5)] public List<ScenarioEntity> Entities = new List<ScenarioEntity>();
}
public abstract string Name { get; }
// A preset placed in the arena. A ship hull stages a Ship; a turret hull an orbital entity with no orbit, which stays
// where it is put.
[MessagePackObject]
public class ScenarioShip
{
[Key(0)] public CultRecordRef<Loadout> Loadout;
[Key(1)] public float2 Position; // zone xz
[Key(2)] public float2 Direction; // zero keeps the entity's default heading
[Key(3)] public List<CultRecordRef<EquippableItemData>> Cargo = new List<CultRecordRef<EquippableItemData>>(); // first cargo bay
}
// The conditions and what to verify in them, shown under the scenario's button.
public abstract string Brief { get; }
[MessagePackObject]
public class ScenarioEntity : ScenarioShip
{
[Key(4)] public ScenarioStance Stance; // applied both ways between this entity and the player
[Key(5)] public bool Piloted; // a Minion flies it; otherwise it has no agent and stays put
// Zero draws the seed from the clock, so each launch is a new galaxy. Anything else fixes the galaxy, its
// background and its arena, so every launch has the same layout.
public virtual uint Seed => 0;
// False: the arena keeps its planets, orbits and stations but generates no ship or turret, so nothing wanders into
// the scenario's conditions.
public virtual bool Ambient => true;
// Which galaxy the run is in: stage.Main() or stage.Prelude().
public abstract Galaxy Generate(GalaxyStage stage);
// What the run starts with: exactly one stage.Player, and anything else the scenario places.
public abstract void Stage(ScenarioStage stage);
}
public enum ScenarioStance

View file

@ -0,0 +1,168 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CultMath;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// The run half of a scenario's vocabulary (docs/scenarios-cut.md, R.4). A scenario's Stage builds its fits and places
// them here; nothing joins the arena until Stage returns with no failure, and then RunStart admits it all at once.
// Every verb names what it could not do instead of throwing, so one bad name in a script lists every bad name.
// Designs and hulls are named as the catalog names them. Only RunStart builds a stage.
public sealed class ScenarioStage
{
private readonly ItemManager _items;
private readonly Predicate<FactionProductData> _available;
private readonly List<string> _failures = new List<string>();
private readonly List<Placement> _placed = new List<Placement>();
private int _placements;
internal ScenarioStage(ItemManager items, Zone arena, string startingHull, TutorialGenerationSettings tutorialGenerationSettings)
{
_items = items;
Arena = arena;
StartingHull = startingHull;
TutorialGenerationSettings = tutorialGenerationSettings;
// A fit names designs, not manufacturers, so what the galaxy offers decides which product builds each one.
_available = new LoadoutGenerator(ref items.Random, items, arena.Galaxy, arena.GalaxyZone, null, 2).IsAvailable;
}
public Galaxy Galaxy => Arena.Galaxy;
public Zone Arena { get; }
public string StartingHull { get; }
public TutorialGenerationSettings TutorialGenerationSettings { get; }
internal IReadOnlyList<string> Failures => _failures;
internal bool PlayerPlaced { get; private set; }
internal Ship PlayerShip { get; private set; }
internal IReadOnlyList<Placement> Placed => _placed;
internal sealed class Placement
{
public Entity Entity;
public ScenarioStance Stance;
public bool Piloted;
}
// A hull fitted slot by slot: each slot a design, the hull cell it goes in, and its rotation.
public ScenarioFit Fit(string hull, params (string design, int2 cell, ItemRotation rotation)[] slots)
{
var where = $"fit {hull}";
var hullData = Named<HullData>(hull, where);
var loadout = new Loadout { Name = hull, WeaponGroups = new int[0][] };
if (hullData != null) loadout.Hull = _items.ItemData.RefOf(hullData);
foreach (var (design, cell, rotation) in slots)
{
var data = Named<EquippableItemData>(design, where);
if (data == null) continue;
loadout.Slots.Add(new LoadoutSlot { Design = _items.ItemData.RefOf(data), Position = cell, Rotation = rotation });
}
return hullData != null && loadout.Slots.Count == slots.Length ? new ScenarioFit(loadout) : null;
}
// A hull with nothing fitted.
public ScenarioFit Bare(string hull) => Fit(hull);
// A fit generated as the game generates its ships, for the faction when one is given. An empty hull means any
// ship hull.
public ScenarioFit Generated(string hull, Faction faction = null)
{
var generator = new LoadoutGenerator(ref _items.Random, _items, Galaxy, Arena.GalaxyZone, faction, 2);
var pack = generator.GenerateShipLoadout(data => string.IsNullOrEmpty(hull) || data.Name == hull);
if (pack != null) return new ScenarioFit(pack);
_failures.Add($"generated: no ship hull named {hull}");
return null;
}
// A loadout preset from the catalog, as capturepreset or Studio wrote it.
public ScenarioFit Preset(string name)
{
var preset = _items.ItemData.GetAll<Loadout>().FirstOrDefault(loadout => loadout.Name == name);
if (preset != null) return new ScenarioFit(preset);
_failures.Add($"preset {name}: no such preset");
return null;
}
// The player's ship. A run has exactly one, and it is a ship hull.
public Ship Player(ScenarioFit fit, float2 at, float2 facing = default)
{
if (PlayerPlaced)
{
_failures.Add("player: the scenario places a second player");
return null;
}
PlayerPlaced = true;
var entity = Build(fit, "player", at, facing);
if (entity == null) return null;
if (entity is Ship ship) return PlayerShip = ship;
_failures.Add($"player: {entity.Name} is not a ship hull");
return null;
}
// Anything else in the arena. A stance holds both ways between it and the player. A piloted ship gets the zone's
// pilot; an unpiloted entity has none and goes nowhere on its own. A turret hull stays where it is put.
public Entity Place(ScenarioFit fit, float2 at, float2 facing = default, ScenarioStance stance = ScenarioStance.Neutral, bool piloted = false)
{
var where = $"entity {_placements++}";
var entity = Build(fit, where, at, facing);
if (entity == null) return null;
if (piloted && !(entity is Ship)) _failures.Add($"{where}: {entity.Name} is not a ship hull, so it cannot be piloted");
_placed.Add(new Placement { Entity = entity, Stance = stance, Piloted = piloted });
return entity;
}
// Items in the entity's first cargo bay, one per design named.
public void Cargo(Entity entity, params string[] designs)
{
if (entity == null) return;
var where = $"cargo of {entity.Name}";
var bay = entity.CargoBays.FirstOrDefault();
foreach (var design in designs)
{
var data = Named<EquippableItemData>(design, where);
if (data == null) continue;
var build = Loadouts.Resolve(_items, _items.ItemData.RefOf(data), _available, where, _failures);
if (build != null && (bay == null || !bay.TryStore(build())))
_failures.Add($"{where}: no room for {design}");
}
}
private Entity Build(ScenarioFit fit, string where, float2 at, float2 facing)
{
if (fit == null) return null; // the verb that made it named why
Entity entity;
if (fit.Pack != null) entity = EntitySerializer.Unpack(_items, Arena, fit.Pack);
else
{
var failures = new List<string>();
entity = Loadouts.Materialize(_items, Arena, fit.Loadout, _available, failures);
_failures.AddRange(failures.Select(failure => $"{where} ({fit.Loadout.Name}): {failure}"));
if (entity == null) return null;
}
entity.Position = float3(at.x, 0, at.y);
if (lengthsq(facing) > 0) entity.Direction = normalize(facing);
return entity;
}
private T Named<T>(string name, string where) where T : EquippableItemData
{
var named = _items.ItemData.GetAll<T>().Where(data => data.Name == name).Take(2).ToArray();
if (named.Length == 1) return named[0];
_failures.Add(named.Length == 0 ? $"{where}: no design named {name}" : $"{where}: more than one design is named {name}");
return null;
}
}
// What a scenario places: a fit built from named designs or a preset, or a ship generated as the game generates them.
// Only ScenarioStage makes one.
public sealed class ScenarioFit
{
internal readonly Loadout Loadout;
internal readonly EntityPack Pack;
internal ScenarioFit(Loadout loadout) => Loadout = loadout;
internal ScenarioFit(EntityPack pack) => Pack = pack;
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d8cd00dc23604dfb91e128e1a9a4a3d5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 35866d82219241ae9fdb0030039afbed
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,46 @@
using static CultMath.math;
using static ItemRotation;
public sealed class Arcs : Scenario
{
public override string Name => "Arcs";
public override string Brief => "Forward mounts, a bare hull off the bow, one off the stern, a hostile turret. Verify: the bow " +
"hull can be hit, nothing fires at the stern one through the ship, the turret tracks you all the way round.";
public override uint Seed => 103;
public override bool Ambient => false;
// Where the targets sit: the player is at the origin facing +z, and every mount fires forward.
public static readonly CultMath.float2 Bow = float2(0, 400), Stern = float2(0, -400), Turret = float2(600, 0);
public override Galaxy Generate(GalaxyStage stage) => stage.Prelude();
public override void Stage(ScenarioStage stage)
{
var fighter = stage.Fit("LonginusX",
("Cockpit 2x2", int2(2, 6), None),
("Traction", int2(2, 3), None),
("Core Power", int2(2, 1), None),
("GT 3K", int2(0, 5), None),
("GT 3K", int2(5, 5), None),
("FastBlast+-", int2(1, 8), None),
("FastBlast+-", int2(4, 8), None),
("Iapyx", int2(1, 2), CounterClockwise),
("Iapyx", int2(4, 2), Clockwise),
("not if i see you first", int2(3, 10), None),
("Fire Control Array", int2(2, 5), None));
var turret = stage.Fit("Turret",
("ClearPath", int2(1, 2), None),
("ClearPath", int2(5, 2), None),
("Skiron", int2(0, 2), CounterClockwise),
("Skiron", int2(7, 2), Clockwise),
("Core Power", int2(3, 3), None),
("Turret Control Module", int2(3, 5), None),
("not if i see you first", int2(4, 5), None),
("Fire Control Array", int2(3, 6), None));
stage.Player(fighter, float2(0, 0), facing: float2(0, 1));
stage.Place(stage.Bare("LonginusX"), Bow);
stage.Place(stage.Bare("LonginusX"), Stern);
stage.Place(turret, Turret, stance: ScenarioStance.Hostile);
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 724a07b881f34e8c9b32b3f2f338ac78
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,15 @@
using float2 = CultMath.float2;
public sealed class DjinniShakedown : Scenario
{
public override string Name => "Djinni Shakedown";
public override string Brief => "A fitted Djinni in empty space. Verify: its thrusters move and turn it; with the hull " +
"offline and thrusters firing, its speed does not run away.";
public override uint Seed => 101;
public override bool Ambient => false;
public override Galaxy Generate(GalaxyStage stage) => stage.Prelude();
public override void Stage(ScenarioStage stage) =>
stage.Player(stage.Generated("Djinni"), float2.zero);
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1afb559630c241ed813ddd3c444c618b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,35 @@
using static CultMath.math;
using static ItemRotation;
public sealed class Duel : Scenario
{
public override string Name => "Duel";
public override string Brief => "One piloted, hostile LonginusX with slow GT 3K missiles and a Spectra in its hold. Verify: rolled " +
"hits and near-misses, subsystem aim, beam reads above bow, armour takes the missile, loot keeps its brand.";
public override uint Seed => 104;
public override bool Ambient => false;
public override Galaxy Generate(GalaxyStage stage) => stage.Prelude();
public override void Stage(ScenarioStage stage)
{
var fighter = stage.Fit("LonginusX",
("Cockpit 2x2", int2(2, 6), None),
("Traction", int2(2, 3), None),
("Core Power", int2(2, 1), None),
("GT 3K", int2(0, 5), None),
("GT 3K", int2(5, 5), None),
("FastBlast+-", int2(1, 8), None),
("FastBlast+-", int2(4, 8), None),
("Iapyx", int2(1, 2), CounterClockwise),
("Iapyx", int2(4, 2), Clockwise),
("not if i see you first", int2(3, 10), None),
("PotaT+-", int2(1, 5), None),
("Fire Control Array", int2(2, 5), None),
("Store-All Plus", int2(2, 8), None));
stage.Player(fighter, float2(0, 0));
var rival = stage.Place(fighter, float2(0, 900), facing: float2(0, -1), stance: ScenarioStance.Hostile, piloted: true);
stage.Cargo(rival, "Spectra");
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f6ba813d4a9447c89e1bbb6997b7ac35
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,33 @@
using static CultMath.math;
using static ItemRotation;
public sealed class LauncherAngles : Scenario
{
public override string Name => "Launcher Angles";
public override string Brief => "A GT 3K, a pswarm and two FastBlasts; a hostile LonginusX broadside on, a bare Longinus beside it. " +
"Verify: hits pulse only the facing edge; a pswarm round whose target dies fades unexploded.";
public override uint Seed => 105;
public override bool Ambient => false;
public override Galaxy Generate(GalaxyStage stage) => stage.Prelude();
public override void Stage(ScenarioStage stage)
{
var launcher = stage.Fit("LonginusX",
("Cockpit 2x2", int2(2, 6), None),
("Traction", int2(2, 3), None),
("Core Power", int2(2, 1), None),
("GT 3K", int2(0, 5), None),
("pswarm", int2(5, 5), None),
("FastBlast+-", int2(1, 8), None),
("FastBlast+-", int2(4, 8), None),
("Iapyx", int2(1, 2), CounterClockwise),
("Iapyx", int2(4, 2), Clockwise),
("not if i see you first", int2(3, 10), None),
("Fire Control Array", int2(2, 5), None));
stage.Player(launcher, float2(0, 0));
stage.Place(stage.Bare("LonginusX"), float2(0, 900), facing: float2(1, 0), stance: ScenarioStance.Hostile);
stage.Place(stage.Bare("Longinus"), float2(300, 700));
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 58cfaf062ddf458c99c12f3652243b45
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,14 @@
using float2 = CultMath.float2;
public sealed class LongHaul : Scenario
{
public override string Name => "Long Haul";
public override string Brief => "A standard ship in a populated galaxy. Verify: memory stays flat across zones with kills; " +
"warp, quit and Continue keep items, tier and brand; death disables Continue.";
public override uint Seed => 106;
public override Galaxy Generate(GalaxyStage stage) => stage.Prelude();
public override void Stage(ScenarioStage stage) =>
stage.Player(stage.Generated(stage.StartingHull), float2.zero);
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3a7911611f9845709e9ae5ba34d91dfb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,13 @@
using float2 = CultMath.float2;
// The full galaxy: factions drawn from the catalog, bosses on the road to the exit, entered in the starting hull.
public sealed class MainGalaxy : Scenario
{
public override string Name => "Main Galaxy";
public override string Brief => "The full galaxy: factions drawn at random, bosses guarding the road to the exit.";
public override Galaxy Generate(GalaxyStage stage) => stage.Main();
public override void Stage(ScenarioStage stage) =>
stage.Player(stage.Generated(stage.StartingHull), float2.zero);
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c7f6bdb3ed7b4b1aa137569c7e79bccd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,11 @@
// Every way to set up a new run, in the order New Game lists them. Modes are the game; Tests are arenas built to check
// one system each, listed only in editor and development builds.
public static class Scenarios
{
public static readonly Scenario[] Modes = { new TutorialGalaxy(), new MainGalaxy() };
public static readonly Scenario[] Tests =
{
new DjinniShakedown(), new StarvedReactor(), new Arcs(), new Duel(), new LauncherAngles(), new LongHaul()
};
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 12f60a9ad25c4aa0a9b8b098d385ea0e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,29 @@
using static CultMath.math;
using static ItemRotation;
public sealed class StarvedReactor : Scenario
{
public override string Name => "Starved Reactor";
public override string Brief => "Two Spectras on a MoveOnPro, which can fall to a quarter of Core Power's charge, and a bare hull " +
"to shoot. Verify: starvation reads as starvation, brownout degrades, refills stutter.";
public override uint Seed => 102;
public override bool Ambient => false;
public override Galaxy Generate(GalaxyStage stage) => stage.Prelude();
public override void Stage(ScenarioStage stage)
{
var starved = stage.Fit("LonginusX",
("Cockpit 2x2", int2(2, 6), None),
("Traction", int2(2, 3), None),
("MoveOnPro", int2(2, 1), None),
("Spectra", int2(1, 8), None),
("Spectra", int2(4, 8), None),
("Iapyx", int2(1, 2), CounterClockwise),
("Iapyx", int2(4, 2), Clockwise),
("not if i see you first", int2(3, 10), None),
("Fire Control Array", int2(2, 5), None));
stage.Player(starved, float2(0, 0));
stage.Place(stage.Bare("LonginusX"), float2(0, 400));
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 91a81ac4784c4066969cac11ac1937b0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,16 @@
using float2 = CultMath.float2;
// New Game as it has always been: a prelude galaxy, entered in the starting hull of the protagonist faction.
public sealed class TutorialGalaxy : Scenario
{
public override string Name => "Tutorial Galaxy";
public override string Brief => "A small galaxy of fixed factions, entered in a ship of the protagonist faction.";
public override Galaxy Generate(GalaxyStage stage) => stage.Prelude();
public override void Stage(ScenarioStage stage)
{
var protagonist = stage.Galaxy.ResolveFaction(stage.TutorialGenerationSettings.ProtagonistFaction);
stage.Player(stage.Generated(stage.StartingHull, protagonist), float2.zero);
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 94d688b37c5044dd9b67865c706d8918
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -5,15 +5,12 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
using TMPro;
using UniRx;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using static CultMath.math;
using float2 = CultMath.float2;
using Random = UnityEngine.Random;
public class MainMenu : MonoBehaviour
{
@ -118,45 +115,8 @@ public class MainMenu : MonoBehaviour
_nextMenu.panel.AddButton("New Game",
() =>
{
RunSave.Clear(ActionGameManager.CultCache);
var generatorState = "Loading Database Contents";
Action<string> setState = s => generatorState = s;
ShowScenarios();
Fade(true);
_nextMenu.panel.gameObject.SetActive(false);
Dialog.Clear();
Dialog.Title.text = "Generating Galaxy";
Dialog.AddProperty(() => generatorState);
Dialog.Show();
{
var backgroundSettings = MessagePackSerializer.Deserialize<SectorBackgroundSettings>(
MessagePackSerializer.Serialize(Settings.TutorialBackgroundSettings));
int iteration = 1;
do
{
backgroundSettings.NoisePosition = Random.value * 1000;
setState($"Finding Galaxy Position: iteration {iteration++}");
} while (backgroundSettings.CloudDensity(float2(0.5f)) < .5f);
Task.Run(() =>
{
var sector = new Galaxy(
Settings.TutorialGenerationSettings,
backgroundSettings,
Settings.NameGeneratorSettings,
ActionGameManager.CultCache,
ActionGameManager.PlayerSettings,
ActionGameManager.GameDataDirectory.CreateSubdirectory("Narrative"),
Debug.Log,
setState);
Observable.NextFrame().Subscribe(_ =>
{
ActionGameManager.CurrentGalaxy = sector;
EnterGame();
});
}).ContinueWith(t => Debug.LogException(t.Exception), TaskContinuationOptions.OnlyOnFaulted);
}
});
_nextMenu.panel.AddButton("Settings",
() =>
@ -167,6 +127,72 @@ public class MainMenu : MonoBehaviour
_nextMenu.panel.AddButton("Quit", Application.Quit);
}
// New Game: every way to set up a run, each with its brief. The test arenas are listed only in editor and
// development builds.
private void ShowScenarios()
{
_nextMenu.panel.Clear();
_nextMenu.panel.Title.text = TitleSubtitle("new game", "scenarios");
AddScenarios(Scenarios.Modes);
if (Debug.isDebugBuild)
{
_nextMenu.panel.AddSection("test arenas");
AddScenarios(Scenarios.Tests);
}
_nextMenu.panel.AddButton("Back",
() =>
{
ShowMain();
Fade(false);
});
}
private void AddScenarios(IEnumerable<Scenario> scenarios)
{
foreach (var scenario in scenarios)
{
_nextMenu.panel.AddButton(scenario.Name, () => Launch(scenario));
_nextMenu.panel.AddProperty(() => scenario.Brief);
}
}
// The only way into a new run: clears the saved run, generates the scenario's galaxy off the main thread, and
// enters the game, where StartGame has RunStart stage it.
private void Launch(Scenario scenario)
{
RunSave.Clear(ActionGameManager.CultCache);
var generatorState = "Loading Database Contents";
Fade(true);
_nextMenu.panel.gameObject.SetActive(false);
Dialog.Clear();
Dialog.Title.text = "Generating Galaxy";
Dialog.AddProperty(() => generatorState);
Dialog.Show();
var stage = new GalaxyStage(
Settings.SectorGenerationSettings,
Settings.SectorBackgroundSettings,
Settings.TutorialGenerationSettings,
Settings.TutorialBackgroundSettings,
Settings.NameGeneratorSettings,
ActionGameManager.CultCache,
ActionGameManager.PlayerSettings,
ActionGameManager.GameDataDirectory.CreateSubdirectory("Narrative"),
Debug.Log,
state => generatorState = state);
Task.Run(() =>
{
var galaxy = RunStart.Generate(scenario, stage);
Observable.NextFrame().Subscribe(_ =>
{
ActionGameManager.CurrentGalaxy = galaxy;
ActionGameManager.PendingScenario = scenario;
EnterGame();
});
}).ContinueWith(t => Debug.LogException(t.Exception), TaskContinuationOptions.OnlyOnFaulted);
}
// The one way into the game scene. Mod ship prototypes import asynchronously at boot, so the scene waits for them,
// and a preload that could not run at all stops here with its reason rather than in the first zone.
private void EnterGame() => StartCoroutine(EnterGameWhenModShipsAreReady());

View file

@ -229,7 +229,7 @@ public sealed partial class RunStartTests
var low = Heater("P30 Low Heater", 260, 10);
var high = Heater("P30 High Heater", 320, 7);
var hull = Hull("Djinni");
var zone = Arena(new Scenario { Ambient = false });
var zone = Arena(false);
List<float> Bills(params GearData[] order)
{

View file

@ -16,10 +16,9 @@ using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// Scenarios Cut 1 (docs/scenarios-cut.md, 2.2): RunStart owns what a new run starts with, and Zone.Admit is the one
// admission into a zone. Every test stages into a real arena: the entrance zone of a prelude galaxy at a fixed seed,
// built from the shipped catalog and the authored settings (Assets/Resources/Settings.asset). The presets and
// scenarios live in a scratch copy of the catalog, because the shipped one holds none.
// RunStart owns what a new run starts with, and Zone.Admit is the one admission into a zone (docs/scenarios-cut.md,
// 2.2 and R). Every test stages into a real arena: the entrance zone of a prelude galaxy at a fixed seed, built from a
// scratch copy of the shipped catalog and the authored settings (Assets/Resources/Settings.asset).
public sealed partial class RunStartTests : IDisposable
{
private const uint GalaxySeed = 1;
@ -32,6 +31,7 @@ public sealed partial class RunStartTests : IDisposable
private readonly PlanetSettings _planetSettings;
private readonly string _startingHull;
private readonly Faction _protagonist;
private readonly TutorialGenerationSettings _tutorialSettings;
public RunStartTests()
{
@ -49,7 +49,7 @@ public sealed partial class RunStartTests : IDisposable
var authored = AuthoredSettings.Load(repo);
_zoneSettings = authored.Read<ZoneGenerationSettings>("ZoneSettings");
_planetSettings = authored.Read<PlanetSettings>("PlanetSettings");
var tutorial = authored.Read<TutorialGenerationSettings>("TutorialGenerationSettings");
var tutorial = _tutorialSettings = authored.Read<TutorialGenerationSettings>("TutorialGenerationSettings");
_galaxy = new Galaxy(tutorial, authored.Read<SectorBackgroundSettings>("TutorialBackgroundSettings"),
authored.Read<NameGeneratorSettings>("NameGeneratorSettings"), _cache, new PlayerSettings(),
Directory.CreateDirectory(Path.Combine(_root, "Narrative")), _ => { }, null, GalaxySeed);
@ -80,8 +80,9 @@ public sealed partial class RunStartTests : IDisposable
throw new DirectoryNotFoundException("Run from inside the Aetheria repository.");
}
private Zone Arena(Scenario scenario) =>
new Zone(_items, _planetSettings, RunStart.GenerateArena(_items, _zoneSettings, _galaxy, scenario), _galaxy.Entrance, _galaxy);
// The fixture galaxy's arena, with or without its generated ships and turrets.
private Zone Arena(bool ambient) =>
new Zone(_items, _planetSettings, RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scripted(ambient, _ => { })), _galaxy.Entrance, _galaxy);
private HullData Hull(string name) => _cache.GetAll<HullData>().Single(hull => hull.Name == name);
@ -92,314 +93,12 @@ public sealed partial class RunStartTests : IDisposable
.OrderBy(hull => hull.Name, StringComparer.Ordinal)
.First();
private CultRecordRef<Loadout> Preset(Loadout loadout)
{
var existing = _cache.GetAll<Loadout>().FirstOrDefault(other => other.Name == loadout.Name);
if (existing != null) return _cache.RefOf(existing);
_cache.Upsert(loadout);
return _cache.RefOf(loadout);
}
private CultRecordRef<Loadout> Bare(HullData hull) =>
Preset(new Loadout { Name = "bare " + hull.Name, Hull = _cache.RefOf(hull), WeaponGroups = new int[0][] });
// A fully fitted preset: a generated ship of this hull, captured.
private Loadout Fitted(string hullName)
{
var generator = new LoadoutGenerator(ref _items.Random, _items, null, null, null, 2);
var ship = (Ship) EntitySerializer.Unpack(_items, null, generator.GenerateShipLoadout(hull => hull.Name == hullName));
Assert.NotEmpty(ship.CargoBays);
return Loadouts.Capture(_items, ship, "fitted " + hullName);
}
private static ScenarioShip At(CultRecordRef<Loadout> preset, float x, float z) =>
new ScenarioShip { Loadout = preset, Position = float2(x, z) };
private static ScenarioEntity Placed(CultRecordRef<Loadout> preset, float x, float z, ScenarioStance stance, bool piloted) =>
new ScenarioEntity { Loadout = preset, Position = float2(x, z), Stance = stance, Piloted = piloted };
private Scenario Quiet(ScenarioShip player, params ScenarioEntity[] entities) =>
new Scenario { Name = "test", Seed = GalaxySeed, Ambient = false, Player = player, Entities = entities.ToList() };
private RunStart.Staged Stage(Zone zone, Scenario scenario)
{
var failures = new List<string>();
var staged = RunStart.Stage(_items, zone, scenario, _startingHull, _protagonist, failures);
Assert.Empty(failures);
Assert.NotNull(staged);
return staged;
}
// A loadout with only its hull, in memory.
private Loadout BareLoadout(HullData hull) =>
new Loadout { Name = "bare " + hull.Name, Hull = _cache.RefOf(hull), WeaponGroups = new int[0][] };
private static int AgentsOf(Zone zone, Entity entity) => zone.Agents.Count(agent => agent.Ship == entity);
// New Game is the absent-scenario case: the authored starting hull, flagged as the player's, admitted at the
// origin, for the protagonist faction, with no agent. The hull name is an input: staging honours another one too.
[Fact]
public void WithNoScenarioThePlayerIsTheStartingHullAtTheOrigin()
{
Assert.Equal("LonginusX", _startingHull);
foreach (var hullName in new[] { _startingHull, "Djinni" })
{
var zone = Arena(null);
var failures = new List<string>();
var staged = RunStart.Stage(_items, zone, null, hullName, _protagonist, failures);
Assert.Empty(failures);
var player = staged.Player;
Assert.Equal(hullName, player.HullData.Name);
Assert.True(player.IsPlayerShip);
SafeAssert.In(zone, player, "the player is admitted");
Assert.True(zone == player.Zone, "the player's zone is the arena");
Assert.Equal(float3.zero, player.Position);
Assert.Same(_protagonist, player.Faction);
Assert.Equal(0, AgentsOf(zone, player));
Assert.Empty(staged.Entities);
}
}
// A scenario's player is its preset, fitted slot for slot, at the authored position and heading, carrying its cargo.
[Fact]
public void AScenarioPlayerIsItsPresetAtItsPlaceWithItsCargo()
{
var fitted = Fitted("Djinni");
var preset = Preset(fitted);
// 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)
.Select(design => new CultRecordRef<EquippableItemData>(_cache.RefOf(design).Key))
.ToList();
Assert.Equal(2, cargo.Count);
var zone = Arena(null);
var scenario = Quiet(new ScenarioShip { Loadout = preset, Position = float2(120, -40), Direction = float2(3, 4), Cargo = cargo });
var player = Stage(zone, scenario).Player;
Assert.Equal("Djinni", player.HullData.Name);
Assert.True(player.IsPlayerShip);
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(
fitted.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 => design.Key.Value).OrderBy(k => k),
player.CargoBays.First().Cargo.Keys.Select(item => item.Data.Key.Value).OrderBy(k => k));
}
// A piloted entity gets exactly one agent; an unpiloted one gets 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 APilotedEntityHasOneAgentAndAnUnpilotedOneNoneAndStaysPut()
{
var zone = Arena(null);
var staged = Stage(zone, Quiet(At(Bare(Hull("Djinni")), 0, 0),
Placed(Preset(Fitted("LonginusX")), 300, 0, ScenarioStance.Neutral, piloted: true),
Placed(Bare(Hull("LonginusX")), -300, 0, ScenarioStance.Neutral, piloted: false)));
var piloted = staged.Entities[0];
var unpiloted = staged.Entities[1];
Assert.Equal(1, AgentsOf(zone, piloted));
Assert.Equal(0, AgentsOf(zone, unpiloted));
Assert.Equal(0, AgentsOf(zone, staged.Player));
SafeAssert.In(zone, piloted, "the piloted entity is admitted");
SafeAssert.In(zone, unpiloted, "the unpiloted entity is admitted");
var look = unpiloted.LookDirection;
for (var tick = 0; tick < 50; tick++)
{
zone.Update(.1f);
Assert.Equal(float2.zero, ((Ship) unpiloted).MovementDirection);
Assert.Equal(look, unpiloted.LookDirection);
}
Assert.Equal(0, AgentsOf(zone, unpiloted));
}
// A stance holds both ways between the entity and the player.
[Fact]
public void AStanceIsSetBothWays()
{
var longinus = Bare(Hull("LonginusX"));
var staged = Stage(Arena(null), Quiet(At(Bare(Hull("Djinni")), 0, 0),
Placed(longinus, 300, 0, ScenarioStance.Hostile, piloted: false),
Placed(longinus, -300, 0, ScenarioStance.Neutral, piloted: false)));
var player = staged.Player;
var hostile = staged.Entities[0];
var neutral = staged.Entities[1];
Assert.True(player.IsHostileTo(hostile));
Assert.True(hostile.IsHostileTo(player));
Assert.False(player.IsHostileTo(neutral));
Assert.False(neutral.IsHostileTo(player));
// Admission activates: the live hostility each side tracks (what targeting and grudges read) agrees.
Assert.True(player.EntityHostility[hostile]);
Assert.True(hostile.EntityHostility[player]);
Assert.False(player.EntityHostility[neutral]);
Assert.False(neutral.EntityHostility[player]);
}
// 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(null);
var longinus = Bare(Hull("LonginusX"));
var staged = Stage(zone, Quiet(At(Bare(Hull("Djinni")), 0, 0),
Placed(longinus, 300, 0, ScenarioStance.Hostile, piloted: false),
Placed(longinus, -300, 0, ScenarioStance.Hostile, piloted: false)));
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), "the player's stance was lost over docking");
Assert.True(hostile.IsHostileTo(player), "the entity's stance was lost over docking");
Assert.True(player.EntityHostility[hostile], "the player's live hostility was lost over docking");
Assert.True(hostile.EntityHostility[player], "the entity's live hostility was lost over docking");
// Presets 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(null);
var player = Stage(zone, null).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, and a plain New Game, keep
// 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 plain = Census(RunStart.GenerateArena(_items, _zoneSettings, _galaxy, null));
var ambient = Census(RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scenario { Ambient = true }));
var quietPack = RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scenario { Ambient = false });
var quiet = Census(quietPack);
Assert.Same(quietPack, _galaxy.Entrance.PackedContents);
Assert.True(plain.ships > 0 && plain.turrets > 0, $"a plain arena generated {plain}");
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 turret preset stages as an orbital entity with no orbit, which stays where it is put.
[Fact]
public void ATurretPresetStagesAsAStationaryOrbitalEntity()
{
var zone = Arena(null);
var staged = Stage(zone, Quiet(At(Bare(Hull("Djinni")), 0, 0),
Placed(Bare(TurretHull()), 200, 50, ScenarioStance.Hostile, piloted: false)));
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 station preset is refused, and so is a turret as the player or a piloted turret: each is a staging failure
// naming the hull, with nothing admitted.
[Fact]
public void AStationPresetOrAMisplacedTurretIsAStagingFailure()
{
var station = _cache.GetAll<HullData>().First(hull => hull.HullType == HullType.Station);
var turret = TurretHull();
var djinni = Bare(Hull("Djinni"));
foreach (var (scenario, expected) in new[]
{
(Quiet(At(djinni, 0, 0), Placed(Bare(station), 200, 0, ScenarioStance.Neutral, false)), $"{station.Name} is a station hull"),
(Quiet(At(Bare(turret), 0, 0)), $"player: {turret.Name} is not a ship hull"),
(Quiet(At(djinni, 0, 0), Placed(Bare(turret), 200, 0, ScenarioStance.Neutral, true)), "cannot be piloted"),
})
{
var zone = Arena(null);
var entities = zone.Entities.ToList();
var agents = zone.Agents.ToList();
var failures = new List<string>();
Assert.Null(RunStart.Stage(_items, zone, scenario, _startingHull, _protagonist, failures));
Assert.Contains(failures, failure => failure.Contains(expected));
SafeAssert.Unchanged(zone, entities, agents);
Assert.Contains(RunStart.Check(_items, scenario), failure => failure.Contains(expected));
}
}
// A scenario naming a missing design or preset fails with each named; the check entry finds the same failures with
// no zone and writes no document, and staging admits nothing, not even the parts that built: a player and a piloted
// entity that build cleanly stay out when a later entity fails.
[Fact]
public void AMissingDesignFailsNamedAndChangesNothing()
{
var djinni = Bare(Hull("Djinni"));
var scenario = Quiet(
new ScenarioShip { Loadout = djinni, Cargo = { new CultRecordRef<EquippableItemData>(new CultRecordKey("absent-design")) } },
Placed(Bare(Hull("LonginusX")), 300, 0, ScenarioStance.Hostile, piloted: true),
Placed(new CultRecordRef<Loadout>(new CultRecordKey("absent-preset")), -300, 0, ScenarioStance.Neutral, false));
var zone = Arena(null);
var entities = zone.Entities.ToList();
var agents = zone.Agents.ToList();
var documents = _cache.AllStoredDocuments.Count();
var checkFailures = RunStart.Check(_items, scenario);
Assert.Equal(2, checkFailures.Count);
Assert.Contains("absent-design", checkFailures[0]);
Assert.Contains("absent-preset", checkFailures[1]);
Assert.Equal(documents, _cache.AllStoredDocuments.Count());
var failures = new List<string>();
Assert.Null(RunStart.Stage(_items, zone, scenario, _startingHull, _protagonist, failures));
Assert.Equal(checkFailures, failures);
SafeAssert.Unchanged(zone, entities, agents);
var lateFailure = Quiet(At(djinni, 0, 0),
Placed(Bare(Hull("LonginusX")), 300, 0, ScenarioStance.Hostile, piloted: true),
Placed(new CultRecordRef<Loadout>(new CultRecordKey("absent-preset")), -300, 0, ScenarioStance.Neutral, false));
failures.Clear();
Assert.Null(RunStart.Stage(_items, zone, lateFailure, _startingHull, _protagonist, failures));
Assert.Contains("absent-preset", Assert.Single(failures));
SafeAssert.Unchanged(zone, entities, agents);
Assert.Empty(RunStart.Check(_items, null));
}
// A heater is gear whose thermostat runs its heat only below a target: a low-pass thermotoggle, then heat.
private static bool IsHeater(EquippedItem item) =>
item.Behaviors.OfType<Thermotoggle>().Any(t => !t.ThermotoggleData.HighPass) && item.Behaviors.OfType<Heat>().Any();
@ -408,7 +107,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void EveryGeneratedStationCarriesOneHeaterAndAReactor()
{
var zone = Arena(null);
var zone = Arena(true);
var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f);
for (var i = 0; i < 8; i++)
{
@ -422,7 +121,7 @@ public sealed partial class RunStartTests : IDisposable
// A main-sector galaxy (not the prelude, which offers every product): each station's gear must come from
// manufacturers its faction can reach, the galaxy's factions its allegiance names.
private Galaxy MainGalaxy()
private Galaxy MainSectorGalaxy()
{
var authored = AuthoredSettings.Load(FindRepoRoot());
return new Galaxy(authored.Read<SectorGenerationSettings>("SectorGenerationSettings"),
@ -443,9 +142,9 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void EveryFactionsStationsCarryOnlyGearItCanReachIncludingAHeaterAndAReactor()
{
var galaxy = MainGalaxy();
var galaxy = MainSectorGalaxy();
Assert.False(galaxy.IsPrelude);
var zone = Arena(null);
var zone = Arena(true);
foreach (var faction in galaxy.Factions)
{
var generator = new LoadoutGenerator(ref _items.Random, _items, galaxy, galaxy.Entrance, faction, .5f);
@ -466,7 +165,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void AFactionReachesItsOwnGearAndItsAllegianceNamesOnlyOthers()
{
var galaxy = MainGalaxy();
var galaxy = MainSectorGalaxy();
var products = _cache.GetAll<FactionProductData>().Where(p => p.Manufacturer.IsSet()).ToArray();
foreach (var faction in galaxy.Factions)
{
@ -483,7 +182,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void AStationWithoutZhestokostAccessIsPoweredAndStaysAboveFreezing()
{
var galaxy = MainGalaxy();
var galaxy = MainSectorGalaxy();
Faction Named(string shortName) => _cache.GetAll<Faction>().Single(f => f.ShortName == shortName);
var coop = new Faction { Name = "Test Cooperative", ShortName = "Coop" };
_cache.Upsert(coop);
@ -497,7 +196,7 @@ public sealed partial class RunStartTests : IDisposable
var own = new FactionProductData { Name = "Cooperative Capacitor", Design = products.First().Design, Manufacturer = _cache.RefOf(coop) };
_cache.Upsert(own);
Assert.True(generator.IsAvailable(own), "a faction reaches its own manufacturer's gear");
var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs
var zone = Arena(false); // no ships: nothing but the zone runs
Entity station = null;
for (var i = 0; i < 8; i++)
{
@ -525,7 +224,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void AnIdleStationStaysAboveFreezing()
{
var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs
var zone = Arena(false); // no ships: nothing but the zone runs
var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0);
var heater = station.Equipment.Single(IsHeater);
var coldest = float.MaxValue;
@ -542,7 +241,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void AStationHeaterHeatsBelowItsTargetAndNotAbove()
{
var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs
var zone = Arena(false); // no ships: nothing but the zone runs
var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0);
var heater = station.Equipment.Single(IsHeater);
var target = heater.Behaviors.OfType<Thermotoggle>().Single().TargetTemperature;
@ -565,7 +264,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void AHeaterAboveItsTargetDrawsNoPower()
{
var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs
var zone = Arena(false); // no ships: nothing but the zone runs
var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0);
var heater = station.Equipment.Single(IsHeater);
var target = heater.Behaviors.OfType<Thermotoggle>().Single().TargetTemperature;
@ -616,7 +315,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void NoOrbitStandsStillAgainstItsParent()
{
var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs
var zone = Arena(false); // no ships: nothing but the zone runs
Assert.True(zone.AsteroidBelts.Count > 0, "the arena has an asteroid belt to race against");
float2 Relative(Orbit orbit, bool previous) =>
(previous ? orbit.PreviousPosition : orbit.Position) -
@ -638,7 +337,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void EveryAsteroidInABeltIsPlacedForTheSameTime()
{
var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs
var zone = Arena(false); // no ships: nothing but the zone runs
var (key, belt) = zone.AsteroidBelts.First();
var asteroids = ((AsteroidBeltData) zone.Planets[key]).Asteroids;
const float dt = .02f;
@ -668,7 +367,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void MiningWhileTheBeltUpdatesNeverRacesIt()
{
var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs
var zone = Arena(false); // no ships: nothing but the zone runs
var (key, _) = zone.AsteroidBelts.First();
var data = (AsteroidBeltData) zone.Planets[key];
var count = data.Asteroids.Length;
@ -963,7 +662,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void TheBusBillsAHeaterExactlyWhenItsThermostatIsOpen()
{
var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs
var zone = Arena(false); // no ships: nothing but the zone runs
var station = zone.Entities.First(entity => entity is OrbitalEntity && entity.DockingBays.Count > 0);
var heater = station.Equipment.Single(IsHeater);
var thermostat = heater.Behaviors.OfType<Thermotoggle>().Single();
@ -1007,7 +706,7 @@ public sealed partial class RunStartTests : IDisposable
{
Name = "Two Group Gear", Design = new CultRecordRef<CraftedItemData>(_cache.RefOf(gear).Key), Manufacturer = _cache.RefOf(_protagonist)
});
var zone = Arena(new Scenario { Ambient = false });
var zone = Arena(false);
var ship = BareHull(Hull("Djinni"));
ship.Zone = zone;
Assert.True(ship.TryEquip(Instance(gear)));
@ -1018,59 +717,6 @@ public sealed partial class RunStartTests : IDisposable
Assert.True(abs(ship.PowerBus.TotalDemand - 7) < .01f, $"billed {ship.PowerBus.TotalDemand} for a closed 10 and an ungated 7");
}
// Q3, with the operator's 2026-09-30 ruling on the ten product-less designs: a design no product sells is a
// scenario test design and nothing else. A test design is one a scenario places: its preset's hull or a slot of
// it, or cargo it carries. These are the unsold designs no scenario places, by name.
private static List<string> UnsoldAndUnplaced(CultCache cache)
{
var sold = cache.GetAll<FactionProductData>().Select(product => product.Design.Key).ToHashSet();
var placed = new HashSet<CultRecordKey>();
foreach (var scenario in cache.GetAll<Scenario>())
foreach (var ship in scenario.Entities.Prepend(scenario.Player).Where(ship => ship != null))
{
foreach (var cargo in ship.Cargo) placed.Add(cargo.Key);
var preset = cache.Get(ship.Loadout);
if (preset == null) continue;
placed.Add(preset.Hull.Key);
foreach (var slot in preset.Slots) placed.Add(slot.Design.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 outside a scenario (today, none unsold at all). A new unsold design is
// flagged until a scenario places it, in a preset's slot or as an entity's cargo.
[Fact]
public void EveryUnsoldDesignIsAScenarioTestDesign()
{
Assert.Empty(UnsoldAndUnplaced(_cache));
GearData Probe(string name)
{
var design = new GearData { Name = name, Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1 };
_cache.Upsert(design);
return design;
}
var fitted = Probe("Census Fitted");
var carried = Probe("Census Carried");
Assert.Equal(new[] { "Census Carried", "Census Fitted" }, UnsoldAndUnplaced(_cache));
var preset = Preset(new Loadout
{
Name = "census preset", Hull = _cache.RefOf(Hull("Djinni")), WeaponGroups = new int[0][],
Slots = { new LoadoutSlot { Design = new CultRecordRef<EquippableItemData>(_cache.RefOf(fitted).Key) } }
});
var scenario = Quiet(At(Bare(Hull("Djinni")), 0, 0), Placed(preset, 100, 0, ScenarioStance.Neutral, false));
scenario.Name = "census";
_cache.Upsert(scenario);
Assert.Equal(new[] { "Census Carried" }, UnsoldAndUnplaced(_cache));
scenario.Entities[0].Cargo.Add(new CultRecordRef<EquippableItemData>(_cache.RefOf(carried).Key));
_cache.Upsert(scenario);
Assert.Empty(UnsoldAndUnplaced(_cache));
}
// The Q3 rule must not reach a real hull: the hulls scenarios rely on each have a product, and materialize bare,
// branded, under the prelude's availability.
[Fact]
@ -1080,7 +726,7 @@ public sealed partial class RunStartTests : IDisposable
foreach (var hull in new[] { Hull("Djinni"), Hull("LonginusX"), TurretHull() })
{
var failures = new List<string>();
var entity = Loadouts.Materialize(_items, null, _cache.Get(Bare(hull)), available.IsAvailable, failures);
var entity = Loadouts.Materialize(_items, null, BareLoadout(hull), available.IsAvailable, failures);
Assert.Empty(failures);
Assert.NotNull(_items.Brand(entity.Hull).Maker);
}
@ -1091,7 +737,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void AdmitIsTheOnlyAgentCreator()
{
var pack = RunStart.GenerateArena(_items, _zoneSettings, _galaxy, null);
var pack = RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scripted(true, _ => { }));
var packedPlayer = pack.Entities.OfType<ShipPack>().First();
packedPlayer.IsPlayerShip = true;
var zone = new Zone(_items, _planetSettings, pack, _galaxy.Entrance, _galaxy);
@ -1099,44 +745,10 @@ public sealed partial class RunStartTests : IDisposable
Assert.True(ships.Count > 1);
foreach (var ship in ships) Assert.Equal(ship.IsPlayerShip ? 0 : 1, AgentsOf(zone, ship));
var arriving = (Ship) Loadouts.Materialize(_items, zone, _cache.Get(Bare(Hull("Djinni"))), _ => true, new List<string>());
var arriving = (Ship) Loadouts.Materialize(_items, zone, BareLoadout(Hull("Djinni")), _ => true, new List<string>());
var agents = zone.Agents.Count;
zone.Admit(arriving, piloted: false);
SafeAssert.In(zone, arriving, "the arrival is admitted");
Assert.Equal(agents, zone.Agents.Count);
}
// The scenario type is catalog data: it round-trips through the catalog file.
[Fact]
public void AScenarioRoundTripsThroughTheCatalog()
{
var djinni = Bare(Hull("Djinni"));
var cargo = new CultRecordRef<EquippableItemData>(_cache.RefOf(Hull("LonginusX")).Key);
var scenario = new Scenario
{
Name = "Round trip", Brief = "one of everything", Seed = 42, Ambient = true,
Player = new ScenarioShip { Loadout = djinni, Position = float2(1, 2), Direction = float2(0, -1), Cargo = { cargo } },
Entities = { Placed(djinni, 3, 4, ScenarioStance.Hostile, piloted: true) }
};
// Written through its own cache over a second copy of the catalog, disposed, then read back fresh
var copy = Path.Combine(_root, "roundtrip.cc");
File.Copy(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"), copy);
using (var writer = new CultCache(Registry()))
{
writer.AddBackingStore(new SingleFileMessagePackBackingStore(copy), AetheriaStores.CatalogTypes);
writer.Commit(batch => batch.Upsert(typeof(Loadout), _cache.Get(djinni), djinni.Key));
writer.Upsert(scenario);
writer.FlushAsync().Wait();
}
using var reopened = new CultCache(Registry());
reopened.AddBackingStore(new SingleFileMessagePackBackingStore(copy, true), AetheriaStores.CatalogTypes);
var loaded = Assert.Single(reopened.GetAll<Scenario>());
Assert.Equal(("Round trip", "one of everything", 42u, true), (loaded.Name, loaded.Brief, loaded.Seed, loaded.Ambient));
Assert.Equal(djinni.Key, loaded.Player.Loadout.Key);
Assert.Equal((float2(1, 2), float2(0, -1)), (loaded.Player.Position, loaded.Player.Direction));
Assert.Equal(cargo.Key, Assert.Single(loaded.Player.Cargo).Key);
var entity = Assert.Single(loaded.Entities);
Assert.Equal((djinni.Key, float2(3, 4), ScenarioStance.Hostile, true), (entity.Loadout.Key, entity.Position, entity.Stance, entity.Piloted));
}
}

View file

@ -2,29 +2,443 @@
* 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': the shipped
// catalog, the authored settings and a prelude galaxy at a fixed seed.
// 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.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.
private (Galaxy galaxy, Zone arena, RunStart.Staged staged, List<string> failures) Launch(Scenario scenario, GalaxyStage inputs = null)
{
var galaxy = RunStart.Generate(scenario, inputs ?? Inputs());
var arena = new Zone(_items, _planetSettings, RunStart.GenerateArena(_items, _zoneSettings, galaxy, scenario), galaxy.Entrance, galaxy);
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");
}
}
// 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("LonginusX", _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 });
}
private static string Layout(Galaxy galaxy) =>
string.Join(";", galaxy.Zones.Select(zone => $"{zone.Name}@{zone.Position}:{zone.Owner?.Name}")) +
$" entrance {Array.IndexOf(galaxy.Zones, galaxy.Entrance)} noise {galaxy.Background.NoisePosition}";
private static string Layout(ZonePack arena) =>
$"{arena.Radius} {arena.Mass} {arena.Planets.Count()} {arena.Orbits.Count()} {arena.Entities.Count()}";
// A fixed seed fixes the galaxy, its background and its arena, launch after launch in one session. Seed zero takes
// the clock, so two launches at two clock readings are two galaxies.
[Fact]
public void SeedRules()
{
var arena = new DjinniShakedown();
Assert.NotEqual(0u, arena.Seed);
var first = Launch(arena);
var second = Launch(arena);
Assert.Equal(Layout(first.galaxy), Layout(second.galaxy));
Assert.Equal(Layout(first.arena.Pack), Layout(second.arena.Pack));
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));
}
// 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("LonginusX"), float2(300, 0), stance: ScenarioStance.Hostile, piloted: true);
stage.Place(stage.Fit("LonginusX", ("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("LonginusX"), float2(300, 0), stance: ScenarioStance.Hostile, piloted: true);
stage.Place(stage.Bare("LonginusX"), 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.LookDirection;
for (var tick = 0; tick < 50; tick++)
{
zone.Update(.1f);
Assert.Equal(float2.zero, ((Ship) neutral).MovementDirection);
Assert.Equal(look, neutral.LookDirection);
}
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("LonginusX"), float2(300, 0), stance: ScenarioStance.Hostile);
stage.Place(stage.Bare("LonginusX"), 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(null);
var arena = Arena(true);
var (prelude, _) = RunSave.Capture(_cache, _galaxy, arena, null, new SavedActionBarBinding[0]);
Assert.True(prelude.IsTutorial);
var (main, _) = RunSave.Capture(_cache, MainGalaxy(), arena, null, new SavedActionBarBinding[0]);
var (main, _) = RunSave.Capture(_cache, MainSectorGalaxy(), arena, null, new SavedActionBarBinding[0]);
Assert.False(main.IsTutorial);
}
// PlayerSettings Key 2 (TutorialPassed) is retired: a player.cc written with it still loads, every other field intact.
// 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()
{
@ -34,7 +448,7 @@ public sealed partial class RunStartTests
writer.WriteArrayHeader(7);
writer.Write("Pilot");
writer.WriteNil(); // Key 1, retired before this cut
writer.Write(true); // Key 2, TutorialPassed
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);