diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index de34c830d..8ebcf413a 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -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(); 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(); - 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 diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 55d8c2f92..8f0816ef9 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(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) }; diff --git a/Assets/Scripts/ServerShared/GalaxyStage.cs b/Assets/Scripts/ServerShared/GalaxyStage.cs new file mode 100644 index 000000000..f4ac7d5f8 --- /dev/null +++ b/Assets/Scripts/ServerShared/GalaxyStage.cs @@ -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 _log; + private readonly Action _progress; + private readonly Func _clock; + + public GalaxyStage( + SectorGenerationSettings sector, + SectorBackgroundSettings sectorBackground, + TutorialGenerationSettings prelude, + SectorBackgroundSettings preludeBackground, + NameGeneratorSettings names, + CultCache cache, + PlayerSettings playerSettings, + DirectoryInfo narrativeDirectory, + Action log, + Action progress = null, + Func 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(MessagePackSerializer.Serialize(settings)); +} diff --git a/Assets/Scripts/ServerShared/GalaxyStage.cs.meta b/Assets/Scripts/ServerShared/GalaxyStage.cs.meta new file mode 100644 index 000000000..9d23d3f4a --- /dev/null +++ b/Assets/Scripts/ServerShared/GalaxyStage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f5cf458f47074786bf11e72b5d93c545 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/RunStart.cs b/Assets/Scripts/ServerShared/RunStart.cs index 53807b562..1eab125bb 100644 --- a/Assets/Scripts/ServerShared/RunStart.cs +++ b/Assets/Scripts/ServerShared/RunStart.cs @@ -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 Entities; // the scenario's entities, in its order; empty with no scenario + public List 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 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. + // 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 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) + TutorialGenerationSettings tutorialGenerationSettings, List 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(); - 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(); + 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() }; } } diff --git a/Assets/Scripts/ServerShared/Scenario.cs b/Assets/Scripts/ServerShared/Scenario.cs index d2d0da48f..ba7165a19 100644 --- a/Assets/Scripts/ServerShared/Scenario.cs +++ b/Assets/Scripts/ServerShared/Scenario.cs @@ -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 Entities = new List(); -} + 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; - [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 -} + // 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 diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs new file mode 100644 index 000000000..241684a35 --- /dev/null +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -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 _available; + private readonly List _failures = new List(); + private readonly List _placed = new List(); + 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 Failures => _failures; + internal bool PlayerPlaced { get; private set; } + internal Ship PlayerShip { get; private set; } + internal IReadOnlyList 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(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(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().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(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(); + 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(string name, string where) where T : EquippableItemData + { + var named = _items.ItemData.GetAll().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; +} diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs.meta b/Assets/Scripts/ServerShared/ScenarioStage.cs.meta new file mode 100644 index 000000000..91b73afb6 --- /dev/null +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d8cd00dc23604dfb91e128e1a9a4a3d5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios.meta b/Assets/Scripts/ServerShared/Scenarios.meta new file mode 100644 index 000000000..d6472c75c --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 35866d82219241ae9fdb0030039afbed +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs new file mode 100644 index 000000000..d1ceb61e0 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs @@ -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); + } +} diff --git a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs.meta b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs.meta new file mode 100644 index 000000000..70bff8250 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 724a07b881f34e8c9b32b3f2f338ac78 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs b/Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs new file mode 100644 index 000000000..720fa2d08 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs @@ -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); +} diff --git a/Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs.meta b/Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs.meta new file mode 100644 index 000000000..4f25ab345 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1afb559630c241ed813ddd3c444c618b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/Duel.cs b/Assets/Scripts/ServerShared/Scenarios/Duel.cs new file mode 100644 index 000000000..fa270a4bd --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/Duel.cs @@ -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"); + } +} diff --git a/Assets/Scripts/ServerShared/Scenarios/Duel.cs.meta b/Assets/Scripts/ServerShared/Scenarios/Duel.cs.meta new file mode 100644 index 000000000..4acea5ed7 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/Duel.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f6ba813d4a9447c89e1bbb6997b7ac35 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs b/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs new file mode 100644 index 000000000..a734be6e1 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs @@ -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)); + } +} diff --git a/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs.meta b/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs.meta new file mode 100644 index 000000000..82a27fcac --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 58cfaf062ddf458c99c12f3652243b45 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/LongHaul.cs b/Assets/Scripts/ServerShared/Scenarios/LongHaul.cs new file mode 100644 index 000000000..bb5381b10 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/LongHaul.cs @@ -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); +} diff --git a/Assets/Scripts/ServerShared/Scenarios/LongHaul.cs.meta b/Assets/Scripts/ServerShared/Scenarios/LongHaul.cs.meta new file mode 100644 index 000000000..984d274d6 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/LongHaul.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3a7911611f9845709e9ae5ba34d91dfb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs b/Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs new file mode 100644 index 000000000..f32e205d3 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs @@ -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); +} diff --git a/Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs.meta b/Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs.meta new file mode 100644 index 000000000..4e862188d --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c7f6bdb3ed7b4b1aa137569c7e79bccd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs b/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs new file mode 100644 index 000000000..31890d789 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs @@ -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() + }; +} diff --git a/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs.meta b/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs.meta new file mode 100644 index 000000000..4c856d4d9 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 12f60a9ad25c4aa0a9b8b098d385ea0e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs b/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs new file mode 100644 index 000000000..ccbf9baa8 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs @@ -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)); + } +} diff --git a/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs.meta b/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs.meta new file mode 100644 index 000000000..e13077120 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 91a81ac4784c4066969cac11ac1937b0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs b/Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs new file mode 100644 index 000000000..161a52348 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs @@ -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); + } +} diff --git a/Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs.meta b/Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs.meta new file mode 100644 index 000000000..3f7c8777a --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 94d688b37c5044dd9b67865c706d8918 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index 9fcff8f14..b5bdab2ee 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -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 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( - 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 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()); diff --git a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs index 383425c60..12daaee32 100644 --- a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs +++ b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs @@ -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 Bills(params GearData[] order) { diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index c3ac81ff5..bfb71e59d 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -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("ZoneSettings"); _planetSettings = authored.Read("PlanetSettings"); - var tutorial = authored.Read("TutorialGenerationSettings"); + var tutorial = _tutorialSettings = authored.Read("TutorialGenerationSettings"); _galaxy = new Galaxy(tutorial, authored.Read("TutorialBackgroundSettings"), authored.Read("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().Single(hull => hull.Name == name); @@ -92,314 +93,12 @@ public sealed partial class RunStartTests : IDisposable .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; - } + // 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(); - 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() - .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); - 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(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().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)); - - _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(), 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(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().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(); - 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(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); - 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(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().Any(t => !t.ThermotoggleData.HighPass) && item.Behaviors.OfType().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"), @@ -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().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().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().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().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().Single(); @@ -1007,7 +706,7 @@ public sealed partial class RunStartTests : IDisposable { Name = "Two Group Gear", Design = new CultRecordRef(_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 UnsoldAndUnplaced(CultCache cache) - { - var sold = cache.GetAll().Select(product => product.Design.Key).ToHashSet(); - var placed = new HashSet(); - foreach (var scenario in cache.GetAll()) - 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() - .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(_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(_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(); - 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().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()); + var arriving = (Ship) Loadouts.Materialize(_items, zone, BareLoadout(Hull("Djinni")), _ => true, new List()); 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(_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)); - } } diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs index f35cded44..c2a4e36b3 100644 --- a/tests/Aetheria.Shared.Tests/ScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/ScenarioTests.cs @@ -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 _stage; + private readonly uint _seed; + private readonly Func _generate; + + public Scripted(bool ambient, Action stage, uint seed = GalaxySeed, Func 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 EveryScenario => Scenarios.Modes.Concat(Scenarios.Tests); + + // The galaxy inputs the menu hands to RunStart, read from the authored settings. + private GalaxyStage Inputs(Func clock = null) + { + var authored = AuthoredSettings.Load(FindRepoRoot()); + return new GalaxyStage( + authored.Read("SectorGenerationSettings"), + authored.Read("SectorBackgroundSettings"), + authored.Read("TutorialGenerationSettings"), + authored.Read("TutorialBackgroundSettings"), + authored.Read("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 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(); + 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(); + 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 Refused(Zone zone, Scenario scenario) + { + var entities = zone.Entities.ToList(); + var agents = zone.Agents.ToList(); + var failures = new List(); + 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(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().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)); + + _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(), 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() + .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) + .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(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(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().First(hull => hull.HullType == HullType.Station).Name; + var turret = TurretHull().Name; + foreach (var (script, expected) in new (Action, 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(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().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 UnsoldAndUnplaced(IEnumerable runs) + { + var sold = _cache.GetAll().Select(product => product.Design.Key).ToHashSet(); + var placed = new HashSet(); + foreach (var entity in runs.SelectMany(run => run.Entities.Prepend(run.Player))) + { + foreach (var item in entity.Equipment.Concat(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() + .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 { ["story"] = "hash" }, options); MessagePackSerializer.Serialize(ref writer, new PlayerGameplaySettings { TemperatureUnit = TemperatureUnit.Kelvin, SignificantDigits = 5 }, options); MessagePackSerializer.Serialize(ref writer, new PlayerInputSettings { ActionBarInputs = { "/1" } }, options);