Scenarios Cut 1: RunStart stages a new run; the Scenario catalog type

RunStart (ServerShared, no Unity type) owns what a new run starts with
(docs/scenarios-cut.md, 2.2):
- GenerateArena: the galaxy's entrance pack, generated under the scenario's
  Ambient rule. ZoneGenerator.GenerateZone gains `ambient`; false keeps
  planets, orbits and stations and generates no ship, turret or wanderer.
- Check: every failure staging a scenario would hit, found with no zone,
  touching no store.
- Stage: all-or-nothing. With no scenario, the generated starting-hull ship
  at the origin, as StartGame built it. With one, the player preset and the
  entities at their authored place and heading with cargo, stance set both
  ways through SetIff, a Minion for each piloted one; all through Zone.Admit.

Scenario (aetheria.scenario v1) joins AetheriaStores.CatalogTypes.
RunStartTests stage into a real prelude arena from the shipped catalog and
authored settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 12:33:02 +02:00
commit 85ebe036e1
8 changed files with 582 additions and 6 deletions

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(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout) };
public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout), typeof(Scenario) };
public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame), typeof(ProvenanceLedger) };
public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) };

View file

@ -149,8 +149,8 @@ public class LoadoutGenerator
// No galaxy means no availability to filter by: every product is on offer. A fixture generates loadouts that
// way, so a test can exercise placement and products without standing up a whole galaxy; no game path does.
// Loadouts.Materialize takes availability as a predicate; a game spawner materializing presets is to pass this, so
// presets and generation share one availability rule. No game path materializes a preset yet.
// Loadouts.Materialize takes availability as a predicate; RunStart, the game's one preset spawner, passes this, so
// presets and generation share one availability rule.
public bool IsAvailable(FactionProductData product) =>
Galaxy == null || Galaxy.IsPrelude ||
Galaxy.ContainsFaction(product.Manufacturer) && (Faction == null || Faction.Allegiance.ContainsKey(product.Manufacturer));

View file

@ -0,0 +1,131 @@
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.
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
}
// 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);
// 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.
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)
{
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();
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 item = build();
if (bay == null || !bay.TryStore(item))
failures.Add($"{where} cargo: no room for {itemManager.GetData(item).Name}");
}
return failures.Count > reported ? null : entity;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 28412ed9a0fc4f95b157846b0b132c27

View file

