diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index bee8a4c9f..8d9c2dfb2 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -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) }; diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 11e277f7f..044b282df 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -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)); diff --git a/Assets/Scripts/ServerShared/RunStart.cs b/Assets/Scripts/ServerShared/RunStart.cs new file mode 100644 index 000000000..53807b562 --- /dev/null +++ b/Assets/Scripts/ServerShared/RunStart.cs @@ -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 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 Check(ItemManager itemManager, Scenario scenario) + { + var failures = new List(); + 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 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() }; + } + + // 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 entities)? Build(ItemManager itemManager, Zone zone, Scenario scenario, + Predicate isAvailable, List 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(); + 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 isAvailable, List 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(); + 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; + } +} diff --git a/Assets/Scripts/ServerShared/RunStart.cs.meta b/Assets/Scripts/ServerShared/RunStart.cs.meta new file mode 100644 index 000000000..2ccf2e506 --- /dev/null +++ b/Assets/Scripts/ServerShared/RunStart.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 28412ed9a0fc4f95b157846b0b132c27 diff --git a/Assets/Scripts/ServerShared/Scenario.cs b/Assets/Scripts/ServerShared/Scenario.cs new file mode 100644 index 000000000..d2d0da48f --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenario.cs @@ -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 Entities = new List(); +} + +// 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; + [Key(1)] public float2 Position; // zone xz + [Key(2)] public float2 Direction; // zero keeps the entity's default heading + [Key(3)] public List> Cargo = new List>(); // 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 +} diff --git a/Assets/Scripts/ServerShared/Scenario.cs.meta b/Assets/Scripts/ServerShared/Scenario.cs.meta new file mode 100644 index 000000000..bea3a0eb2 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenario.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: da26f6b6c5f34deaa1057177e2dd3262 diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 2144f718c..d57939168 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -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++) { diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs new file mode 100644 index 000000000..03945ae78 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -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("ZoneSettings"); + _planetSettings = authored.Read("PlanetSettings"); + var tutorial = authored.Read("TutorialGenerationSettings"); + _galaxy = new Galaxy(tutorial, authored.Read("TutorialBackgroundSettings"), + authored.Read("NameGeneratorSettings"), _cache, new PlayerSettings(), + Directory.CreateDirectory(Path.Combine(_root, "Narrative")), _ => { }, null, GalaxySeed); + _items = new ItemManager(_cache, new ProvenanceLedger(), authored.Read("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() != 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().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() + .Where(hull => hull.HullType == HullType.Turret) + .Where(hull => _cache.GetAll().Any(p => p.Design.Key.Equals(_cache.RefOf(hull).Key))) + .OrderBy(hull => hull.Name, StringComparer.Ordinal) + .First(); + + private CultRecordRef Preset(Loadout loadout) + { + var existing = _cache.GetAll().FirstOrDefault(other => other.Name == loadout.Name); + if (existing != null) return _cache.RefOf(existing); + _cache.Upsert(loadout); + return _cache.RefOf(loadout); + } + + private CultRecordRef 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 preset, float x, float z) => + new ScenarioShip { Loadout = preset, Position = float2(x, z) }; + + private static ScenarioEntity Placed(CultRecordRef 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(); + 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(); + 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() + .Where(design => design.Shape.Coordinates.Length == 1) + .Where(design => _cache.GetAll().Any(p => p.Design.Key.Equals(_cache.RefOf(design).Key))) + .OrderBy(design => design.Name, StringComparer.Ordinal) + .Take(2) + .Select(design => new CultRecordRef(_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(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().Count(), + pack.Entities.OfType().Count(e => ((HullData) _cache.Get(e.Hull.Data)).HullType == HullType.Turret), + pack.Entities.OfType().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(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().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(); + 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(new CultRecordKey("absent-design")) } }, + Placed(Bare(Hull("LonginusX")), 300, 0, ScenarioStance.Hostile, piloted: true), + Placed(new CultRecordRef(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(); + 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(); + 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().First(); + packedPlayer.IsPlayerShip = true; + var zone = new Zone(_items, _planetSettings, pack, _galaxy.Entrance, _galaxy); + var ships = zone.Entities.OfType().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()); + 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(_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()); + 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)); + } +}