From dd0253c45ee06acb0179c0c3c6db17faf03fde59 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 19:32:02 +0200 Subject: [PATCH 01/17] Demo Terminus: a compiled scenario stages one fixed-cast prelude galaxy whose antagonist's home is the gate DemoTerminus generates a prelude galaxy of the Pirates, Zhestokost, Lucent Media and Aeronautics Unlimited with no quest faction, places the gate at the antagonist's home (Galaxy.PlaceGate: boss zone and exit), and stages the player in a Pirates hull with the cast's standing. The prelude constructor resolves every cast name through one refusal that names the field, and places a quest zone only when a quest faction is named. Release builds list only the demo; the tutorial and main galaxies join the test arenas in editor and development builds. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Galaxy.cs | 54 ++++++++++++------- Assets/Scripts/ServerShared/GalaxyStage.cs | 7 ++- .../ServerShared/Scenarios/DemoTerminus.cs | 41 ++++++++++++++ .../Scenarios/DemoTerminus.cs.meta | 11 ++++ .../ServerShared/Scenarios/Scenarios.cs | 9 ++-- Assets/Scripts/UI/MainMenu.cs | 6 ++- 6 files changed, 103 insertions(+), 25 deletions(-) create mode 100644 Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs create mode 100644 Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs.meta diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs index a5660f52e..5fc919d7b 100644 --- a/Assets/Scripts/ServerShared/Galaxy.cs +++ b/Assets/Scripts/ServerShared/Galaxy.cs @@ -24,7 +24,7 @@ public class Galaxy public Faction[] Factions { get; } public GalaxyZone[] Zones { get; } public GalaxyZone Entrance { get; } - public GalaxyZone Exit { get; } + public GalaxyZone Exit { get; private set; } public Dictionary FactionRelationships { get; } = new Dictionary(); private Action Log { get; } public bool IsPrelude { get; } @@ -143,6 +143,18 @@ public class Galaxy return _cache.GetAll().FirstOrDefault(f => f.Name.StartsWith(name, StringComparison.InvariantCultureIgnoreCase)); } + // A cast member of a prelude galaxy, by name. A name the catalog does not hold refuses the galaxy, naming the field. + private Faction ResolveCast(string field, string name) => + ResolveFaction(name) ?? throw new InvalidOperationException($"cast faction {name} ({field}) is not in the catalog"); + + // Gives a prelude galaxy its gate: the antagonist's home is the boss zone and the exit. The only writer of Exit + // besides the constructors. + public void PlaceGate(Faction antagonist) + { + BossZones[antagonist] = HomeZones[antagonist]; + Exit = HomeZones[antagonist]; + } + // How many jumps a faction's influence reaches in this galaxy: its authored distance, halved (rounding up) in a // prelude. Derived, never written back to the catalog's Faction, so one galaxy's generation cannot change the next. public int InfluenceOf(Faction faction) => @@ -169,20 +181,23 @@ public class Galaxy var factions = new List(); - var protagonistFaction = ResolveFaction(settings.ProtagonistFaction); + var protagonistFaction = ResolveCast(nameof(settings.ProtagonistFaction), settings.ProtagonistFaction); factions.Add(protagonistFaction); - var antagonistFaction = ResolveFaction(settings.AntagonistFaction); + var antagonistFaction = ResolveCast(nameof(settings.AntagonistFaction), settings.AntagonistFaction); factions.Add(antagonistFaction); - var bufferFaction = ResolveFaction(settings.BufferFaction); + var bufferFaction = ResolveCast(nameof(settings.BufferFaction), settings.BufferFaction); factions.Add(bufferFaction); - var questFaction = ResolveFaction(settings.QuestFaction); - factions.Add(questFaction); + // A cast without a quest faction places no quest zone. + var questFaction = string.IsNullOrEmpty(settings.QuestFaction) + ? null + : ResolveCast(nameof(settings.QuestFaction), settings.QuestFaction); + if (questFaction != null) factions.Add(questFaction); var neutralFactions = settings.NeutralFactions - .Select(ResolveFaction) + .Select(name => ResolveCast(nameof(settings.NeutralFactions), name)) .ToArray(); factions.AddRange(neutralFactions); @@ -223,17 +238,20 @@ public class Galaxy CalculateFactionInfluence(progressCallback); - var potentialQuestZones = Zones - .Where(z => z.Factions.Contains(antagonistFaction) && z.Factions.Contains(bufferFaction)); - if (potentialQuestZones.Any()) - HomeZones[questFaction] = potentialQuestZones - .MaxBy(z => z.Distance[HomeZones[antagonistFaction]] * ConnectedRegion(z, InfluenceOf(questFaction)).Count); - else - HomeZones[questFaction] = Zones - .Where(z => z.Factions.Contains(antagonistFaction)) - .MinBy(z=>z.Distance[HomeZones[bufferFaction]]); - - CalculateFactionInfluence(progressCallback); + if (questFaction != null) + { + var potentialQuestZones = Zones + .Where(z => z.Factions.Contains(antagonistFaction) && z.Factions.Contains(bufferFaction)); + if (potentialQuestZones.Any()) + HomeZones[questFaction] = potentialQuestZones + .MaxBy(z => z.Distance[HomeZones[antagonistFaction]] * ConnectedRegion(z, InfluenceOf(questFaction)).Count); + else + HomeZones[questFaction] = Zones + .Where(z => z.Factions.Contains(antagonistFaction)) + .MinBy(z=>z.Distance[HomeZones[bufferFaction]]); + + CalculateFactionInfluence(progressCallback); + } Entrance = Zones.Where(z => z.Owner == null).MinBy(z => z.Distance[HomeZones[protagonistFaction]]); diff --git a/Assets/Scripts/ServerShared/GalaxyStage.cs b/Assets/Scripts/ServerShared/GalaxyStage.cs index f4ac7d5f8..8b6e5a1a6 100644 --- a/Assets/Scripts/ServerShared/GalaxyStage.cs +++ b/Assets/Scripts/ServerShared/GalaxyStage.cs @@ -68,7 +68,10 @@ public sealed class GalaxyStage } // A prelude galaxy: the tutorial settings and their fixed factions, centred in dense cloud. - public Galaxy Prelude() + public Galaxy Prelude() => Prelude(_prelude); + + // A prelude galaxy of the given fixed cast, centred in dense cloud. + public Galaxy Prelude(TutorialGenerationSettings cast) { var background = Copy(_preludeBackground); var random = new Random(Seed); @@ -78,7 +81,7 @@ public sealed class GalaxyStage 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); + return new Galaxy(cast, background, _names, _cache, _playerSettings, _narrativeDirectory, _log, _progress, Seed); } private static SectorBackgroundSettings Copy(SectorBackgroundSettings settings) => diff --git a/Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs b/Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs new file mode 100644 index 000000000..0555a3b29 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs @@ -0,0 +1,41 @@ +using float2 = CultMath.float2; + +// The demo: one gate, one region and one boss, with a fixed cast. The Pirates are the protagonist's ostensible +// employer, Zhestokost the antagonist whose home is the boss zone and the exit, Lucent Media and Aeronautics Unlimited +// the neutrals. A prelude galaxy of that cast, entered in a Pirates-made hull. +public sealed class DemoTerminus : Scenario +{ + public static readonly TutorialGenerationSettings Cast = new TutorialGenerationSettings + { + ProtagonistFaction = "Pirates", + AntagonistFaction = "Zhe", + BufferFaction = "Luc", + NeutralFactions = new[] { "Aero" }, + QuestFaction = null, + ZoneCount = 64, + LinkDensity = .5f + }; + + public override string Name => "Terminus (Demo)"; + public override string Brief => "One gate, one region, one boss. Allied: the Pirates. Opposed: Zhestokost. " + + "Neutral: Lucent Media and Aeronautics Unlimited."; + + public override Galaxy Generate(GalaxyStage stage) + { + var galaxy = stage.Prelude(Cast); + galaxy.PlaceGate(galaxy.ResolveFaction(Cast.AntagonistFaction)); + return galaxy; + } + + public override void Stage(ScenarioStage stage) + { + var galaxy = stage.Galaxy; + var pirates = galaxy.ResolveFaction(Cast.ProtagonistFaction); + galaxy.FactionRelationships[pirates] = FactionRelationship.Friendly; + galaxy.FactionRelationships[galaxy.ResolveFaction(Cast.AntagonistFaction)] = FactionRelationship.Hated; + galaxy.FactionRelationships[galaxy.ResolveFaction(Cast.BufferFaction)] = FactionRelationship.Neutral; + foreach (var neutral in Cast.NeutralFactions) + galaxy.FactionRelationships[galaxy.ResolveFaction(neutral)] = FactionRelationship.Neutral; + stage.Player(stage.Generated(stage.StartingHull, pirates), float2.zero); + } +} diff --git a/Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs.meta b/Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs.meta new file mode 100644 index 000000000..706f1bf0e --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5ba7152d3c8b46a98924a4148be550db +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 index 31890d789..4a43c8960 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs @@ -1,8 +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. +// Every way to set up a new run, in the order New Game lists them. Modes are what a release build lists; Development +// are the other ways the game sets up a run and Tests are arenas built to check one system each, both listed only in +// editor and development builds. public static class Scenarios { - public static readonly Scenario[] Modes = { new TutorialGalaxy(), new MainGalaxy() }; + public static readonly Scenario[] Modes = { new DemoTerminus() }; + + public static readonly Scenario[] Development = { new TutorialGalaxy(), new MainGalaxy() }; public static readonly Scenario[] Tests = { diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index f20208ff7..7c2666420 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -127,8 +127,8 @@ 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. + // New Game: every way to set up a run, each with its brief. A release build lists only the modes; the other ways + // to set up a run and the test arenas are listed only in editor and development builds. private void ShowScenarios() { _nextMenu.panel.Clear(); @@ -136,6 +136,8 @@ public class MainMenu : MonoBehaviour AddScenarios(Scenarios.Modes); if (Debug.isDebugBuild) { + _nextMenu.panel.AddSection("development"); + AddScenarios(Scenarios.Development); _nextMenu.panel.AddSection("test arenas"); AddScenarios(Scenarios.Tests); } From 1997a3da333f83e31d9687152283cf00c45ce93c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 19:32:03 +0200 Subject: [PATCH 02/17] Demo Terminus tests: fixed cast, gate, standing across Continue, refused cast, tutorial unchanged Co-Authored-By: Claude Opus 5.5 --- .../DemoTerminusTests.cs | 140 ++++++++++++++++++ tests/Aetheria.Shared.Tests/ScenarioTests.cs | 4 +- 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 tests/Aetheria.Shared.Tests/DemoTerminusTests.cs diff --git a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs new file mode 100644 index 000000000..b03a70e29 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs @@ -0,0 +1,140 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +// The demo scenario (aetheria-release, cut demo-galaxy): a prelude galaxy of a fixed cast whose antagonist's home is the +// boss zone and the exit. The shipped catalog holds no Pirates faction yet, so each test that needs one adds it to its +// scratch copy of the catalog. +public sealed partial class RunStartTests +{ + private static readonly uint[] DemoSeeds = { 1, 2, 3, 4, 5 }; + + // The catalog record the demo's protagonist needs, until the shipped catalog has one. + private void AddPirates() => _cache.Upsert(new Faction { Name = "Pirates", ShortName = "Pirates" }); + + private Galaxy DemoGalaxy(uint seed) => RunStart.Generate(new DemoTerminus(), Inputs(() => seed)); + + [Fact] + public void DemoTerminus_fixed_cast() + { + AddPirates(); + foreach (var seed in DemoSeeds) + Assert.Equal(new[] { "Pirates", "Zhestokost", "Lucent Media", "Aeronautics Unlimited" }, + DemoGalaxy(seed).Factions.Select(faction => faction.Name)); + } + + [Fact] + public void DemoTerminus_gate_at_antagonist_home() + { + AddPirates(); + foreach (var seed in DemoSeeds) + { + var galaxy = DemoGalaxy(seed); + var zhestokost = galaxy.Factions.Single(faction => faction.Name == "Zhestokost"); + Assert.Equal(new[] { zhestokost }, galaxy.BossZones.Keys); + Assert.Same(galaxy.HomeZones[zhestokost], galaxy.BossZones[zhestokost]); + Assert.Same(galaxy.HomeZones[zhestokost], galaxy.Exit); + Assert.NotSame(galaxy.Entrance, galaxy.Exit); + } + } + + // The player's standing is part of the run: Continue restores it, and the gate, through the saved game. + [Fact] + public void DemoTerminus_standing_survives_continue() + { + AddPirates(); + var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => 1)); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + galaxy.Entrance.Contents = arena; + var (game, zones) = RunSave.Capture(_cache, galaxy, arena, staged.Player, new SavedActionBarBinding[0]); + RunSave.Commit(_cache, game, zones, RunSave.Lots(_cache)); + var restored = new Galaxy(_cache, _cache.GetGlobal(), _ => { }); + + Dictionary Standing(Galaxy g) => g.Factions.ToDictionary(faction => faction.Name, faction => g.FactionRelationships[faction]); + Assert.Equal(new Dictionary + { + ["Pirates"] = FactionRelationship.Friendly, + ["Zhestokost"] = FactionRelationship.Hated, + ["Lucent Media"] = FactionRelationship.Neutral, + ["Aeronautics Unlimited"] = FactionRelationship.Neutral + }, Standing(restored)); + Assert.Equal(Standing(galaxy), Standing(restored)); + Assert.Equal(Array.IndexOf(galaxy.Zones, galaxy.Exit), Array.IndexOf(restored.Zones, restored.Exit)); + Assert.Equal( + galaxy.BossZones.Select(boss => (boss.Key.Name, Array.IndexOf(galaxy.Zones, boss.Value))), + restored.BossZones.Select(boss => (boss.Key.Name, Array.IndexOf(restored.Zones, boss.Value)))); + } + + // On the shipped catalog, which has no Pirates, the run does not start: the galaxy refuses, naming the cast field, + // and the scenario never reaches staging. + [Fact] + public void DemoTerminus_missing_cast_refused() + { + Assert.DoesNotContain(_cache.GetAll(), faction => faction.Name.StartsWith("Pirates", StringComparison.Ordinal)); + var refusal = Assert.Throws(() => DemoGalaxy(1)); + Assert.Contains("Pirates", refusal.Message); + Assert.Contains(nameof(TutorialGenerationSettings.ProtagonistFaction), refusal.Message); + } + + // A cast that names no quest faction places no quest zone: one home for each of its four factions. + [Fact] + public void ACastNamingNoQuestFactionPlacesNoQuestZone() + { + AddPirates(); + var galaxy = DemoGalaxy(1); + Assert.Equal(4, galaxy.HomeZones.Count); + Assert.Equal(4, galaxy.Factions.Length); + } + + // A name that resolves to nothing refuses the galaxy whichever cast field names it, and the refusal names the field. + [Theory] + [InlineData(nameof(TutorialGenerationSettings.ProtagonistFaction))] + [InlineData(nameof(TutorialGenerationSettings.AntagonistFaction))] + [InlineData(nameof(TutorialGenerationSettings.BufferFaction))] + [InlineData(nameof(TutorialGenerationSettings.QuestFaction))] + [InlineData(nameof(TutorialGenerationSettings.NeutralFactions))] + public void AnUnresolvedCastNameRefusesTheGalaxyNamingItsField(string field) + { + var cast = new TutorialGenerationSettings + { + ProtagonistFaction = _tutorialSettings.ProtagonistFaction, + AntagonistFaction = _tutorialSettings.AntagonistFaction, + BufferFaction = _tutorialSettings.BufferFaction, + QuestFaction = _tutorialSettings.QuestFaction, + NeutralFactions = _tutorialSettings.NeutralFactions, + ZoneCount = _tutorialSettings.ZoneCount, + LinkDensity = _tutorialSettings.LinkDensity + }; + typeof(TutorialGenerationSettings).GetField(field).SetValue(cast, field == nameof(cast.NeutralFactions) ? new[] { "Nonesuch" } : "Nonesuch"); + var scenario = new Scripted(false, _ => { }, generate: galaxy => galaxy.Prelude(cast)); + var refusal = Assert.Throws(() => RunStart.Generate(scenario, Inputs())); + Assert.Contains(field, refusal.Message); + } + + // The tutorial is the galaxy it was before the demo: its cast, homes, entrance and ownership at a fixed seed, with + // no exit and no boss zone. + [Fact] + public void TutorialGalaxy_unchanged() + { + var galaxy = RunStart.Generate(new TutorialGalaxy(), Inputs(() => GalaxySeed)); + Assert.Equal(TutorialSeed1, TutorialShape(galaxy)); + Assert.Null(galaxy.Exit); + Assert.Empty(galaxy.BossZones); + } + + private static string TutorialShape(Galaxy g) => + string.Join(",", g.Factions.Select(f => f.Name)) + + " | homes " + string.Join(",", g.Factions.Select(f => g.HomeZones.TryGetValue(f, out var z) ? Array.IndexOf(g.Zones, z) : -2)) + + " | entrance " + Array.IndexOf(g.Zones, g.Entrance) + + " | zones " + g.Zones.Length + + " | owners " + string.Join(",", g.Zones.Select(z => z.Owner == null ? "-" : z.Owner.ShortName)); + + // Measured on 228f241e, before the demo cut, at seed 1. + private const string TutorialSeed1 = + "Miss Terri’s Sugariffic Snack Company,Zhestokost,Lucent Media,Adrasteia,Aeronautics Unlimited,Finch Cybernetics | homes 47,10,17,49,35,1 | entrance 25 | zones 64 | owners Zhestokost,Finch,Lucent,Zhestokost,Finch,Adrasteia,Zhestokost,Adrasteia,Zhestokost,Zhestokost,Zhestokost,Zhestokost,Finch,Lucent,Adrasteia,Adrasteia,Zhestokost,Lucent,Finch,Finch,Lucent,Lucent,Finch,AU,Adrasteia,-,-,Adrasteia,Lucent,-,AU,Lucent,Zhestokost,Lucent,Lucent,AU,Adrasteia,AU,Lucent,AU,Finch,Finch,-,Lucent,Zhestokost,Zhestokost,Miss Terri's,Miss Terri's,Miss Terri's,Adrasteia,Finch,-,-,Miss Terri's,Lucent,AU,Miss Terri's,Zhestokost,Lucent,Lucent,Lucent,Finch,-,-"; +} diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs index ddb75aa8e..d60ebaedd 100644 --- a/tests/Aetheria.Shared.Tests/ScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/ScenarioTests.cs @@ -44,7 +44,7 @@ public sealed partial class RunStartTests public override void Stage(ScenarioStage stage) => _stage(stage); } - private static IEnumerable EveryScenario => Scenarios.Modes.Concat(Scenarios.Tests); + private static IEnumerable EveryScenario => Scenarios.Modes.Concat(Scenarios.Development).Concat(Scenarios.Tests); // The galaxy inputs the menu hands to RunStart, read from the authored settings. private GalaxyStage Inputs(Func clock = null) @@ -96,6 +96,7 @@ public sealed partial class RunStartTests [Fact] public void EveryScenarioStages() { + AddPirates(); Assert.Equal(EveryScenario.Count(), EveryScenario.Select(scenario => scenario.Name).Distinct().Count()); foreach (var scenario in EveryScenario) { @@ -507,6 +508,7 @@ public sealed partial class RunStartTests [Fact] public void EveryUnsoldDesignIsAScenarioTestDesign() { + AddPirates(); var runs = EveryScenario.Select(scenario => Launch(scenario, Inputs(() => GalaxySeed)).staged).ToList(); Assert.Empty(UnsoldAndUnplaced(runs)); From c7c56e152c63ae6761044dc1dd3c46f3d8f8a81b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 19:39:16 +0200 Subject: [PATCH 03/17] Demo Terminus tests: the Pirates fixture carries a name file, as generating zone names needs one Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/DemoTerminusTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs index b03a70e29..03a1bb123 100644 --- a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs +++ b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs @@ -15,7 +15,10 @@ public sealed partial class RunStartTests private static readonly uint[] DemoSeeds = { 1, 2, 3, 4, 5 }; // The catalog record the demo's protagonist needs, until the shipped catalog has one. - private void AddPirates() => _cache.Upsert(new Faction { Name = "Pirates", ShortName = "Pirates" }); + private void AddPirates() => _cache.Upsert(new Faction + { + Name = "Pirates", ShortName = "Pirates", GeonameFile = _cache.RefOf(_cache.GetAll().First()) + }); private Galaxy DemoGalaxy(uint seed) => RunStart.Generate(new DemoTerminus(), Inputs(() => seed)); From 65464a0a0e94bf94583e3fc4bbac3d4fe3b38904 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 19:45:37 +0200 Subject: [PATCH 04/17] Demo Terminus tests: the Pirates fixture reaches every manufacturer, as the entrance's station needs Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/DemoTerminusTests.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs index 03a1bb123..97e324649 100644 --- a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs +++ b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs @@ -14,11 +14,17 @@ public sealed partial class RunStartTests { private static readonly uint[] DemoSeeds = { 1, 2, 3, 4, 5 }; - // The catalog record the demo's protagonist needs, until the shipped catalog has one. - private void AddPirates() => _cache.Upsert(new Faction + // The catalog record the demo's protagonist needs, until the shipped catalog has one. Zone names need a name file, + // and the entrance's station needs a faction whose allegiance reaches some manufacturer, so it reaches every one. + private void AddPirates() { - Name = "Pirates", ShortName = "Pirates", GeonameFile = _cache.RefOf(_cache.GetAll().First()) - }); + var pirates = new Faction + { + Name = "Pirates", ShortName = "Pirates", GeonameFile = _cache.RefOf(_cache.GetAll().First()) + }; + foreach (var faction in _cache.GetAll().ToArray()) pirates.Allegiance[_cache.RefOf(faction)] = 1; + _cache.Upsert(pirates); + } private Galaxy DemoGalaxy(uint seed) => RunStart.Generate(new DemoTerminus(), Inputs(() => seed)); From 32b544936a614f55a6d0ae281aeea34309486455 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 19:52:09 +0200 Subject: [PATCH 05/17] Demo Terminus tests: Continue commits the run's own lot ledger Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/DemoTerminusTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs index 97e324649..9b51e53a4 100644 --- a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs +++ b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs @@ -61,7 +61,7 @@ public sealed partial class RunStartTests Assert.True(failures.Count == 0, string.Join("; ", failures)); galaxy.Entrance.Contents = arena; var (game, zones) = RunSave.Capture(_cache, galaxy, arena, staged.Player, new SavedActionBarBinding[0]); - RunSave.Commit(_cache, game, zones, RunSave.Lots(_cache)); + RunSave.Commit(_cache, game, zones, _items.Lots); var restored = new Galaxy(_cache, _cache.GetGlobal(), _ => { }); Dictionary Standing(Galaxy g) => g.Factions.ToDictionary(faction => faction.Name, faction => g.FactionRelationships[faction]); From edeec0f6105adfbabcc06ac22e6cafa68e3249a6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 20:24:17 +0200 Subject: [PATCH 06/17] Demo Terminus tests: compare zones by index, so a failing gate is a red test and not a dead test host Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/DemoTerminusTests.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs index 9b51e53a4..667121211 100644 --- a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs +++ b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs @@ -44,11 +44,14 @@ public sealed partial class RunStartTests foreach (var seed in DemoSeeds) { var galaxy = DemoGalaxy(seed); + // Zones are compared by index: xunit formats a failing zone into its message, and a zone drags the galaxy + // graph in with it until the test host dies. + int At(GalaxyZone zone) => Array.IndexOf(galaxy.Zones, zone); var zhestokost = galaxy.Factions.Single(faction => faction.Name == "Zhestokost"); - Assert.Equal(new[] { zhestokost }, galaxy.BossZones.Keys); - Assert.Same(galaxy.HomeZones[zhestokost], galaxy.BossZones[zhestokost]); - Assert.Same(galaxy.HomeZones[zhestokost], galaxy.Exit); - Assert.NotSame(galaxy.Entrance, galaxy.Exit); + Assert.Equal(new[] { "Zhestokost" }, galaxy.BossZones.Keys.Select(faction => faction.Name)); + Assert.Equal(At(galaxy.HomeZones[zhestokost]), At(galaxy.BossZones[zhestokost])); + Assert.Equal(At(galaxy.HomeZones[zhestokost]), At(galaxy.Exit)); + Assert.NotEqual(At(galaxy.Entrance), At(galaxy.Exit)); } } @@ -132,8 +135,8 @@ public sealed partial class RunStartTests { var galaxy = RunStart.Generate(new TutorialGalaxy(), Inputs(() => GalaxySeed)); Assert.Equal(TutorialSeed1, TutorialShape(galaxy)); - Assert.Null(galaxy.Exit); - Assert.Empty(galaxy.BossZones); + Assert.True(galaxy.Exit == null, "the tutorial has no exit"); + Assert.True(galaxy.BossZones.Count == 0, $"the tutorial has {galaxy.BossZones.Count} boss zones"); } private static string TutorialShape(Galaxy g) => From ff254b0269aab1bd2ba96709c831297be46b5af6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 20:43:39 +0200 Subject: [PATCH 07/17] Demo Terminus tests: a release build lists only the demo; the tutorial and main galaxy are development modes Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/DemoTerminusTests.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs index 667121211..01d950505 100644 --- a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs +++ b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs @@ -128,6 +128,15 @@ public sealed partial class RunStartTests Assert.Contains(field, refusal.Message); } + // What a release build lists under New Game is the demo alone; the tutorial and the main galaxy are listed only in + // editor and development builds (MainMenu.ShowScenarios), beside the test arenas. + [Fact] + public void ReleaseBuildsListOnlyTheDemo() + { + Assert.Equal(new[] { typeof(DemoTerminus) }, Scenarios.Modes.Select(scenario => scenario.GetType())); + Assert.Equal(new[] { typeof(TutorialGalaxy), typeof(MainGalaxy) }, Scenarios.Development.Select(scenario => scenario.GetType())); + } + // The tutorial is the galaxy it was before the demo: its cast, homes, entrance and ownership at a fixed seed, with // no exit and no boss zone. [Fact] From bd77e3ed87e6b942f0b7f4378343db537288babb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 21:35:55 +0200 Subject: [PATCH 08/17] Demo Terminus tests: the name, the Pirates hull and its start, and the region's size are pinned Each reads the compiled scenario as the run start builds it: the listed scenario's Name, the staged player's faction, hull and position, and the generated galaxy's zone count and links against the same cast at density one half. Co-Authored-By: Claude Opus 5.5 --- .../DemoTerminusTests.cs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs index 01d950505..daffe87ff 100644 --- a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs +++ b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; +using CultMath; using Xunit; // The demo scenario (aetheria-release, cut demo-galaxy): a prelude galaxy of a fixed cast whose antagonist's home is the @@ -93,6 +94,58 @@ public sealed partial class RunStartTests Assert.Contains(nameof(TutorialGenerationSettings.ProtagonistFaction), refusal.Message); } + // The operator's ruling is that the first release is a demo: the one scenario a release build lists says so by name. + [Fact] + public void DemoTerminus_is_named_a_demo() + { + var listed = Assert.Single(Scenarios.Modes); + Assert.IsType(listed); + Assert.Equal("Terminus (Demo)", listed.Name); + } + + // The run start as New Game runs it: the player flies a Pirates-made hull, at the entrance zone's origin. + [Fact] + public void DemoTerminus_player_flies_a_pirates_hull_at_the_origin() + { + AddPirates(); + foreach (var seed in DemoSeeds) + { + var (galaxy, _, staged, failures) = Launch(new DemoTerminus(), Inputs(() => seed)); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + Assert.Equal("Pirates", staged.Player.Faction.Name); + Assert.Same(galaxy.Factions.Single(faction => faction.Name == "Pirates"), staged.Player.Faction); + Assert.Equal(_startingHull, staged.Player.HullData.Name); + Assert.Equal(float3.zero, staged.Player.Position); + } + } + + // The demo region's size and connectivity are authored: 64 zones, links thinned to half. The reference galaxy is the + // same cast with the thinning written out here, generated at the same seed, so any other density is a different graph. + [Fact] + public void DemoTerminus_region_is_sixty_four_zones_linked_at_half_density() + { + AddPirates(); + string Links(Galaxy g) => string.Join(";", g.Zones.Select(zone => + string.Join(",", zone.AdjacentZones.Select(adjacent => Array.IndexOf(g.Zones, adjacent)).OrderBy(index => index)))); + Galaxy Reference(uint seed, float density) + { + var cast = new TutorialGenerationSettings + { + ProtagonistFaction = "Pirates", AntagonistFaction = "Zhe", BufferFaction = "Luc", + NeutralFactions = new[] { "Aero" }, QuestFaction = null, ZoneCount = 64, LinkDensity = density + }; + return RunStart.Generate(new Scripted(false, _ => { }, seed, galaxy => galaxy.Prelude(cast)), Inputs()); + } + + foreach (var seed in DemoSeeds) + { + var galaxy = DemoGalaxy(seed); + Assert.Equal(64, galaxy.Zones.Length); + Assert.Equal(Links(Reference(seed, .5f)), Links(galaxy)); + Assert.NotEqual(Links(Reference(seed, 1f)), Links(galaxy)); + } + } + // A cast that names no quest faction places no quest zone: one home for each of its four factions. [Fact] public void ACastNamingNoQuestFactionPlacesNoQuestZone() From ebe64600c179db33a4dd4be082c14eed23bace57 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 22:47:47 +0200 Subject: [PATCH 09/17] Demo Terminus: the player starts in the base Longinus, not a Pirates-made hull The Pirates hire the player, who is not one of them (ruling player-not-a-pirate); the base Longinus with its default fit is the start hull (ruling demo-start-hull-longinus). The hull is named in the script; StartingHull no longer owns the demo's hull. The Pirates-hull test is replaced by one pinning the Longinus, its Alakrita maker, a null ship faction and the origin. Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Scenarios/DemoTerminus.cs | 4 ++-- tests/Aetheria.Shared.Tests/DemoTerminusTests.cs | 15 ++++++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs b/Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs index 0555a3b29..8cda7c600 100644 --- a/Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs +++ b/Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs @@ -2,7 +2,7 @@ using float2 = CultMath.float2; // The demo: one gate, one region and one boss, with a fixed cast. The Pirates are the protagonist's ostensible // employer, Zhestokost the antagonist whose home is the boss zone and the exit, Lucent Media and Aeronautics Unlimited -// the neutrals. A prelude galaxy of that cast, entered in a Pirates-made hull. +// the neutrals. A prelude galaxy of that cast; the player, who is not a Pirate, flies the base Longinus. public sealed class DemoTerminus : Scenario { public static readonly TutorialGenerationSettings Cast = new TutorialGenerationSettings @@ -36,6 +36,6 @@ public sealed class DemoTerminus : Scenario galaxy.FactionRelationships[galaxy.ResolveFaction(Cast.BufferFaction)] = FactionRelationship.Neutral; foreach (var neutral in Cast.NeutralFactions) galaxy.FactionRelationships[galaxy.ResolveFaction(neutral)] = FactionRelationship.Neutral; - stage.Player(stage.Generated(stage.StartingHull, pirates), float2.zero); + stage.Player(stage.Generated("Longinus"), float2.zero); } } diff --git a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs index daffe87ff..e2df794cd 100644 --- a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs +++ b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs @@ -103,18 +103,23 @@ public sealed partial class RunStartTests Assert.Equal("Terminus (Demo)", listed.Name); } - // The run start as New Game runs it: the player flies a Pirates-made hull, at the entrance zone's origin. + // The run start as New Game runs it: the player is not a Pirate, so the hull is the base Longinus, made by Alakrita, + // not by the Pirates or any cast faction, with no faction on the ship, at the entrance zone's origin. [Fact] - public void DemoTerminus_player_flies_a_pirates_hull_at_the_origin() + public void DemoTerminus_player_flies_a_non_pirates_brand_hull_the_longinus() { AddPirates(); foreach (var seed in DemoSeeds) { var (galaxy, _, staged, failures) = Launch(new DemoTerminus(), Inputs(() => seed)); Assert.True(failures.Count == 0, string.Join("; ", failures)); - Assert.Equal("Pirates", staged.Player.Faction.Name); - Assert.Same(galaxy.Factions.Single(faction => faction.Name == "Pirates"), staged.Player.Faction); - Assert.Equal(_startingHull, staged.Player.HullData.Name); + Assert.Equal("Longinus", staged.Player.HullData.Name); + var design = _cache.RefOf(staged.Player.HullData).Key; + var product = Assert.Single(_cache.GetAll(), p => p.Design.Key.Equals(design)); + var maker = _cache.Get(product.Manufacturer); + Assert.Equal("Alakrita", maker.Name); + Assert.DoesNotContain(galaxy.Factions, faction => faction.Name == maker.Name); + Assert.Null(staged.Player.Faction); Assert.Equal(float3.zero, staged.Player.Position); } } From 85d0a14808f56cb2bda38606dd2df910d237155d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 00:04:46 +0200 Subject: [PATCH 10/17] Demo Terminus tests: the player's Longinus carries the default fit the game generates for it The expected fit is the generator replayed from the staging seed, so a bare hull, a preset or one swapped item fails. Co-Authored-By: Claude Opus 5.5 --- .../DemoTerminusTests.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs index e2df794cd..3b41c16e0 100644 --- a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs +++ b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs @@ -124,6 +124,28 @@ public sealed partial class RunStartTests } } + // The ruling says the Longinus with its default fit: the fit the game generates for that hull from the run's item + // draws, which Stage seeds from the galaxy's seed. The expected fit is that same generation replayed from the same + // seed, so no item list is spelled here; a bare hull, a preset, or one item swapped for another differs from it. + [Fact] + public void DemoTerminus_player_carries_the_longinus_default_fit() + { + AddPirates(); + string Fit(Entity ship) => string.Join(";", ship.Equipment + .Select(item => $"{item.Data.Name}@{item.Position}/{item.EquippableItem.Rotation}") + .OrderBy(entry => entry, StringComparer.Ordinal)); + foreach (var seed in DemoSeeds) + { + var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => seed)); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + _items.Random = new CultMath.Random(galaxy.Seed * 0x9E3779B1u + 2); + var generator = new LoadoutGenerator(ref _items.Random, _items, galaxy, arena.GalaxyZone, null, 2); + var expected = EntitySerializer.Unpack(_items, null, generator.GenerateShipLoadout(hull => hull.Name == "Longinus")); + Assert.NotEmpty(expected.Equipment); + Assert.Equal(Fit(expected), Fit(staged.Player)); + } + } + // The demo region's size and connectivity are authored: 64 zones, links thinned to half. The reference galaxy is the // same cast with the thinning written out here, generated at the same seed, so any other density is a different graph. [Fact] From db4617d832d19730d46ffd0df45da7ebf2a01d98 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 01:13:08 +0200 Subject: [PATCH 11/17] Boss gate: a boss zone spawns its faction's boss, and RunGoal derives whether the exit gate is open EntityPack.Boss (key 21) carries Entity.IsBoss through save and Continue. GenerateZone adds one boss per BossZones entry before the ambient return, flying the faction's BossHull or, with the gap logged, its most expensive available ship hull. RunGoal (ServerShared) is the one owner of boss life and exit state, derived from Zone.Entities. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 2 + .../Scripts/ServerShared/EntitySerializer.cs | 4 + .../Scripts/ServerShared/LoadoutGenerator.cs | 38 +++- Assets/Scripts/ServerShared/RunGoal.cs | 41 ++++ Assets/Scripts/ServerShared/RunGoal.cs.meta | 2 + Assets/Scripts/ServerShared/Wormhole.cs | 2 + Assets/Scripts/ServerShared/ZoneGenerator.cs | 14 ++ tests/Aetheria.Shared.Tests/RunGoalTests.cs | 187 ++++++++++++++++++ 8 files changed, 285 insertions(+), 5 deletions(-) create mode 100644 Assets/Scripts/ServerShared/RunGoal.cs create mode 100644 Assets/Scripts/ServerShared/RunGoal.cs.meta create mode 100644 tests/Aetheria.Shared.Tests/RunGoalTests.cs diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 853bea91e..4bc82d89c 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -16,6 +16,8 @@ public abstract class Entity { public Zone Zone; public Faction Faction; + // The mark of a zone's boss, written only by ZoneGenerator and restored from the pack. RunGoal reads it. + public bool IsBoss; public EquippableItem Hull; public EquippedItem EquippedHull; diff --git a/Assets/Scripts/ServerShared/EntitySerializer.cs b/Assets/Scripts/ServerShared/EntitySerializer.cs index 6fb749b29..7f934b937 100644 --- a/Assets/Scripts/ServerShared/EntitySerializer.cs +++ b/Assets/Scripts/ServerShared/EntitySerializer.cs @@ -28,6 +28,7 @@ public static class EntitySerializer else throw new ArgumentException("Attempted to pack an instance of abstract class Entity!"); pack.Settings = entity.Settings; + pack.Boss = entity.IsBoss; pack.Hull = entity.Hull; pack.Name = entity.Name; @@ -81,6 +82,7 @@ public static class EntitySerializer private static void Restore(ItemManager itemManager, Zone zone, EntityPack pack, Entity entity) { entity.Name = pack.Name; + entity.IsBoss = pack.Boss; entity.Faction = itemManager.ItemData.Get(pack.Faction); entity.Children = pack.Children.Select(c => { @@ -187,6 +189,8 @@ public abstract class EntityPack [Key(14)] public EntitySettings Settings; [Key(15)] public CultRecordRef Faction; [Key(16)] public int[][] WeaponGroups; + // Keys 17-20 belong to the subclasses; the base's next free key is 21. + [Key(21)] public bool Boss; private int _price; diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 2fb2a69f9..42f43033e 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -140,6 +140,18 @@ public class LoadoutGenerator // required item falls back to any manufacturer when none of those make one, which means the product table // lacks variety, so it is logged rather than hidden. public (FactionProductData product, T design)[] RandomProducts(int count, float sizeExponent, Predicate filter = null, bool required = false) where T : EquippableItemData + { + var (available, preferManufacturers) = AvailableProducts(filter, required); + return available.WeightedRandomElements(ref Random, entry => + (preferManufacturers ? ManufacturerPreference(entry.product.Manufacturer) : 1) * + pow(entry.design.Shape.Coordinates.Length, sizeExponent) / // Prioritize larger items + pow(entry.design.Price, PriceExponent), // Penalize item price to a controllable degree + count); + } + + // The products this faction can be offered for a design kind, and whether manufacturer preference applies (it does + // not once a required item has fallen back to any manufacturer). The one availability rule for every selection. + private ((FactionProductData product, T design)[] available, bool preferManufacturers) AvailableProducts(Predicate filter, bool required) where T : EquippableItemData { var hulls = ItemManager.ItemData.GetAll().ToArray(); var candidates = ItemManager.ItemData.GetAll() @@ -160,11 +172,27 @@ public class LoadoutGenerator preferManufacturers = false; } - return available.WeightedRandomElements(ref Random, entry => - (preferManufacturers ? ManufacturerPreference(entry.product.Manufacturer) : 1) * - pow(entry.design.Shape.Coordinates.Length, sizeExponent) / // Prioritize larger items - pow(entry.design.Price, PriceExponent), // Penalize item price to a controllable degree - count); + return (available, preferManufacturers); + } + + // A faction's boss: its BossHull when the faction sets one and a product sells it, else the most expensive ship hull + // the faction can be offered, with the gap logged. Null only when no ship hull is on offer at all. + public EntityPack GenerateBossLoadout() + { + var bossHull = Faction != null && Faction.BossHull.IsSet() ? ItemManager.ItemData.Get(Faction.BossHull) : null; + if (bossHull != null) + { + var boss = GenerateShipLoadout(hull => hull == bossHull); + if (boss != null) return boss; + ItemManager.Log($"{Faction.Name}'s BossHull {bossHull.Name} is not sold by any product; its boss flies the most expensive ship hull instead."); + } + else + ItemManager.Log($"{Faction?.Name ?? "This faction"} has no BossHull; its boss flies the most expensive ship hull instead."); + + var (available, _) = AvailableProducts(hull => hull.HullType == HullType.Ship, required: true); + if (available.Length == 0) return null; + var priciest = available.OrderByDescending(entry => entry.design.Price).ThenBy(entry => entry.design.Name, StringComparer.Ordinal).First().design; + return GenerateShipLoadout(hull => hull == priciest); } // Hardpoint gear that no hull in the catalog can mount has no home (operator, 2026-09-30: "we'll need to author a diff --git a/Assets/Scripts/ServerShared/RunGoal.cs b/Assets/Scripts/ServerShared/RunGoal.cs new file mode 100644 index 000000000..966789a36 --- /dev/null +++ b/Assets/Scripts/ServerShared/RunGoal.cs @@ -0,0 +1,41 @@ +using System.Linq; +using CultMath; +using static CultMath.math; + +// The run's goal: whether a zone's boss lives, and so whether the exit gate is open. Engine-free and stateless; the +// answer is derived from Zone.Entities (the death path removes the dead, Zone.cs), so no "boss defeated" flag exists to +// disagree with it, and no presentation decides it. +public static class RunGoal +{ + public static Entity Boss(Zone zone) => zone.Entities.FirstOrDefault(entity => entity.IsBoss); + + public static bool BossAlive(Zone zone) => Boss(zone) != null; + + // The exit gate is open in the galaxy's exit zone once that zone holds no living boss. + public static bool ExitOpen(Zone zone) => + zone.Galaxy?.Exit != null && zone.GalaxyZone == zone.Galaxy.Exit && !BossAlive(zone); + + // Where the exit gate sits: at the adjacency wormholes' radius (the zone's radius times the ratio), in the middle of + // the widest angular gap between them, so it shares a point with none. A zone with no neighbours gets the +x axis. + public static float2 ExitGatePosition(Zone zone, float wormholeDistanceRatio) + { + var radius = zone.Pack.Radius * wormholeDistanceRatio; + var angles = zone.GalaxyZone.AdjacentZones + .Select(adjacent => normalize(adjacent.Position - zone.GalaxyZone.Position)) + .Select(direction => atan2(direction.y, direction.x)) + .OrderBy(angle => angle) + .ToArray(); + if (angles.Length == 0) return float2(radius, 0); + + var bestAngle = 0f; + var bestGap = -1f; + for (var i = 0; i < angles.Length; i++) + { + var next = i + 1 < angles.Length ? angles[i + 1] : angles[0] + 2 * PI; + if (next - angles[i] <= bestGap) continue; + bestGap = next - angles[i]; + bestAngle = angles[i] + bestGap / 2; + } + return float2(cos(bestAngle), sin(bestAngle)) * radius; + } +} diff --git a/Assets/Scripts/ServerShared/RunGoal.cs.meta b/Assets/Scripts/ServerShared/RunGoal.cs.meta new file mode 100644 index 000000000..2e3839fad --- /dev/null +++ b/Assets/Scripts/ServerShared/RunGoal.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: de03c3545cb04ce38bf85b713dc5df74 diff --git a/Assets/Scripts/ServerShared/Wormhole.cs b/Assets/Scripts/ServerShared/Wormhole.cs index bad43b9fd..fd93e51ca 100644 --- a/Assets/Scripts/ServerShared/Wormhole.cs +++ b/Assets/Scripts/ServerShared/Wormhole.cs @@ -4,4 +4,6 @@ public class Wormhole { public float2 Position; public GalaxyZone Target; + // The run's exit gate: it leads nowhere (Target is null) and ends the run when RunGoal.ExitOpen. + public bool Exit; } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 2ff4fe005..40078c562 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -337,6 +337,20 @@ public static class ZoneGenerator if (ambient) PlaceTurrets(lagrangeOrbit, GetLoadoutGenerator(nearestFaction), 2); } + // A boss zone gets its faction's boss whether or not the zone is ambient: the boss is the run's goal, not + // wandering traffic. RunGoal reads the mark; nothing else decides whether a boss lives. + foreach (var bossFaction in galaxy.BossZones.Where(entry => entry.Value == galaxyZone).Select(entry => entry.Key)) + { + var boss = GetLoadoutGenerator(bossFaction).GenerateBossLoadout(); + if (boss == null) + { + itemManager.Log($"No boss could be generated for {bossFaction.Name}; its boss zone has no boss to kill!"); + continue; + } + boss.Boss = true; + pack.Entities.Add(boss); + } + if (!ambient) return pack; var enemyCount = (int)(random.NextFloat() * factionPresence * 2) + baseStationCount; diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs new file mode 100644 index 000000000..897d647aa --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -0,0 +1,187 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using CultMath; +using UniRx; +using Xunit; + +// The demo's goal (aetheria-release, cut boss-gate): the boss zone spawns its faction's boss, and the exit gate opens +// only when that boss is dead. Every input is the production one: the demo galaxy, GenerateZone, the Zone death path, +// and the run save. +public sealed partial class RunStartTests +{ + private ZonePack BossZonePack(Galaxy galaxy, ItemManager items, GalaxyZone zone, bool ambient = true) + { + items.Random = new CultMath.Random(7); + return ZoneGenerator.GenerateZone(items, _zoneSettings, galaxy, zone, galaxy.IsPrelude, ambient); + } + + private Zone ZoneOf(Galaxy galaxy, GalaxyZone zone) => + new Zone(_items, _planetSettings, BossZonePack(galaxy, _items, zone), zone, galaxy); + + private static Faction Zhestokost(Galaxy galaxy) => galaxy.Factions.Single(faction => faction.Name == "Zhestokost"); + + private ItemManager LoggingItems(List log) => + new ItemManager(_cache, new ProvenanceLedger(), AuthoredSettings.Load(FindRepoRoot()).Read("GameplaySettings"), log.Add); + + private static string HullName(ItemManager items, EntityPack pack) => items.GetData(pack.Hull).Name; + + private HullData[] SoldShipHulls() => _cache.GetAll() + .Where(product => product.Manufacturer.IsSet()) + .Select(product => _cache.Get(product.Design) as HullData) + .Where(hull => hull != null && hull.HullType == HullType.Ship && hull.Price > 0) + .Distinct().ToArray(); + + [Fact] + public void Boss_spawns_once_in_boss_zone() + { + AddPirates(); + foreach (var seed in DemoSeeds.Take(3)) + { + var galaxy = DemoGalaxy(seed); + var antagonist = Zhestokost(galaxy); + foreach (var zone in galaxy.Zones) + { + var bosses = BossZonePack(galaxy, _items, zone).Entities.Where(entity => entity.Boss).ToArray(); + if (zone != galaxy.Exit) { Assert.Empty(bosses); continue; } + + var boss = Assert.Single(bosses); + Assert.IsType(boss); + Assert.Same(antagonist, _cache.Get(boss.Faction)); + // Generation is repeatable per zone: the same zone yields the same boss hull. + var again = Assert.Single(BossZonePack(galaxy, _items, zone).Entities, entity => entity.Boss); + Assert.Equal(HullName(_items, boss), HullName(_items, again)); + } + } + } + + // A zone that is not ambient still has its boss: the boss is the goal, not traffic. + [Fact] + public void Boss_spawns_in_a_zone_that_is_not_ambient() + { + AddPirates(); + var galaxy = DemoGalaxy(1); + var pack = BossZonePack(galaxy, _items, galaxy.Exit, ambient: false); + Assert.Single(pack.Entities, entity => entity.Boss); + Assert.DoesNotContain(pack.Entities, entity => !entity.Boss && entity is ShipPack); + } + + [Fact] + public void Boss_hull_from_BossHull_else_fallback() + { + AddPirates(); + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var hulls = SoldShipHulls().OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).ToArray(); + var priciest = hulls.First(); + var cheaper = hulls.Last(); + Assert.True(cheaper.Price < priciest.Price, "the catalog needs two ship hulls of different price for this test to tell them apart"); + + // Set and sold: the boss flies it, and nothing is logged about a gap. + antagonist.BossHull = _cache.RefOf(cheaper); + var log = new List(); + var items = LoggingItems(log); + var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); + Assert.Equal(cheaper.Name, HullName(items, boss)); + Assert.DoesNotContain(log, line => line.Contains("BossHull")); + + // Unset: the most expensive ship hull on offer, and the log names the missing BossHull. + antagonist.BossHull = default; + log.Clear(); + boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); + Assert.Equal(priciest.Name, HullName(items, boss)); + Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("no BossHull")); + } + + // Set but sold by no product: the boss falls back the same way, and the log says which hull has no seller. + [Fact] + public void Boss_hull_nobody_sells_falls_back() + { + AddPirates(); + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var sold = SoldShipHulls(); + var unsold = _cache.GetAll().FirstOrDefault(hull => hull.HullType == HullType.Ship && !sold.Contains(hull)); + Assert.True(unsold != null, "the catalog needs a ship hull no product sells for this test"); + antagonist.BossHull = _cache.RefOf(unsold); + var log = new List(); + var items = LoggingItems(log); + var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); + Assert.Equal(sold.OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).First().Name, HullName(items, boss)); + Assert.Contains(log, line => line.Contains(unsold.Name) && line.Contains("BossHull")); + } + + [Fact] + public void Exit_sealed_until_boss_dies() + { + AddPirates(); + var galaxy = DemoGalaxy(1); + var exit = ZoneOf(galaxy, galaxy.Exit); + var boss = Assert.Single(exit.Entities, entity => entity.IsBoss); + Assert.True(RunGoal.BossAlive(exit)); + Assert.False(RunGoal.ExitOpen(exit)); + + // Death removes the boss from the zone (the one death path), and that alone opens the gate. + boss.HeatstrokeDeath.OnNext(Unit.Default); + Assert.DoesNotContain(boss, exit.Entities); + Assert.False(RunGoal.BossAlive(exit)); + Assert.True(RunGoal.ExitOpen(exit)); + + // No other zone is ever an exit, boss or no boss. + foreach (var zone in galaxy.Zones.Where(zone => zone != galaxy.Exit)) + Assert.False(RunGoal.ExitOpen(ZoneOf(galaxy, zone))); + } + + // Continue: the mark is saved with the zone and restored, so a boss alive when the run was saved is alive after. + [Fact] + public void Boss_mark_survives_continue() + { + AddPirates(); + var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => 1)); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + galaxy.Entrance.Contents = arena; + galaxy.Exit.Contents = ZoneOf(galaxy, galaxy.Exit); + var before = Assert.Single(galaxy.Exit.Contents.Entities, entity => entity.IsBoss); + Assert.False(RunGoal.ExitOpen(galaxy.Exit.Contents)); + + var (game, zones) = RunSave.Capture(_cache, galaxy, arena, staged.Player, new SavedActionBarBinding[0]); + RunSave.Commit(_cache, game, zones, _items.Lots); + var restored = new Galaxy(_cache, _cache.GetGlobal(), _ => { }); + var exit = new Zone(_items, _planetSettings, restored.Exit.PackedContents, restored.Exit, restored); + + var after = Assert.Single(exit.Entities, entity => entity.IsBoss); + Assert.Equal(before.Name, after.Name); + Assert.Equal(before.Faction.Name, after.Faction.Name); + Assert.False(RunGoal.ExitOpen(exit)); + after.HeatstrokeDeath.OnNext(Unit.Default); + Assert.True(RunGoal.ExitOpen(exit)); + } + + // The gate shares no point with an adjacency wormhole: every one is farther from it than a ship's wormhole reach. + [Fact] + public void Exit_gate_shares_no_point_with_a_wormhole() + { + AddPirates(); + var ratio = float.Parse(File.ReadLines(Path.Combine(FindRepoRoot(), "Assets", "Resources", "Settings.asset")) + .Select(line => Regex.Match(line, @"^ WormholeDistanceRatio: (.+)$")).First(match => match.Success).Groups[1].Value.Trim(), + System.Globalization.CultureInfo.InvariantCulture); + var reach = AuthoredSettings.Load(FindRepoRoot()).Read("GameplaySettings").WormholeExitRadius; + foreach (var seed in DemoSeeds) + { + var galaxy = DemoGalaxy(seed); + var zone = ZoneOf(galaxy, galaxy.Exit); + var gate = RunGoal.ExitGatePosition(zone, ratio); + foreach (var adjacent in galaxy.Exit.AdjacentZones) + { + var wormhole = math.normalize(adjacent.Position - galaxy.Exit.Position) * zone.Pack.Radius * ratio; + Assert.True(math.length(gate - wormhole) > 2 * reach, $"seed {seed}: the exit gate sits {math.length(gate - wormhole)} from a wormhole"); + } + } + } +} From 9883d08dcd946835e7bdd2bca497e3cef242c973 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 01:13:09 +0200 Subject: [PATCH 12/17] Exit gate: the exit zone shows a gate that ends the run as won once RunGoal says the boss is dead ZoneRenderer adds an exit wormhole at RunGoal.ExitGatePosition; Interact asks RunGoal.ExitOpen and either calls Win or names the boss that seals it. Die and Win share one EndRun. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 32 ++++++++++++++++++-- Assets/Scripts/Zone Display/ZoneRenderer.cs | 8 +++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index c8f0bb6e2..a32eaf41d 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -323,6 +323,19 @@ public class ActionGameManager : MonoBehaviour foreach (var wormhole in ZoneRenderer.WormholeInstances.Keys) { if (!(length(wormhole.Position - CurrentEntity.Position.xz) < Settings.GameplaySettings.WormholeExitRadius)) continue; + if (wormhole.Exit) + { + // The exit gate leads nowhere: RunGoal decides whether it is open, and an open one ends the run. + if (RunGoal.ExitOpen(Zone)) Win(); + else + { + Dialog.Clear(); + Dialog.Title.text = $"The exit gate is sealed. {RunGoal.Boss(Zone)?.Name ?? "Its guardian"} holds it shut."; + Dialog.Show(); + Dialog.MoveToCursor(); + } + return; + } EnterWormhole(wormhole); } Dock(); @@ -1165,9 +1178,9 @@ public class ActionGameManager : MonoBehaviour return float3(pose.x, Settings.PlanetSettings.AsteroidVerticalOffset, pose.y); } - private void Die(CauseOfDeath cause) + // The one way a run ends, won or lost: the run is cleared and the main menu returns. + private void EndRun() { - var deathTime = Time.time; UnbindEntity(); CurrentEntity = null; MainMenu.gameObject.SetActive(true); @@ -1175,6 +1188,21 @@ public class ActionGameManager : MonoBehaviour CurrentGalaxy = null; RunSave.Clear(CultCache); SavePlayerSettings(); + } + + private void Win() + { + EndRun(); + Dialog.Clear(); + Dialog.Title.text = "You have escaped the Terminus. This was the demo; the whole of it is yet to come."; + Dialog.Show(); + Dialog.MoveToCursor(); + } + + private void Die(CauseOfDeath cause) + { + var deathTime = Time.time; + EndRun(); Observable.EveryUpdate() .Where(_ => Time.time - deathTime < DeathPostTransitionTime) .Subscribe(_ => diff --git a/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index b6021027a..89e9a9515 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -233,6 +233,14 @@ public class ZoneRenderer : MonoBehaviour Position = dir * zone.Pack.Radius * Settings.WormholeDistanceRatio }); } + + // The run's exit gate is presentation only: whether it opens is RunGoal.ExitOpen, read when it is used. + if (zone.GalaxyZone == zone.Galaxy.Exit) + AddWormhole(new Wormhole + { + Exit = true, + Position = RunGoal.ExitGatePosition(zone, Settings.WormholeDistanceRatio) + }); } } From bb2f49f3583780ad22a0f9a48a8312c6fb9796fe Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 01:24:29 +0200 Subject: [PATCH 13/17] Boss gate tests: the unsold BossHull case builds its own unsold hull, as the catalog has none Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 897d647aa..48abb3043 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -107,8 +107,9 @@ public sealed partial class RunStartTests var galaxy = DemoGalaxy(1); var antagonist = Zhestokost(galaxy); var sold = SoldShipHulls(); - var unsold = _cache.GetAll().FirstOrDefault(hull => hull.HullType == HullType.Ship && !sold.Contains(hull)); - Assert.True(unsold != null, "the catalog needs a ship hull no product sells for this test"); + // A ship hull no product sells, dearer than any that is sold: the fallback must not pick it. + var unsold = new HullData { Name = "Unsold Test Hull", HullType = HullType.Ship, Price = sold.Max(hull => hull.Price) + 1 }; + _cache.Upsert(unsold); antagonist.BossHull = _cache.RefOf(unsold); var log = new List(); var items = LoggingItems(log); From 90768de10ef31ed9514e3348f3d5d614ef93ec4a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 03:54:04 +0200 Subject: [PATCH 14/17] Boss gate tests: the exit gate's position is pinned against a sweep of every direction Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 40 +++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 48abb3043..8e75fef90 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -185,4 +185,44 @@ public sealed partial class RunStartTests } } } + + // The gate sits on the wormholes' radius, in the middle of the widest gap between neighbours, whatever the + // neighbours' number and order: an empty ring, one, two straddling the +-pi seam, and eight spread evenly (every gap + // under one radian). Checked against a brute-force sweep of every direction. + [Theory] + [InlineData(new double[0])] + [InlineData(new[] { 40.0 })] + [InlineData(new[] { 170.0, -170.0 })] + [InlineData(new[] { 100.0, 10.0, 20.0, 350.0, -90.0 })] + [InlineData(new[] { 0.0, 45.0, 90.0, 135.0, 180.0, -135.0, -90.0, -45.0 })] + public void Exit_gate_sits_in_the_widest_gap_at_the_wormhole_radius(double[] neighbourDegrees) + { + AddPirates(); + var galaxy = DemoGalaxy(1); + var zone = ZoneOf(galaxy, galaxy.Exit); + const float ratio = 0.75f; + var radius = zone.Pack.Radius * ratio; + var origin = galaxy.Exit.Position; + var original = galaxy.Exit.AdjacentZones; + galaxy.Exit.AdjacentZones = neighbourDegrees + .Select(degrees => new GalaxyZone + { + Position = origin + math.float2((float) Math.Cos(degrees * Math.PI / 180), (float) Math.Sin(degrees * Math.PI / 180)) * 1000 + }).ToList(); + float2 gate; + try { gate = RunGoal.ExitGatePosition(zone, ratio); } + finally { galaxy.Exit.AdjacentZones = original; } + + Assert.Equal(radius, math.length(gate), 2); + if (neighbourDegrees.Length == 0) { Assert.Equal(radius, gate.x, 2); Assert.Equal(0, gate.y, 2); return; } + + double Separation(double angle) => neighbourDegrees.Min(degrees => + { + var difference = Math.Abs(angle - degrees * Math.PI / 180) % (2 * Math.PI); + return difference > Math.PI ? 2 * Math.PI - difference : difference; + }); + var best = Enumerable.Range(0, 7200).Max(step => Separation(step * Math.PI / 3600)); + Assert.True(Separation(Math.Atan2(gate.y, gate.x)) >= best - 0.002, + $"the gate's nearest wormhole is {Separation(Math.Atan2(gate.y, gate.x))} rad away; the widest gap's middle is {best}"); + } } From cb56557b4d8c56f385e270352faf26001f64d249 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 04:38:36 +0200 Subject: [PATCH 15/17] Boss gate tests: the gate geometry test places the exit zone away from the origin Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 8e75fef90..b910f768b 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -202,16 +202,19 @@ public sealed partial class RunStartTests var zone = ZoneOf(galaxy, galaxy.Exit); const float ratio = 0.75f; var radius = zone.Pack.Radius * ratio; - var origin = galaxy.Exit.Position; + // Away from the origin and with close neighbours, so a direction taken from raw positions instead of the + // offset from the zone cannot pass. + var originalPosition = galaxy.Exit.Position; + var origin = galaxy.Exit.Position = math.float2(500, -300); var original = galaxy.Exit.AdjacentZones; galaxy.Exit.AdjacentZones = neighbourDegrees .Select(degrees => new GalaxyZone { - Position = origin + math.float2((float) Math.Cos(degrees * Math.PI / 180), (float) Math.Sin(degrees * Math.PI / 180)) * 1000 + Position = origin + math.float2((float) Math.Cos(degrees * Math.PI / 180), (float) Math.Sin(degrees * Math.PI / 180)) * 10 }).ToList(); float2 gate; try { gate = RunGoal.ExitGatePosition(zone, ratio); } - finally { galaxy.Exit.AdjacentZones = original; } + finally { galaxy.Exit.AdjacentZones = original; galaxy.Exit.Position = originalPosition; } Assert.Equal(radius, math.length(gate), 2); if (neighbourDegrees.Length == 0) { Assert.Equal(radius, gate.x, 2); Assert.Equal(0, gate.y, 2); return; } From c8d40eaf74b6a21182e8469fc1dc8a75719a5f76 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 06:41:33 +0200 Subject: [PATCH 16/17] Boss gate: the exit gate fails closed; it opens only for a boss that spawned and then died ZonePack.BossSpawned records the spawn, since a dead boss leaves no entity. RunGoal.ExitOpen requires it, so an exit zone without a boss stays sealed, and ZoneGenerator logs that configuration by field and code. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/RunGoal.cs | 5 ++- Assets/Scripts/ServerShared/Zone.cs | 1 + Assets/Scripts/ServerShared/ZoneData.cs | 5 +++ Assets/Scripts/ServerShared/ZoneGenerator.cs | 4 ++ tests/Aetheria.Shared.Tests/RunGoalTests.cs | 46 ++++++++++++++++++++ 5 files changed, 59 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/RunGoal.cs b/Assets/Scripts/ServerShared/RunGoal.cs index 966789a36..ee34b1563 100644 --- a/Assets/Scripts/ServerShared/RunGoal.cs +++ b/Assets/Scripts/ServerShared/RunGoal.cs @@ -11,9 +11,10 @@ public static class RunGoal public static bool BossAlive(Zone zone) => Boss(zone) != null; - // The exit gate is open in the galaxy's exit zone once that zone holds no living boss. + // The exit gate fails closed: it is open only in the galaxy's exit zone, once a boss was spawned there and no living + // boss remains. An exit zone that never got a boss stays sealed, so a missing boss can never read as a dead one. public static bool ExitOpen(Zone zone) => - zone.Galaxy?.Exit != null && zone.GalaxyZone == zone.Galaxy.Exit && !BossAlive(zone); + zone.Galaxy?.Exit != null && zone.GalaxyZone == zone.Galaxy.Exit && zone.Pack.BossSpawned && !BossAlive(zone); // Where the exit gate sits: at the adjacency wormholes' radius (the zone's radius times the ratio), in the middle of // the widest angular gap between them, so it shares a point with none. A zone with no neighbours gets the +x axis. diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index d3973d92e..3be6ad99c 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -182,6 +182,7 @@ public class Zone Orbits = Orbits.Keys.Select(key => new CultRecordRef(key)).ToList(), Planets = Planets.Keys.Select(key => new CultRecordRef(key)).ToList(), Time = _time, + BossSpawned = Pack.BossSpawned, // Cut 2 (docs/mining-cut.md): drop entries that carry no live information -- healed (no damage) and // not currently broken -- so a save never accumulates wear rows for chunks nobody has touched since // they last respawned. diff --git a/Assets/Scripts/ServerShared/ZoneData.cs b/Assets/Scripts/ServerShared/ZoneData.cs index 2216355d6..7dc02b878 100644 --- a/Assets/Scripts/ServerShared/ZoneData.cs +++ b/Assets/Scripts/ServerShared/ZoneData.cs @@ -43,6 +43,11 @@ public class ZonePack // default instance. [JsonProperty("chunkWear"), Key(6)] public List ChunkWear; + + // True once this zone's boss has been spawned. The dead boss leaves no entity, so this is the one fact that tells a + // slain boss from one that never existed; RunGoal.ExitOpen reads it. Written only by ZoneGenerator, carried by PackZone. + [JsonProperty("bossSpawned"), Key(7)] + public bool BossSpawned; } // Cut 2 (docs/mining-cut.md): one packed wear entry for one chunk. "Field" names the body that owns the chunk diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 40078c562..156deb803 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -349,8 +349,12 @@ public static class ZoneGenerator } boss.Boss = true; pack.Entities.Add(boss); + pack.BossSpawned = true; } + if (galaxyZone == galaxy.Exit && !pack.BossSpawned) + itemManager.Log("Galaxy.Exit has no boss: Galaxy.BossZones does not map a boss faction to the exit zone, or its boss could not be generated (ZoneGenerator exit-without-boss). The exit gate stays sealed."); + if (!ambient) return pack; var enemyCount = (int)(random.NextFloat() * factionPresence * 2) + baseStationCount; diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index b910f768b..b67d8c461 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -164,6 +164,52 @@ public sealed partial class RunStartTests Assert.True(RunGoal.ExitOpen(exit)); } + // The gate fails closed. The main galaxy puts its bosses on choke points, not at its exit, so its exit zone never gets + // one: the gate stays sealed on arrival and when every boss in the galaxy dies, because a boss that never existed is + // not a dead one. + [Fact] + public void Exit_sealed_where_no_boss_was_spawned_main_galaxy() + { + foreach (var seed in new uint[] { 1, 2, 3, 4, 5, 6, 7, 8 }) + { + var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => seed)); + var exit = ZoneOf(galaxy, galaxy.Exit); + Assert.False(RunGoal.ExitOpen(exit), $"seed {seed}: the gate is open on arrival"); + foreach (var boss in exit.Entities.Where(entity => entity.IsBoss).ToArray()) + boss.HeatstrokeDeath.OnNext(Unit.Default); + Assert.False(RunGoal.ExitOpen(exit), $"seed {seed}: the gate is open with no boss spawned"); + } + } + + // The same for a demo exit whose boss could not be generated: the zone logs the gap by name and the gate stays sealed. + [Fact] + public void Exit_sealed_and_logged_when_the_boss_cannot_be_generated() + { + AddPirates(); + var galaxy = DemoGalaxy(1); + galaxy.BossZones.Clear(); + var log = new List(); + var pack = BossZonePack(galaxy, LoggingItems(log), galaxy.Exit); + Assert.False(pack.BossSpawned); + Assert.Contains(log, line => line.Contains("Galaxy.Exit") && line.Contains("exit-without-boss")); + var exit = new Zone(_items, _planetSettings, pack, galaxy.Exit, galaxy); + Assert.False(RunGoal.ExitOpen(exit)); + } + + // A boss killed before the save stays dead after it, and the gate stays open: the spawn fact travels with the zone. + [Fact] + public void Exit_open_after_save_of_a_slain_boss() + { + AddPirates(); + var galaxy = DemoGalaxy(1); + var exit = ZoneOf(galaxy, galaxy.Exit); + Assert.Single(exit.Entities, entity => entity.IsBoss).HeatstrokeDeath.OnNext(Unit.Default); + var repacked = exit.PackZone(); + Assert.True(repacked.BossSpawned); + Assert.DoesNotContain(repacked.Entities, entity => entity.Boss); + Assert.True(RunGoal.ExitOpen(new Zone(_items, _planetSettings, repacked, galaxy.Exit, galaxy))); + } + // The gate shares no point with an adjacency wormhole: every one is farther from it than a ship's wormhole reach. [Fact] public void Exit_gate_shares_no_point_with_a_wormhole() From 2a5fc00bcd127b48c4e079b18b1585a3ffa47ff9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 06:49:39 +0200 Subject: [PATCH 17/17] Boss gate tests: the no-boss exit zone is unpacked with the items that generated it Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index b67d8c461..abd660976 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -189,10 +189,11 @@ public sealed partial class RunStartTests var galaxy = DemoGalaxy(1); galaxy.BossZones.Clear(); var log = new List(); - var pack = BossZonePack(galaxy, LoggingItems(log), galaxy.Exit); + var items = LoggingItems(log); + var pack = BossZonePack(galaxy, items, galaxy.Exit); Assert.False(pack.BossSpawned); Assert.Contains(log, line => line.Contains("Galaxy.Exit") && line.Contains("exit-without-boss")); - var exit = new Zone(_items, _planetSettings, pack, galaxy.Exit, galaxy); + var exit = new Zone(items, _planetSettings, pack, galaxy.Exit, galaxy); Assert.False(RunGoal.ExitOpen(exit)); }