@ -0,0 +1,42 @@
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
{
[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>();
}
// 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
}
[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
}
public enum ScenarioStance
{
Neutral,
Hostile
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: da26f6b6c5f34deaa1057177e2dd3262

View file

@ -35,12 +35,15 @@ public static class ZoneGenerator
private const int MaximumPlacementSamples = 32;
// ambient: false keeps the planets, orbits and stations but generates no ship and no turret, so nothing wanders
// into a scenario's authored conditions (docs/scenarios-cut.md, 2.1). The rule is applied here, not by pruning.
public static ZonePack GenerateZone(
ItemManager itemManager,
ZoneGenerationSettings zoneSettings,
Galaxy galaxy,
GalaxyZone galaxyZone,
bool isTutorial = false)
bool isTutorial = false,
bool ambient = true)
{
var pack = new ZonePack();
var cache = itemManager.ItemData;
@ -306,7 +309,7 @@ public static class ZoneGenerator
station.Story = i;
pack.Entities.Add(station);
PlaceTurrets(lagrangeOrbit, GetLoadoutGenerator(story.Faction), story.Turrets);
if (ambient) PlaceTurrets(lagrangeOrbit, GetLoadoutGenerator(story.Faction), story.Turrets);
}
for (var i = storyStations.Length; i < selectedStationOrbits.Length; i++)
@ -328,9 +331,11 @@ public static class ZoneGenerator
station.SecurityRadius = pack.Radius;
pack.Entities.Add(station);
PlaceTurrets(lagrangeOrbit, GetLoadoutGenerator(nearestFaction), 2);
if (ambient) PlaceTurrets(lagrangeOrbit, GetLoadoutGenerator(nearestFaction), 2);
}
if (!ambient) return pack;
var enemyCount = (int)(random.NextFloat() * factionPresence * 2) + baseStationCount;
for (int i = 0; i < enemyCount; i++)
{

View file

@ -0,0 +1,394 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using CultMath;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using Xunit;
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.
public sealed class RunStartTests : IDisposable
{
private const uint GalaxySeed = 1;
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-runstart-" + Guid.NewGuid().ToString("N"));
private readonly CultCache _cache;
private readonly ItemManager _items;
private readonly Galaxy _galaxy;
private readonly ZoneGenerationSettings _zoneSettings;
private readonly PlanetSettings _planetSettings;
private readonly string _startingHull;
private readonly Faction _protagonist;
public RunStartTests()
{
var repo = FindRepoRoot();
Directory.CreateDirectory(_root);
var catalog = Path.Combine(_root, "Aetheria.cc");
File.Copy(Path.Combine(repo, "GameData", "Aetheria.cc"), catalog);
// A registry scoped to the shipped assembly's own [CultDocument] types, as RestoredHullsTests composes it, so
// this test assembly's own documents never reach the real catalog's validation.
_cache = new CultCache(Registry());
_cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalog), AetheriaStores.CatalogTypes);
_cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(_root, "run.cc")), AetheriaStores.RunTypes);
var authored = AuthoredSettings.Load(repo);
_zoneSettings = authored.Read<ZoneGenerationSettings>("ZoneSettings");
_planetSettings = authored.Read<PlanetSettings>("PlanetSettings");
var tutorial = 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);
_items = new ItemManager(_cache, new ProvenanceLedger(), authored.Read<GameplaySettings>("GameplaySettings"), _ => { });
_protagonist = _galaxy.ResolveFaction(tutorial.ProtagonistFaction);
// GameSettings.StartingHullName is a plain field of the Unity settings object, not a ServerShared subtree
_startingHull = File.ReadLines(Path.Combine(repo, "Assets", "Resources", "Settings.asset"))
.Select(line => Regex.Match(line, @"^ StartingHullName: (.+)$"))
.First(match => match.Success).Groups[1].Value.Trim();
}
public void Dispose()
{
_cache.Dispose();
Directory.Delete(_root, true);
}
private static CultDocumentRegistry Registry() => CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes()
.Where(t => t is { IsAbstract: false, IsInterface: false })
.Where(t => t.GetCustomAttribute<CultDocumentAttribute>() != null));
private static string FindRepoRoot()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
if (File.Exists(Path.Combine(dir.FullName, "Aetheria.Shared", "Aetheria.Shared.csproj")))
return dir.FullName;
throw new DirectoryNotFoundException("Run from inside the Aetheria repository.");
}
private Zone Arena(Scenario scenario) =>
new Zone(_items, _planetSettings, RunStart.GenerateArena(_items, _zoneSettings, _galaxy, scenario), _galaxy.Entrance, _galaxy);
private HullData Hull(string name) => _cache.GetAll<HullData>().Single(hull => hull.Name == name);
// The first turret hull, by name, that a manufacturer makes: a real turret, not a fixture.
private HullData TurretHull() => _cache.GetAll<HullData>()
.Where(hull => hull.HullType == HullType.Turret)
.Where(hull => _cache.GetAll<FactionProductData>().Any(p => p.Design.Key.Equals(_cache.RefOf(hull).Key)))
.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;
}
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);
Assert.Contains(player, zone.Entities);
Assert.Same(zone, player.Zone);
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);
Assert.Contains(player, zone.Entities);
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 stays where it was put.
[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));
Assert.Contains(piloted, zone.Entities);
Assert.Contains(unpiloted, zone.Entities);
var start = unpiloted.Position;
for (var tick = 0; tick < 50; tick++) zone.Update(.1f);
Assert.True(length(unpiloted.Position - start) < .01f, $"the unpiloted ship moved to {unpiloted.Position}");
}
// 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));
}
// 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.
[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));
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);
}
// 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());
Assert.Contains(turret, zone.Entities);
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 before = zone.Entities.ToList();
var failures = new List<string>();
Assert.Null(RunStart.Stage(_items, zone, scenario, _startingHull, _protagonist, failures));
Assert.Contains(failures, failure => failure.Contains(expected));
Assert.Equal(before, zone.Entities);
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.
[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);
Assert.Equal(entities, zone.Entities);
Assert.Equal(agents, zone.Agents);
Assert.Empty(RunStart.Check(_items, null));
}
// 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]
public void TheRealHullsMaterializeBareFromABrandedProduct()
{
var available = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, null, 2);
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);
Assert.Empty(failures);
Assert.NotNull(_items.Brand(entity.Hull).Maker);
}
}
// Zone.Admit is the only creator of agents: construction pilots every packed ship but the player's, and an
// unpiloted admission (a warp arrival, an undock, a spawned turret) gets none.
[Fact]
public void AdmitIsTheOnlyAgentCreator()
{
var pack = RunStart.GenerateArena(_items, _zoneSettings, _galaxy, null);
var packedPlayer = pack.Entities.OfType<ShipPack>().First();
packedPlayer.IsPlayerShip = true;
var zone = new Zone(_items, _planetSettings, pack, _galaxy.Entrance, _galaxy);
var ships = zone.Entities.OfType<Ship>().ToList();
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 agents = zone.Agents.Count;
zone.Admit(arriving, piloted: false);
Assert.Contains(arriving, zone.Entities);
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));
}
}