From dd0253c45ee06acb0179c0c3c6db17faf03fde59 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 19:32:02 +0200 Subject: [PATCH 01/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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)); } From bd46f942bf56080edcd1b4849718b8f43edb6e11 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 14:09:22 +0200 Subject: [PATCH 18/23] Boss-gate tests: RunSave.Capture gained a credits parameter on master Adapted the two boss-gate/demo-galaxy Capture call sites to pass 0 credits, the value master's other tests pass when credits are not under test. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/DemoTerminusTests.cs | 2 +- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs index 3b41c16e0..577296ac3 100644 --- a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs +++ b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs @@ -64,7 +64,7 @@ public sealed partial class RunStartTests 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]); + var (game, zones) = RunSave.Capture(_cache, galaxy, arena, staged.Player, new SavedActionBarBinding[0], 0); RunSave.Commit(_cache, game, zones, _items.Lots); var restored = new Galaxy(_cache, _cache.GetGlobal(), _ => { }); diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index abd660976..da7ff39ba 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -151,7 +151,7 @@ public sealed partial class RunStartTests 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]); + var (game, zones) = RunSave.Capture(_cache, galaxy, arena, staged.Player, new SavedActionBarBinding[0], 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); From 658e25054be0e16b1ec04b2bcaed2f94d87db7db Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 15:38:15 +0200 Subject: [PATCH 19/23] Map: demo hulls, the Pirates record and the derived boss hull Folds the demo-hulls and emoji-pirates (Pirates record r2, several package products per hull) addenda into Demo scope: body facts H1-H11, the Pirates record and product tables, the boss-hull rationale and the model page rows. Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-release-map.md | 162 +++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index 4e0ce300e..78c0d0223 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -377,6 +377,168 @@ Counts: demo 52 (31 specs, 11 of them landed, and 21 follow-ups); full game 34 ( 25 follow-ups); obsolete 2 follow-ups. No in-force spec lost its purpose in both scopes, so none was withdrawn. +### Demo hulls, the Pirates and the boss + +Anchors are against `origin/master` `4d664b53`, unless marked `@2a5fc00b`, the tip of the +unmerged `eureka/aetheria-release-boss-gate` branch (which contains demo-galaxy). Read by +`cut-pirates-record`, `cut-package-product`, `cut-boss-hull-derived` and `cut-demo-cast-hulls`; +question `pirates-record-content` ruled the record's content (see "The Pirates record" below). + +- **H1. The catalog at `4d664b53` still has no Pirates, and no faction has a boss hull.** Probe: + `git show 4d664b53:GameData/Aetheria.cc | git lfs smudge`, decoded with Python `msgpack` + using the store's own schema table. Result: 12 `aetheria.faction` records, the same twelve + as D6, with no Pirates. `BossHull` (slot 10) is the empty key `''` on all twelve. Each + faction's `Allegiance` holds 11 entries, one for every other faction. Influence runs from + 2 to 6. There are 12 `NameFile` records: Australia, Congo, Germany, India, Iran, Japan, + Mexico, Russia, Swahili, Thailand, USA and pleiades. There are 5 hulls: LonginusX, Longinus + and Djinni (`HullType` 0, ship), Zenith (1, station) and Turret (2, turret). Their prices + are 7.5M, 7.5M, 10M, 10M and 0.5M. There are 64 products. The catalog changed once after + `df7c44f2`: `3d77f2e0` (evasion-catalog tracking). +- **H2. "Pirates" appears only on the unmerged demo branches.** `git grep -i pirate` over + `*.cs` at `4d664b53` returns nothing. At `@2a5fc00b`, `DemoTerminus.cs:10` sets + `ProtagonistFaction = "Pirates"`, which `Galaxy.ResolveFaction` resolves by name prefix. + The test partial `RunStartTests` copies `GameData/Aetheria.cc` into a scratch directory + (`RunStartTests.cs:36-46 @2a5fc00b`). `AddPirates()` (`DemoTerminusTests.cs:20-28 + @2a5fc00b`) upserts a stand-in Pirates record whose allegiance covers every faction. It is + called 21 times: DemoTerminusTests 9, RunGoalTests 10, ScenarioTests 2. Test + `DemoTerminus_missing_cast_refused` (`DemoTerminusTests.cs:86-95 @2a5fc00b`) asserts that + the shipped catalog has no Pirates. Once the record lands, both the stand-in and that + assertion are wrong: the stand-in duplicates the record and the assertion fails. +- **H3. demo-galaxy and boss-gate are held but unmerged.** `boss-gate` contains + `demo-galaxy`; its tip is `2a5fc00b`, 17 commits ahead of master and 94 behind. Verdicts + `cut-demo-galaxy.s4` and `cut-boss-gate.s2` hold. Finding + `cut-boss-gate.s1.boss-hull-is-a-strangers` records that the demo cannot be generated on + the bare catalog, because the Pirates record is missing. +- **H4. A package holds exactly two records, and no product.** `ShipAuthoringStore.LoadRecords` + (`ShipAuthoring.cs:155-167`) refuses any count other than one hull and one visual. + `ship_cc.py:85-86` refuses any file that does not hold exactly two envelopes. + `ShipModCatalog.Compose` (`ShipModCatalog.cs:48-133`) writes only those two records + (`:113-115`). Generation, station stock and the boss all choose from + `FactionProductData` (M5). A package hull is therefore reachable only through the + console's `give` command. Ruling `package-carries-product` is not implemented on master. +- **H5. Mod keys.** `IsModKey` (`ShipModCatalog.cs:25-26`) recognises `mod-hull:` and + `mod-ship:`. Its comment states the rule that the shipped catalog never references a mod + key, because a mod may be uninstalled. A shipped `Faction.BossHull` therefore cannot name + a package hull. +- **H6. Package hulls and top speed.** `ship-authoring create` without `--like` + (`ShipAuthoringCommands.cs:45-66`) writes a hull with no behaviours and `Price` 0. + `HullLike` copies the reference's members, `VelocityLimit` and `Price` included + (`ShipAuthoring.cs:91-106`). `Validate` (`ShipAuthoring.cs:186-293`) has no rule about top + speed or price. This is R6, still true. Generation silently skips price-0 products (M5). +- **H7. Where boss and hull type are read.** At `@2a5fc00b`: + - `LoadoutGenerator.GenerateBossLoadout` (`:178-196`) uses `Faction.BossHull` when it is + set and sold. Otherwise it falls back to the priciest ship hull on offer. + - `RandomHull` (`:131-136`) is the one hull chooser. `GenerateShipLoadout` reaches it at + `:37`. + - The station stock filter (`:112`) admits ship hulls only. + - `Galaxy.PlaceFactionsMain` picks its boss factions by `BossHull.IsSet()` (`:296-299`). + - AetherDb's `factions` command prints `BossHull` (`Program.cs:662-672`), and `dangling` + mentions it (`:544`). + + No other reader exists. `HullType` is `Ship | Station | Turret` (`Enums.cs:140-145`). + Thrusters carry `EntityTypeRestriction(HullType.Ship)`, so a boss must be a `Ship`, not a + new `HullType`. +- **H8. HullData's free key.** Keys in use: ItemData 1, 2, 4-8; CraftedItemData 9; + EquippableItemData 10-16, 18-22, 30, 31; HullData 23-29, 32. Key 33 is free on + `HullData`. evasion-term's key 33 is on `WeaponData`, a sibling type. +- **H9. Emoji in names.** The TextMesh Pro default sprite asset + (`Assets/TextMesh Pro/Sprites/EmojiOne.json`) holds 16 face glyphs (1f600-1f923, 263a, + 2639) and no others. The fire emoji in the Pirate Coalition's equipment copy would not + render without a new emoji asset (see "Full emoji"). +- **H10. Pirates lore that the record reads from.** Sources: + - The vault note `Worldbuilding/Pre-Elysium/Factions/Powers/Major/Pirate Coalition.md`: + a loose network, no central command. + - `Brainstorming/Faction Flavor and Visual Identity.md:526-557`. Its points: stolen and + adapted equipment gives the faction its range rather than an exclusive technology; + products are named in emoji; hulls show several surviving finishes interrupted by primer, + with one crew accent colour. + - `Game Design/Faction Play.md:395` and `:416`: pirates buy everyone's gear. + - Corporate Roster: the pirate name is rolled per run. The demo's record is fixed. +- **H11. Packages in git and in builds.** At `4d664b53`, `.gitattributes` has no `*.glb` + rule, and `GameData/*.cc` does not match `GameData/Mods//ship.cc` (M11 still true). + There is no player build entry point; `ships-player` owns staging. `ActionGameManager` + composes from `GameData/Mods` (`:49`, `:63`), so editor play reads packages from the repo. + +#### The Pirates record + +Draft for question `pirates-record-content`. + +| Field | Draft | Why | +|---|---|---| +| Name, ShortName | `Pirates`, `Pirates` | DemoTerminus's cast field and ruling `demo-cast` both say "Pirates" | +| Description | "A loose coalition of crews and hidden docks moving people and goods outside official control. No command, no common law: possession has a maintenance schedule." | Vault note; Faction Play's operating logic | +| PrimaryColor | (0.42, 0.40, 0.37), primer grey | "finishes interrupted by primer" | +| SecondaryColor | (1.00, 0.45, 0.05), the crew's accent colour | "a crew's chosen accent colour" | +| GeonameFile | `pleiades` | Star names suit hidden docks; it is not one of the cast's files | +| InfluenceDistance | 3 | Scattered bases, no continuous territory | +| Allegiance | 1.0 to each of the 12 other factions; no other faction's allegiance changes | Pirates buy everyone's gear. Only the Pirates field Pirates-made products, including their hull | +| Personality, music, BossHull | Empty or unset | Doctrine arrives with faction-play r2; the Pirates are not a boss | + +Products: stolen-and-adapted rebrands over other makers' designs. The Pirates own no design; +their range is what they took (Faction Flavor `:532`). Each product's roles: one ProductRole per +role the design declares, mean .45, sd .22 (middling and inconsistent, against makers' .15). +Names are emoji; the text gloss is for the operator's review and is not stored. + +| Product name | Gloss | Design (current maker) | Description | +|---|---|---|---| +| 🫳🔫 | finders keepers | Autocannon (Zhestokost) | "Serial number filed off. Still shoots." | +| 🪦🤠 | second owner | Earp (Aeronautics Unlimited) | "The previous owner had no further use for it." | +| 🧾🚫 | no receipt | FastBlast+- (NiteLife Energy) | "Warranty void where prohibited, which is everywhere." | +| 🎨🙈 | overpainted | 6k Shooter (Zhestokost) | "The logo underneath is somebody else's problem." | +| 🔥 | the fire | scorched void policy (Death Monkey Explosives) | "Turns out we did start the fire after all." (the roster's line, `Corporate Roster and Item Wishlist.md:85`) | +| 👀🎯 | borrowed eyes | Targeting Computer (generic) | "Somebody else's eyes. They still work." | +| 🔑🏃💨 | hotwired | Small Drive (Lightsail Express) | "Hotwired. Do not ask about the ignition." | +| 📦🤫 | not as declared | Store-All Plus (Lightsail Express) | "Contents not as declared." | + +None is on a Miss Terri's design. Each (Pirates, design) pair is new, so `Brand` stays +unambiguous (CG4). The fire product moves to the flamethrower design if follow-up +`pirates-flamethrower` lands one. + +The record's data does not depend on the emoji font: the simulation owns facts and the font is a +presenter (invariant `sim-owns-facts`). So the record waits on boss-gate (H2) and not on the emoji +cuts, and the demo's Pirates (finding `boss-hull-is-a-strangers`) are not held behind a Unity +presentation cut. Until the emoji font lands, editor play shows the names as missing-glyph boxes. + +#### The boss hull is derived, not named + +Follow-up `zhestokost-boss-hull` left this map to choose between two options. One was an +AetherDb command that names the package hull from `Faction.BossHull`. The other was a package +that declares which faction it is the boss of. The first is ruled out by H5: the shipped +catalog would name a mod key, and uninstalling the package would leave the reference +dangling. + +The second option, built from parts that already exist: +- `HullData.Boss` (key 33, bool) marks a hull that only bosses fly. +- The package's own products name the faction as manufacturer (`package-carries-product`). +- A faction's boss hull is then the `Boss` hull it manufactures. +- `Faction.BossHull` has no writer in the catalog (H1), so it is deleted. Key 10 is retired, + as `ShipAuthoring` key 1 was. + +Starsector is the prior art: a mod puts its ships into an existing faction by shipping a +faction file that the engine merges. The package therefore carries its own availability (map +"Why first-party hulls through the package path"). + +The consequence for the product: a `Boss` hull is never station stock, never ordinary traffic, +and never offered by `RandomHull` unless the caller asks for a boss. That keeps the boss +unique. If a faction makes no `Boss` hull, boss-gate's logged fallback stands. + +**Why top speed is refused at `Validate`.** `Validate` is the one owner of top speed (follow-up +`package-hull-top-speed`): compose then excludes a speedless package with its name, and the +dock intro never sees one. Handling the absence at the intro was rejected because it would let +a hull fly with no cap. + +Ruling `demo-content-bar` also needs NPC role hulls (haulers, tenders, miners) and a dockable +station; follow-up `demo-role-hulls` owns them. + +### Model page rows + +| Kind | What names it | Over time | Who decides | +|---|---|---|---| +| Pirates `Faction` | Catalog record, name `Pirates` | Authored once by `AetherDb pirates-faction apply`; doctrine is added by faction-play r2 | Ruling `pirates-record-content`; the operator sees the dry run | +| Package product | `mod-product::` in the package's `ship.cc` | Written by `AetherDb ship-authoring product`; copied verbatim by compose; Blender never edits it | The package author | +| Boss hull | `HullData.Boss` (key 33) | Set by `ship-authoring boss`; a faction's boss hulls are derived per generation from the products it makes | The package author; `BossHullsOf` derives | +| `Faction.BossHull` (key 10) | Retired | Deleted by `boss-hull-derived` | none | + ## Faction play: the NPC scripting The operator ruled on 2026-10-03 that the faction dynamics may elaborate the NPC From 4a38634e53c8a53e8cf418ecc146b1b56d17bcc9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 15:38:58 +0200 Subject: [PATCH 20/23] Map: full emoji body facts and model page rows Folds the emoji section of the emoji-pirates addendum into Demo scope: TextMesh Pro probes E1-E10, the font-asset rationale and rejected alternatives, and the emoji and product model page rows. Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-release-map.md | 114 +++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index 78c0d0223..c0338c296 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -530,6 +530,113 @@ a hull fly with no cap. Ruling `demo-content-bar` also needs NPC role hulls (haulers, tenders, miners) and a dockable station; follow-up `demo-role-hulls` owns them. +### Full emoji + +Anchors are against `origin/master` `4d664b53`. Read by `cut-emoji-font` and `cut-emoji-sequences`; +question `emoji-art-set` asks which pinned font input and fetch step the font build uses. +Rulings this rests on: `pirates-record-content` (emoji-names) and the operator's "I totally want +full emoji support". + +- **E1. TextMesh Pro is the one inside `com.unity.ugui` 2.0.0, on Unity 6000.3.24f1.** + `ProjectSettings/ProjectVersion.txt`; `Packages/manifest.json:19` (`"com.unity.ugui": "2.0.0"`); + there is no separate `com.unity.textmeshpro` entry. Source read from + `F:\Projects\Aetheria\Library\PackageCache\com.unity.ugui@b996e7548785\Runtime\TMP` (read only). +- **E2. TMP Settings today** (`Assets/TextMesh Pro/Resources/TMP Settings.asset`, an LFS file): + `m_ActiveFontFeatures: 00000000` (:18), `m_fallbackFontAssets: []` (:36), + `m_defaultSpriteAsset` = EmojiOne (`c41005c1…`, :39-40), `m_enableEmojiSupport: 1` (:42), + `m_EmojiFallbackTextAssets: []` (:44). Default font asset is LiberationSans SDF (:27). +- **E3. EmojiOne is a 16-face sample and nothing uses it.** `Assets/TextMesh Pro/Sprites/EmojiOne.json` + holds 16 faces (H9). `git grep "`), so a sprite asset cannot match a ZWJ, + skin-tone, flag or keycap sequence. Sequences resolve only through a font asset's GSUB + ligature records. + - Ligatures: `TextMeshProUGUI.cs:2050-2118`. Gated by `bool ligature = + m_ActiveFontFeatures.Contains(OTL_FeatureTag.liga)` (`:1824`), per component. Matching skips + ZWJ and VS1-VS16 when they are not the expected component + (`TMP_TextParsingUtilities.IsIgnorableForLigature`, `:172-178`), consumes a trailing variation + selector, and collapses the span into one glyph. The ligature table read is the current font + asset's, which is the emoji font when the fallback found the first code point (`:2021-2023`). + - Records come from `FontEngine.GetLigatureSubstitutionRecords` as glyphs are added to a dynamic + font asset (`TMP_FontAsset.cs:3097-3131`). + - Colour glyphs: a font asset in `GlyphRenderMode.COLOR` gets an RGBA32 atlas and the + `TextMeshPro/Sprite` shader (`TMP_FontAsset.cs:606-625`); its vertices are forced white, so a + `` tag around an emoji does not tint it (`TMP_Text.cs:5376-5385`). + - `TMP_Text.OnMissingCharacter` (`TMP_Text.cs:1389`) is a static event raised for every + missing code point: the smoke's probe. +- **E5. No text component has ligatures on.** 154 TMP components in 50 files (48 prefabs, 2 + scenes), none legacy `UnityEngine.UI.Text`. Serialized feature lists: 24 x `00000000` (legacy: + converted on load to `[kern]` when `m_enableKerning`, `TMP_Text.cs:6082-6088`) and 38 x + `6e72656b` (`kern`), all in the two scenes (`ARPG.unity` 64 lines with the field, + `FieldShieldTest.unity` 1; `grep -rc "m_ActiveFontFeatures: 6e72656b$"` over prefabs and + scenes = 38). The 48 prefabs' components have no field and take the same legacy path. One component is + created at runtime (`ActionGameManager.cs:301`, the FACE AIM mark). +- **E6. Turning `liga` on changes no Latin text.** All ten project font assets (`Assets/Fonts/*/*.asset`, + LiberationSans SDF) are static (`m_AtlasPopulationMode: 0`) with zero ligature records. Only + the emoji font would supply ligatures. +- **E7. LFS.** `.gitattributes:33` `*.ttf` and `:50` `*.asset` are LFS; TMP Settings.asset, + EmojiOne.asset and EmojiOne.png are LFS objects today. +- **E8. Editor batch pattern.** `Assets/Scripts/Editor/EngineAssetCheck.cs:35-50`: a MenuItem plus + `-executeMethod EngineAssetCheck.Run`, exit 1 on failure in batchmode. The emoji builder and + smoke follow it. +- **E9. Prior art (web, 2026-10-08).** + - Unity's documented path for colour emoji is a colour font asset in TMP Settings' Emoji + Fallback Text Assets (TMP 3.2/4.0 manual "Color emojis"; Unity 6 manual + `UIE-color-emojis`). Unsupported: SVG colour glyphs, AAT fonts, COLRv1 (the manual names + "Noto Color Emoji" as the COLRv1 case), and chain-context and single-substitution features. + Ligature substitution (GSUB type 4), which carries ZWJ, skin-tone and flag sequences, is + supported. COLRv0 is supported (the manual's Android note). + - Unity forum thread 932305: the CBDT `NotoColorEmoji.ttf` from the googlefonts repo renders + through a Color font asset; the Google Fonts download (COLRv1) does not. + - Sets: Twemoji (`jdecked/twemoji`, community fork, Emoji 17.0, graphics CC-BY 4.0; distro + packages build COLRv0/COLRv1/CBDT TTFs from its SVGs with `nanoemoji`, version 17.0.2). + Noto Emoji (`googlefonts/noto-emoji`: fonts OFL 1.1, images Apache 2.0; the prebuilt + `NotoColorEmoji.ttf` is CBDT, bitmaps from `png/128`). OpenMoji (CC-BY-SA 4.0; COLRv0 font + shipped; outlined line-art). + - Not verified here: the exact jdecked release assets (its release page lists two source + archives and no TTF), so a Twemoji TTF is built, not downloaded. +- **E10. Visual identity.** AetheriaLore `Brainstorming/Faction Flavor and Visual Identity.md:532, + 541, 548`: Pirates' products are named in emoji, a crew vocabulary; hulls carry "large emoji + marks". `Ship Design Language.md:222-226`: one large emoji crew emblem painted three times. + `Corporate Roster and Item Wishlist.md:85`: "Named entirely in emoji. The flamethrower is" the + fire emoji. `Game Design/Visual and Sensory Direction.md` says nothing about an emoji art set; + its interface register is "should feel like equipment" (:43). + +**Why one colour emoji font asset.** One owner: a colour emoji font asset generated from a pinned +font file, set as TMP's emoji fallback and appended to TMP's global fallback list. Every TMP text +renders emoji through it with no per-component wiring, because TMP itself routes emoji code points +there (E4). + +Rejected: a generated TMP sprite asset (the brief's first guess). Sprite lookup is one code point +per sprite (E4), so full emoji would also need a text preprocessor that rewrites every sequence to +a private-use code point, installed on every one of 154 components and every runtime text. That is +more code doing what TMP's font path already does. + +Rejected: a runtime-built font asset (`TMP_FontAsset.CreateFontAsset` at boot). It needs +`Shader.Find("TextMeshPro/Sprite")` in a player (`TMP_FontAsset.cs:625`), which a build strips +unless the shader is always-included; an editor-built asset references its material and shader. + +Sequences need `liga` on the rendering component (E4, E5). There is no global TMP switch, so the +sequences cut sets it on every serialized component through one editor command and on TMP +Settings' defaults for new components. It changes no Latin text (E6). + +The atlas is dynamic and multi-atlas: glyphs rasterise on first use, so nothing is hand-made and +nothing is pre-baked. `m_ClearDynamicDataOnBuild: 1` (TMP Settings) already clears dynamic atlases +at build. The pinned font file is the only binary input, and its provenance is a lock file +(source, version, sha256 in and out). `emoji-art-set` decides only which pinned input and which +fetch step; the asset, settings, smoke and deletion are the same for every option. The font build +itself (option twemoji: nanoemoji) runs under `py -3` on Yggdrasil, not Starfire; the Unity batch +run is Starfire's. The sequences command touches only the `m_ActiveFontFeatures` line of each of +48 prefabs and 2 scenes, so it wants the freshest master and a quick merge. + ### Model page rows | Kind | What names it | Over time | Who decides | @@ -538,6 +645,13 @@ station; follow-up `demo-role-hulls` owns them. | Package product | `mod-product::` in the package's `ship.cc` | Written by `AetherDb ship-authoring product`; copied verbatim by compose; Blender never edits it | The package author | | Boss hull | `HullData.Boss` (key 33) | Set by `ship-authoring boss`; a faction's boss hulls are derived per generation from the products it makes | The package author; `BossHullsOf` derives | | `Faction.BossHull` (key 10) | Retired | Deleted by `boss-hull-derived` | none | +| Pirates products | 8 `FactionProductData`, emoji names, over other makers' designs | Authored with the faction in one commit | Ruling `pirates-record-content` | +| Package hull price | `HullData.Price` | Written by `ship-authoring price` (or `create --like`) | The package author | +| Emoji font file | `Assets/Fonts/Emoji/.ttf` + `emoji-font.lock` | Rebuilt only by `tools/emoji` at a new pinned version | The operator's `emoji-art-set` ruling | +| Emoji font asset | `Assets/Fonts/Emoji/Emoji.asset` (dynamic, COLOR, multi-atlas) | Regenerated by `EmojiFont.Build`; atlas filled at runtime, cleared at build | `EmojiFont.Build` | +| TMP emoji routing | TMP Settings `m_EmojiFallbackTextAssets` and `m_fallbackFontAssets` | Set by `EmojiFont.Build` | `EmojiFont.Build`; no prefab overrides it | +| Ligatures per text | Each TMP component's `fontFeatures` | `liga` added once by `EmojiFont.EnableLigatures`; new components inherit TMP Settings | `EmojiFont.EnableLigatures`, then the component | +| EmojiOne sample | none | Deleted by `emoji-font` | none | ## Faction play: the NPC scripting From 0e39a7f05888d7fe68b3ef37011993edc28b3f88 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 15:39:50 +0200 Subject: [PATCH 21/23] Map: faction-play re-anchor on 4d664b53 and the prelude retirement Folds the faction-prelude addendum: faction-play probes BS1-3 and FP1-14 under Faction play, prelude probes PR1-11 and their rationale under Demo scope, and the changed model page rows in both sections. Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-release-map.md | 196 ++++++++++++++++++++++++++++++++++- 1 file changed, 194 insertions(+), 2 deletions(-) diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index c0338c296..41ae1c738 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -637,6 +637,92 @@ itself (option twemoji: nanoemoji) runs under `py -3` on Yggdrasil, not Starfire run is Starfire's. The sequences command touches only the `m_ActiveFontFeatures` line of each of 48 prefabs and 2 scenes, so it wants the freshest master and a quick merge. +### Retiring the prelude and the tutorial + +Body: `origin/master` `4d664b53`; "merge tree M" as in the faction-play re-anchor (BS-probes), unless +noted. Read by `cut-retire-prelude` and `cut-gear-without-presence`; question `dev-scenarios-cast` +asks which galaxy the six test scenarios use once the authored cast goes. D1 and "Why the demo is a +prelude galaxy" describe the code before this retirement. + +- **PR1.** `Galaxy.IsPrelude` (`Galaxy.cs:30`) is written by three constructors: the save + constructor from `SavedGame.IsTutorial` (`:53`), the main constructor `false` (`:107`), the + prelude constructor `true` (`:175`). Its readers (grep over Assets, tests, tools): `InfluenceOf` + (`:158-161`, halves influence rounding up), `LoadoutGenerator.IsAvailable` (`:211-214`, every + product on offer), `RunStart.GenerateArena` (`RunStart.cs:29`) and + `ActionGameManager.PopulateLevel` (`ActionGameManager.cs:721-727`) as ZoneGenerator's + `isTutorial`, `RunSave.Capture` (`SavedGame.cs:77`), and tests (`RunGoalTests.cs:22`, + `RunStartTests.cs:146`, `ScenarioTests.cs:137, :146, :554-557`). +- **PR2.** `InfluenceOf` feeds both generation and later zone generation: `Galaxy.cs:214-247` + (cast homes), `:314-324` (main homes), `:342-349` (influence), and `ZoneGenerator.cs:212` + (`factionPresence`, which sets station and enemy counts). A restored galaxy generates unvisited + zones through `PopulateLevel`, so the halving must persist with the run, today through + `SavedGame.IsTutorial` (key 11). +- **PR3.** `ZoneGenerator.GenerateZone(..., bool isTutorial = false, bool ambient = true)` + (`:40-46`): the entrance station floor at `:223-224`, the widened orbit pool at `:238` and + `:246-248`, and the throw at `:327-329` all apply only to `isTutorial && galaxyZone == + galaxy.Entrance`. Source: operator 2026-09-25 "Tutorial station" (`docs/locomotion-cut.md:375`), + scoped to the tutorial level. +- **PR4.** The cast type is `TutorialGenerationSettings` (`Settings.cs:96-106`): five faction + names, LinkDensity, ZoneCount. Readers: `GameSettings.cs:30` (field, plus + `TutorialBackgroundSettings` `:31`), `GalaxyStage.cs:16, :29, :71-74` (`Prelude()` and + `Prelude(cast)`), `RunStart.cs:36-37`, `ScenarioStage.cs:21-35` (read only by + `TutorialGalaxy.cs:13`), `MainMenu.cs:178-179`, `ActionGameManager.cs:848`, + `DemoTerminus.cs:8` (the demo's cast), `tools/AetherDb/Program.cs:489-533` (`settings` and + `settings-dump`). +- **PR5.** `Assets/Resources/Settings.asset` (LFS, smudged at 4d664b53) `:43-52`: the authored cast + is protagonist `Miss`, antagonist `Zhe`, buffer `Luc`, neutrals `Aero` and `Finch`, quest + `Adras`, 64 zones; `TutorialBackgroundSettings` at `:53`. +- **PR6.** Six test scenarios generate `stage.Prelude()`, the authored cast: Arcs, DjinniShakedown, + Duel, LauncherAngles, LongHaul, StarvedReactor (`Scenarios/*.cs`, one line each). + `Scenarios.Development` is `{ TutorialGalaxy, MainGalaxy }` (`Scenarios.cs:8`). Their galaxy + includes Miss Terri's as protagonist. +- **PR7.** The prelude constructor's `playerSettings` and `narrativeDirectory` parameters feed only + the StoryProcessor lines commented out at `Galaxy.cs:258-260` (dead since ce1a0a46, 2021, per + locomotion-cut.md:375). +- **PR8.** `LoadoutGenerator.IsAvailable` (`:211-214`) and `ManufacturerPreference` (`:217-226`): + availability requires `Galaxy.ContainsFaction(manufacturer)` unless the galaxy is a prelude; an + absent manufacturer has no home zone, so `ManufacturerPreference` gives it distance 0, the + highest weight its allegiance allows. `Galaxy.ContainsFaction` (`:453-457`, cache + `_containedFactions` `:36`) has one game reader (IsAvailable); other readers are + `FireControlCut6cTests.cs:253` and the AetherDb `loadout exclude-sellers` mode + (`Program.cs:745-790`), which builds a galaxy without named factions precisely to exercise the + presence gate. `ScenarioStage.cs:29` builds its availability with Faction null, which today means + every present maker. +- **PR9.** `SavedGame` (`SavedGame.cs:9-52`) is an int-keyed MessagePack document + `aetheria.savedgame` v1, keys 0-13; `IsTutorial` is key 11. +- **PR10.** Tests touching the concept (grep -c "tutorial|prelude", case-insensitive): + RestoredHullsTests 65, ScenarioTests 30, DemoTerminusTests 29, RunStartTests 16, MiningCut3Tests + 3, RunGoalTests 1. Most are the `TutorialGalaxySettings()` fixture family in RestoredHullsTests + and the `isTutorial:` argument. +- **PR11.** Open boss-gate finding `test-reads-isprelude` (verdict cut-boss-gate.s1) is + `RunGoalTests.cs:22`; retiring the flag removes it by construction. + +**Why two cuts.** The follow-up asks to absorb gear-availability-without-presence and to keep the +subtraction separate from behaviour. Removing the presence gate changes what every galaxy +generates, so it is its own cut (`gear-without-presence`), landed first; `retire-prelude` then +deletes the flag with no behaviour change except where named. + +**Why `IsPrelude` is split by rule, not renamed.** It bundles four unrelated rules, and each goes +to its owner: +- influence halving -> the cast's own `InfluenceScale` (the demo's cast sets .5, so its galaxy is + unchanged), carried by the Galaxy and the save (key 14) because later zones read it (PR2); +- the entrance station -> `Scenario.EntranceStation`, a declaration beside `Ambient`, default true, + false for MainGalaxy, so every scenario's arena is unchanged; RunStart.GenerateArena applies it; +- availability -> the general rule of gear-without-presence; +- the save flag -> deleted (key 11 retired; ruling `no-save-compatibility-before-players`). + +A renamed flag on Galaxy was rejected: it would keep one switch deciding four things. + +**Absent manufacturers.** Preference weights by distance to the maker's headquarters, and an absent +maker has none. The cut weighs it one jump beyond the farthest zone. Prior art: the gravity model +of trade (Tinbergen 1962; Anderson and van Wincoop 2003) weights flows by distance decay, and a +supplier with no local presence carries the largest trade cost, so it is reachable but least +preferred. Zero distance (today's accident) would make absent makers the most preferred, inverting +the rule. + +**Old saves.** Key 14 absent decodes as 0 and the save constructor refuses a non-positive scale by +name. Per ruling `no-save-compatibility-before-players` nothing reads key 11. + ### Model page rows | Kind | What names it | Over time | Who decides | @@ -652,6 +738,11 @@ run is Starfire's. The sequences command touches only the `m_ActiveFontFeatures` | TMP emoji routing | TMP Settings `m_EmojiFallbackTextAssets` and `m_fallbackFontAssets` | Set by `EmojiFont.Build` | `EmojiFont.Build`; no prefab overrides it | | Ligatures per text | Each TMP component's `fontFeatures` | `liga` added once by `EmojiFont.EnableLigatures`; new components inherit TMP Settings | `EmojiFont.EnableLigatures`, then the component | | EmojiOne sample | none | Deleted by `emoji-font` | none | +| Galaxy cast (replaces `TutorialGenerationSettings`) | A scenario's code constant (`DemoTerminus.Cast`) | Compiled with the game; no authored settings copy | The scenario | +| `Galaxy.InfluenceScale` | Its galaxy | Set by the constructor (the cast's value, 1 for main), saved as `SavedGame` key 14 | The cast | +| `Scenario.EntranceStation` | Its scenario | Compiled | The scenario; `RunStart` applies it | +| `SavedGame` key 11 (`IsTutorial`) | Retired | Not read, not written | none | +| `Galaxy.IsPrelude`, `TutorialGalaxy`, `TutorialGenerationSettings`, `GameSettings.TutorialGenerationSettings` | Deleted by `retire-prelude` | | none | ## Faction play: the NPC scripting @@ -918,8 +1009,9 @@ and builds on `Zone.Release` from `loot-1`. | Kind | Identity | Lifecycle | Authority | |---|---|---|---| -| Doctrine | `Faction.Doctrine`, catalog, key 17. | Authored through an `AetherDb` catalog command, as the targeting migrations were. | The catalog author. Read when a ship joins a flight. | -| Flight | Runtime: zone plus faction. | Formed at admission, rebuilt on load, gone with the zone. Never persisted. | Itself alone, for members' `Target`, `Task`, hails and pings. | +| Doctrine | `Faction.Doctrine`, catalog, key 17. | Authored through an `AetherDb` catalog command, as the targeting migrations were. Null reads as the default doctrine built from `GameplaySettings`. | The catalog author. Read when a ship joins a flight. | +| Agent (`Zone.Agents`) | Its ship. | Created by `Zone.Admit`, removed on the `Entities` removal path (it was never removed before). | `Zone`. | +| Flight | Runtime: zone plus faction. | Created on first join, rebuilt on load; members leave on removal or death, and it goes with the zone. Never persisted. | Itself alone, for members' `Target`, `Task`, hails and pings. | | Track (flight picture) | Runtime: flight plus hostile entity. | Lives while any member sees it, or a sharer lends it. | The flight, from members' perception. Never written into `EntityInfoGathered`. | | Duel | Runtime: flight plus challenger. | Offered, then held or broken. Ends when the challenger leaves the picture or dies. | The flight, from landed hits with their source. | | Ammunition baseline | Runtime: member. | Counted at admission. | The flight. Rearm moves real cargo through `TryTransferItems`. | @@ -980,6 +1072,106 @@ four wait for `codex/mining` (it changes `Target`'s type and edits `Minion.cs`) `codex/scenarios` (it rewrites admission in `Zone.cs`) to merge, because the first cut rewrites exactly those lines. +### Re-anchor on master 4d664b53 (2026-10-08) + +Body: `origin/master` `4d664b537f76029fbaee94392da3d8a122f81339`, read in a detached scratch +worktree. Where a probe says "merge tree M", it read `git merge-tree --write-tree origin/master +origin/eureka/aetheria-release-boss-gate` = tree `8387b47214ef6e44cae28a4e6a2b9b91d51464d9` +(boss-gate `2a5fc00b` contains demo-galaxy `85d0a148`; the merge is clean, no conflicts) with +`git show :`. No commit was made. Read by the faction-play cuts, r2 and later. + +- **BS1. Branch state.** `git merge-base --is-ancestor`: `codex/mining`, `codex/scenarios`, + `eureka/aetheria-release-mining` and `-scenarios` are ancestors of master. + `eureka/aetheria-release-demo-galaxy` (85d0a148) and `-boss-gate` (2a5fc00b) are NOT. + demo-galaxy's merge base with master is 228f241e; master is 94 commits ahead of it. + `-retire-longinusx` on origin equals master (4d664b53): its Hands had not pushed. +- **BS2.** `git merge-tree --write-tree --name-only origin/master `: demo-galaxy and + boss-gate each merge into master with no conflicting path (trees f3f69e84 and 8387b472). +- **BS3.** feedback-1, ballistic-ammo, controls-mount-aim, controls-ai-bearing, autofire-threshold + and ai-autofire had in-force specs and no report (specs-with-no-report query, as of ordinal 753). + `CapabilityEvents.PublishAbsorb` has no caller in `Assets/Scripts/ServerShared` (grep): + feedback-1's absorb publish sites do not exist yet. +- **FP1.** `Minion.cs:7-22`: constructor `Minion(Ship ship)`; `:14` is the one VisibleEnemies + subscription that writes `Ship.SetTarget(add.Value)` when `Target.Value.IsNone`; combat + transitions read `Ship.Target.Value.Entity` (`:18`, `:21`). +- **FP2.** `Zone.cs`: `Agents` `:40`; `Admit` `:167-172` is the only `Agents.Add` in Assets (grep) + and the comment names it the one admission; `CreateAgent` `:174-181` draws the circuit at `:178` + and assigns `agent.Task` at `:179`; the agent loop `:221-222`; the only Entities subscription is + the death subscription at `:107` (ObserveAdd -> Death -> Remove + Deactivate). +- **FP3. Nothing ever removes an agent.** `Agents.Remove`/`Clear` appear nowhere in Assets or tests + (grep). A dead or departed ship's Minion keeps running every tick. Removal from `Zone.Entities` + happens at `Zone.cs:107` (death), `Entity.cs:1268` (docking) and `ActionGameManager.cs:725` (the + player's zone transfer). +- **FP4.** `Entity.cs`: `_iffOverrides` `:53`; `Target` `:60` (TargetRef, one writer `SetTarget` + `:358`); `SetIff` `:811-815`; `IsHostileTo` `:817-836`; `GetFactionRelationship` `:838-845` reads + `Galaxy.FactionRelationships` only for the player's ship, so NPC-vs-NPC hostility is still only + trespass in an owned zone plus reciprocity ("TODO: Inter-faction hostility"). Consequence for the + demo: the allied Pirates and the antagonist Zhestokost do not fight each other; the Pirates' + doctrine is seen only against trespassers in Pirates zones. Relations (key 16) remain unmapped. +- **FP5.** `FireControl.cs:104-112` `AgentFires(weapon, shooter, target)` reads + `GameplaySettings.AgentMinHitProbability` at `:109`. Callers: `Combat.cs:121` and + `TurretController.cs:83` (grep). `Combat.cs:159` reads `AgentRangeExponent`; `_optimumRange` + `:13`. `Settings.cs:251` and `:274` hold the two globals. Ten FireControl test fixtures set + `AgentMinHitProbability = 0f` in their settings. +- **FP6.** `Agent.cs`: `Task` `:22`, `Settings` `:25`, constructor `:28-36`, `Update` `:47-59` zeroes + `Ship.Turn` each tick (controls-helm), `Accelerate` `:61-84`. `TaskType` `AgentTask.cs:24-33` has + no Follow or Flee. States live in `Agents/States` (BaseState, Combat, MoveTo); `PatrolOrbitsState` + is in `Agents/Tasks/PatrolOrbits.cs:10`. +- **FP7.** `Corporations.cs`: Faction keys run to `BossMusic` Key(15) at `:58-59`; keys 16 and 17 + are free. +- **FP8.** `tools/AetherDb/Program.cs` command switch `:23-40`; `TargetingCatalog(bool apply)` at + `:1096` is the dry-run/apply precedent. +- **FP9.** Tests that construct `new Minion(ship)` directly: `FireControlPerWeaponTests.cs:276, + :291, :580, :597` and `FireControlTests.cs:214`; `RunStartTests.cs:100` and `SafeAssert.cs:44` + read `zone.Agents`. +- **FP10.** `LoadoutGenerator.cs`: `GenerateShipLoadout(Predicate)` `:35-50`, + `EquipHardpoints` `:220-263`, `FillInterior` `:265`. `ZoneGenerator.cs`: faction ships + `:342-348`, wanderers `:350-364`, `return pack` `:366`. boss-gate adds a boss block to both files + (+38 and +18 lines), so anchors in them move by symbol after it merges. +- **FP11.** `Entity.TryTransferItems(Entity, CultRecordKey, int)` at `Entity.cs:977`. No + `GeneratedMagazines` or `AmmoType` exists in `LoadoutGenerator.cs` or `Settings.cs`: + ballistic-ammo has not landed. +- **FP12.** `Entity.Sensor` `:142`; `Sensor.Ping()` `Sensor.cs:110`. +- **FP13.** Catalog `GameData/Aetheria.cc` at 4d664b53 (LFS, smudged in the worktree): the string + "Pirates" occurs only inside geoname lists, no Faction record (H1). Follow-up + `pirates-catalog-record` is the owner. +- **FP14.** Lore source for the Pirates' doctrine: AetheriaLore `Aetheria/Game Design/Faction + Play.md` section "Pirate Coalition" (`:333-338`) and the summary row `:386`: crews want cargo, + not death; demand a drop, hit drives, leave fights they are losing. Of these, the flight + primitives express the demand (hail and grace) and leaving a losing fight (break-off). Demanding + a drop as compliance and aiming for drives need jettison (map item 10) and subsystem aim, which + no cut builds yet. + +**What changed from the first anchor.** The first faction-play pass anchored on `f1dee184` and +waited for the mining and scenarios lanes. Both are merged (BS1), so the later specs branch from +master and anchor on 4d664b53. Target is a TargetRef written only through `SetTarget`, and +`Zone.Admit` is the only creator of agents, as the follow-up says. + +Agents are also removed on entity removal (FP3). A flight whose membership follows removal while +the agent list does not would leave two answers to "who is in this zone"; one subscription now +decides both. + +The Pirates' doctrine is authored in the doctrine-catalog command's table. While the catalog lacks +the Pirates record (FP13), the command names the missing faction and writes the rest; once the +record lands, re-running apply writes it. No doctrine, barks or levers for Miss Terri's. + +The played proof sits in the DemoTerminus scenario with the demo cast, because the third cut +completes the behaviours the demo cast shows (Zhestokost's tender column, Lucent's duel, AU's +haulers); the fourth keeps the tender loop as full-game scope with no proof. + +The ping cadence asks `Sensor.Ping()` and nothing more. Follow-up `sensor-stat-set-cut` is a +separate, larger mapping (per-sensor power, beam width, passive gear, shared tracks) and was not +mapped here; whatever Ping becomes, the flight only decides when to ask. + +Key layout on Faction: Relations 16 (reserved, ruling `faction-relations-field`), Doctrine 17 +(ruling `faction-doctrine-typed`). ships-hull-livery's Livery at 16 must move to 18 (follow-up +`livery-key-collision`, owner ships-hull-livery's next revision). + +Collision: `ai-autofire` rewrites Combat's and TurretController's fire decision "at the agent +threshold". After faction-play-1 the agent threshold is the member's +`RoleDoctrine.MinHitProbability` (turrets keep the global). Whichever lands second re-anchors; the +next ai-autofire revision should read `_agent.Doctrine.MinHitProbability`. + ### Prior art - Damián Isla, "Handling Complexity in the Halo 2 AI", GDC 2005 From c8376c354e3a094e1668472f78041658afa5deb0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 15:40:53 +0200 Subject: [PATCH 22/23] Map: munition evasion core, mount term and autofire seams Folds the munitions-autofire addendum into Missiles and point defense: probes MA1-12, the munition Track and autofire-subject model page row changes, and the rationale for the evasion core, the split cuts and the close-crosser divert test. Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-release-map.md | 153 ++++++++++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 2 deletions(-) diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index 41ae1c738..15e9d9992 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -4518,6 +4518,93 @@ pass of 2026-10-07, source marks as in Controls; the EVE formula is the player w today, and any larger shape fits no hull, like CShot. Nothing in the record settles its intended shape; question `plight-shape` asks it. +### Body facts: evasion core, mount term and autofire seams (2026-10-08) + +Body: `origin/master` `4d664b53`, read in a detached worktree. The in-flight branch +`origin/eureka/aetheria-release-controls-mount-aim` (e103b90a, 538b11b4) was read with `git diff +4d664b53 `, never checked out. Read by `cut-munition-shots`, `cut-munition-jink`, +`cut-autofire-threshold` and `cut-autofire-controls`. + +- **MA1. Master's evasion is two factors in one price** (source read, `FireControl.cs`). + `HitProbability` (:350-357) is `PFire * PMount(AngularVelocity(source, target), + TrackingRate(weapon, source)) * PSpread * POnHull * PDeviation(evasion, Tracking(source))`. + `Forecast` (:326) computes `evasion = Evasion(target, TravelDirection)`. The mount factor reads + the gun's rate times the ship's gear (`TrackingRate`, :221-224); the jink factor reads the ship's + gear alone in metres (`Tracking`, :195-199), as rulings `tracking-per-gun` and `jink-per-ship` + say. +- **MA2. The evasion core is already subject-free** (`FireControl.cs:496-516`). + `Evasion(Entity, los)` returns 0 for a non-Ship and otherwise calls the pure + `Evasion(in ManoeuvreEnvelope, float2 heading, in ManoeuvreTrack, float2 los, float window)`, + whose comment calls it "the one call guns and munitions make". A munition can answer it with its + own envelope, heading and track without a second evasion function. +- **MA3. The envelope and track types** (`FireControl.cs:2049-2115`). `ManoeuvreEnvelope` is six + accelerations (Forward, Reverse, Left, Right in m/s^2 along body axes; Clockwise, + CounterClockwise turn rates). `ManoeuvreTrack.Observe(float2 acceleration, float dt, float + window)` keeps trend, innovation and second moments and forgets a window after the last change + of vector. `Reach` (:521-546) with both turn rates zero reduces to the box's no-turn support + `0.5 * (face along) * T^2` plus faces whose turn is zero (`ReachFace` returns 0 for a turned face + when its rate is below 1e-4, :552). So a point-mass munition with no turn rate is priced by its + box alone, which is what a vector-commanded munition can do. +- **MA4. Who writes a ship's track** (grep over `Assets/Scripts/ServerShared`). One writer: + `Entity.Update` at `Entity.cs:1478` (`Manoeuvre.Observe(Acceleration, delta, SolutionWindow)`), + acceleration being the base update's velocity change with the limit clamp added back (:1477). + Thrusters supply the envelope (`Thruster.Manoeuvre()`, `Thruster.cs:98`); `Ship.Envelope` sums + them (`Ship.cs:153-159`). +- **MA5. Fire freezes the mount for an entity subject only** (`FireControl.cs:688`). `pFire = + solution.PFire * (solution.Engaged != null ? PMount(AngularVelocity(source, solution.Engaged), + TrackingRate(weapon, source)) : 1f)`. `CommitProbability` (:864-878) multiplies the frozen + `PFire` by `DeviationProbability` (:483-489), which adds `Evasion(shot.Target, ...)` to the + realised drift. A munition subject needs both: the mount frozen at fire from its own position and + velocity, and the evasion at commit from its own track. +- **MA6. `AngularVelocity` has a planar overload** (`FireControl.cs:209-213`): + `AngularVelocity(float2 offset, float2 relativeVelocity)`, range floored at 1 m. A munition's + offset is `Flight.Position - source.Position.xz` and its relative velocity `Flight.Velocity - + source.Velocity`; no new geometry is needed. +- **MA7. No munition code is on master** (`ls Assets/Scripts/ServerShared`, grep `MunitionId`). + `Munitions.cs`, `MunitionFlight` and `MunitionId` do not exist; `TargetRef` (`Zone.cs:646-670`) + has only Entity and Chunk. missile-stats, missile-records and missile-odds had no report + (specs-with-no-report query, as of ordinal 750). munition-shots' anchors into that code are by + name. +- **MA8. controls-mount-aim, in execution, changes the seams these cuts touch** (diff of + 4d664b53..origin/eureka/aetheria-release-controls-mount-aim, FireControl.cs and Weapon.cs): adds + `Solution(Weapon, Entity shooter, TargetRef subject)` after `AimDirection` returning `GunSolution + { Entity Subject; bool Bears; bool Free; float2 Direction }`; adds `Bears(weapon, source, Entity + target, out range)` and routes `PFire` through it; rewrites `ArcPermitsFire` to `fused || mine + layer || Solution(weapon, shooter, shooter.Target.Value)` bears or free; Solve's travel direction + becomes `Solution(weapon, source, target).Direction`. The branch dropped the Broadside scenario + (538b11b4: no catalog hull has side-rotated gun hardpoints). +- **MA9. PendingShot construction sites** (grep `new PendingShot` over Assets/Scripts and tests): + two, `FireControl.Fire` and `FireControlCut9Tests.cs`. Readers of `shot.Target` in FireControl: + :487-488, :808, :867-876, :884, :920-950, :1132-1140, :1190, :1483; outside: + `EntityInstance.cs:331-333`. +- **MA10. Autofire seams on master** (source reads): `Entity.WeaponGroups` `Entity.cs:82`, sized + :791-793, removal path :1050; `PowerBus.Step` :1453 then the equipment loop :1455-1458 (the only + place equipped behaviours execute in `Entity.Update`); `Weapon.Activate/Deactivate` set `_firing` + only (`Weapon.cs:156-164`), so a weapon draws no power by being triggered. `StanceAllowsFire` + `Weapon.cs:113`, `ArcAllowsFire` :123. `Refuses` `FireControl.cs:93-95`, `AgentFires` :104-112 + (its first branch is the mine layer's: worth it when `Solve` says Designated). `Fire` reads + `source.Target.Value.Entity` at :683. `EntityPack` keys 0-16 on the base class, subclasses use + 17-20, key 21 unused (`EntitySerializer.cs`). `ActionBarWeaponGroupBinding` + `ActionBarSlot.cs:177-210`; slot input actions are created in `ActionGameManager.createBinding` + (:434-442, `started` activates, `canceled` deactivates). `ControlsHud.cs` does not exist on master + (controls-hud creates it). `Zone.Targets.Within(in TargetSearch, List into)` + (`TargetingIndex.cs:99`) fills a caller's list. `Scenarios.Tests` (`Scenarios.cs:7-10`) ends with + `LauncherAngles, LongHaul`. +- **MA11. The close-crosser fixture already exists** + (`tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs`). `EvMountRange(gunRate, laserRate)` sets + two guns on one shooter to authored rates through `EvSetRate` (:23-29), and + `LightAndHeavyGunsSplitOnAFastCloseCrosser` (:139-160) with rates 40 and 3 and `EvCross(range, + jinking: false, float2(100, 0))` at 200 m measures PMount >= 0.85 and <= 0.01. The steady + crosser's evasion is zero (EvCross settles the track), so the split is the mount term alone. The + fixture places the target with `ScenarioStance.Neutral` (default of `ScenarioStage.Place`, + `ScenarioStage.cs:109`); `Entity.SetIff(other, true)` (`Entity.cs:810`) or + `ScenarioStance.Hostile` makes it hostile. +- **MA12. No PD-class gun is in the catalog** (tracking table). The highest authored rates are + ClearPath 12 and Spectra 10 deg/s; launchers are +infinity. Against inbound missiles the angular + velocity is small (mostly radial approach), so these suffice to engage salvos in a scenario; + against a fast close crosser ClearPath prices 0.37 / 0.76 (TC / FCA, map table). The catalog + growth section's PD guns (CG-probes) are the content that closes this. + ### The design **The resolution model** is ruled: `missiles-resolution-model`, geometry-sets-odds. The second @@ -4997,12 +5084,12 @@ while one of your autofire guns has it as subject. | Kind | Identity | Lifecycle | Authority | |---|---|---|---| | Munition (light record) | Its `PendingShot`'s `ShotId`, as `MunitionId` in a `TargetRef`. | From fire through its terminal gate (commit) to arrival, or until interception or lifetime; never saved, as pending shots are not. | `FireControl` for transitions and the roll; `Munitions.Advance` for flight. | -| Munition flight | `PendingShot.Flight` (position, velocity, delta-v, command, wear, seeker, lock, faction, inbound rounds). | Stepped every tick; gone with its shot. | `Munitions.Advance` under `FireControl.Step`; wear by `DamageMunition`. | +| Munition flight | `PendingShot.Flight` (position, velocity, delta-v, command, wear, seeker, lock, faction, inbound rounds, `Track`). | Stepped every tick; gone with its shot. `Flight.Track` observes the command Advance applied each step; it is runtime, never saved, and discarded with the shot. | `Munitions.Advance` under `FireControl.Step`, the one writer of the track (as `Entity.Update` is for a ship); `Munitions.Envelope` derives what it can reach from its stats and fuel, and `FireControl` prices it through the same evasion core as a ship; wear by `DamageMunition`. | | Entity manoeuvre track | `Entity.Manoeuvre` (`ManoeuvreTrack`: trend, filtered innovation, its second moment). | Observed every update from the velocity change across the base update; never saved. | `Entity.Update`. | | Ship envelope | `Ship.Envelope` (`ManoeuvreEnvelope`: four body-frame accelerations, two turn rates). | Recomputed every update from the live propulsors; never saved. | `Ship`, from each propulsor's report. | | Missile stats | `MunitionData` on `LauncherData` / `GuidedWeaponData`, six groups. | Catalog data in `Aetheria.cc`. | The catalog now; gear once munitions are entities. | | Autofire setting | Per weapon group, `Entity.Autofire` (on, threshold). | Set by the player; saved in `EntityPack` key 21. | The player. | -| Autofire subject | Per weapon, runtime only. | Re-picked by `Autofire.Update` when the current one stops being worth firing. | `FireControl.ChooseSubject`. | +| Autofire subject | Per weapon, `Weapon.AutofireSubject`, runtime only. | Re-picked every tick, and when the current one stops being worth firing. | `FireControl.ChooseSubject`. | ### Rationale @@ -5108,6 +5195,68 @@ reading can take), jamming and soft kill; boost and midcourse fuel planning; min the demo cast fields launchers for the player's PD to meet is content: the cuts add a `Point Defense` scenario for the operator check. +**Why a munition's evasion goes through the ship's core.** Follow-up +`munition-shots-evasion-interface` (absorbed by `cut-munition-shots`) asked that a munition answer +`FireControl.Evasion` "with its own manoeuvre envelope (from its missile-stats thruster group) and +its own course-change record, like ships do". The earlier price went through a direct agility +(`Stats.Lateral` while fuelled) and the command vector, which is the strafe-only reading ruling +`evasion-is-unpredictability` overturned for ships. The munition now gives the core the two inputs +it reads: `Munitions.Envelope(flight)` (faces equal to Advance's own command clamps; no turn rates, +because a munition commands any planar vector at once, MA3) and `Flight.Track` (observed by +Advance, MA4's pattern). Then `Evasion(flight, los)` is one line over the shared core, a jinking +missile is evasive because its track sees changes of vector, and a dry one is not because its +envelope is zero. A missile pursuing a weaving target also shows innovation from its PN commands; +that is evasion by the ruling's own measure ("how much the ship is actually changing its vector"), +not a defect. + +**Why the mount term is the munition's own geometry.** Follow-up `munition-shots-mount-term`: +`PMount(AngularVelocity(Flight.Position - source.Position.xz, Flight.Velocity - source.Velocity), +TrackingRate(weapon, source))` (MA6), frozen at fire like an entity's (MA5), with the jink forgiven +per ship by `Tracking(source)` (ruling `jink-per-ship`). Inbound missiles approach mostly radially, +so their angular velocity is small and PD guns hold them; a missile crossing close is hard to +follow, which is the CIWS geometry and EVE's tracking formula `0.5^((omega/rate)^2)` the mount term +already is. + +**Why the box support is enough.** `Reach`'s no-turn support assumes the lateral and along-track +faces fire independently (a box), while Advance clamps the total command to `Stats.Acceleration` (a +disc with a lateral cap). Across the line of sight the binding limit is the lateral face, which the +box and the disc share; the corners overstate reach only on oblique lines of sight, and the +observed track caps the evasion before the envelope does in every case the tests build. A disc +support would be a second reach function for one corner case; not bought. + +**Why munition-shots is split.** The earlier estimate was 465 lines and 12 tests across Solve, +Fire, Step, Commit, Apply, Detonate, Solution and Advance, and the envelope, track and mount term +add to it. The evasion-term cut of similar reach took 190 calls and overran (memory +`hands-batch-sizing-and-stryker-offsets`). The jink (inbound-round count, Advance step 4 and the +fuel-spend tests) is a separate behaviour with its own rulings (`missiles-fuel-and-seekers`, +`missile-cognition`) and its own failure modes, and it reaches the PD price only through the track. +So `munition-shots` is the subject, price and resolution (about 380 lines) and `munition-jink` is +the jink (about 170 lines), each falsifiable alone. + +**Why the autofire divert test uses a steady close crosser.** Under ruling +`evasion-angular-velocity-r2` a steady fast crosser close in evades through the mount term even +with no jink, and that is exactly where ruling `speed-demon-counterplay` wants light guns to hold a +ship heavy guns cannot. MA11's fixture gives the split (PMount >= 0.85 against <= 0.01) with zero +evasion, so the test proves the chooser reads the per-gun price and nothing else. The threshold is +set inside the interval between the heavy gun's price and the lower of the light gun's price and +the heavy gun's price without the mount, so a Worth that drops the mount, or reads the ship's gear +in place of the gun's rate, lets the heavy gun fire and fails. + +**Why autofire-threshold is split too.** The earlier version (about 520 lines) mixed the headless +chooser, Worth, SubjectOf, the saved settings and 13 tests with Unity work (action-bar modifiers, +HUD diamonds, a new scenario) whose every loop needs a Starfire compile. `autofire-threshold` keeps +the sim half (about 360 lines) and gives the settings one writer, `Entity.SetAutofire`, so the +presentation half, `autofire-controls` (about 190 lines), writes nothing else. + +**Unowned checks.** +- `missile-odds` was written against evasion-term. It prices a missile's hit on a ship with the + target's evasion; whether a missile as shooter takes any mount term was never ruled (the tracking + table lists launchers at +infinity: "the round steers itself"). Worth a check before missile-odds + is dispatched, if no parallel pass already owns it. +- No PD-class gun existed (MA12). The Point Defense scenario and the speed-demon play check could + run on ClearPath and Spectra, but the ruled outcome "PD-class guns hit a speed demon reliably" + has no catalog gun to show it until one is authored (see "Catalog growth"). + ## Retiring the LonginusX and parking the aether drive Ruling `retire-longinusx`, in the operator's words: "I want to retire the LonginusX, the aether From 1476cfd0e0694c65a47339b63e7c801f5f027128 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 15:42:12 +0200 Subject: [PATCH 23/23] Map: catalog growth, generic designs and branded products Folds the catalog-growth addendum and the emoji-pirates product facts into a new section: probes CG1-CG12 and LP1-LP9, the maker profile, PD product, generic-name and weapon product-line tables, the model page rows (Title is the one owner of an item's title; DisplayName is not a thing) and rationale. Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-release-map.md | 311 +++++++++++++++++++++++++++++++++++ 1 file changed, 311 insertions(+) diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index 15e9d9992..9d83f1d89 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -5257,6 +5257,317 @@ presentation half, `autofire-controls` (about 190 lines), writes nothing else. run on ClearPath and Spectra, but the ruled outcome "PD-class guns hit a speed demon reliably" has no catalog gun to show it until one is authored (see "Catalog growth"). +## Catalog growth: generic designs, branded products + +Ruling `catalog-grows-generic-designs-branded-products` (operator, 2026-10-08): designs are generic +and branding lives in products. A design is expected to carry several products. `Title` is the one +owner of an item's displayed title (`ItemManager.Title`, landed by `product-titles`); there is no +`DisplayName`. Body: `GameCult/Aetheria` `origin/master` `4d664b537f76029fbaee94392da3d8a122f81339`, +unless a fact names another anchor (LP facts read the `product-titles` head `7f700d3c`). Read by +`cut-pd-gear`, `cut-lot-product`, `cut-generic-design-names`, `cut-product-lines-weapons`, +`cut-product-titles` and `cut-pirates-record`; question `minor-power-brands` and follow-up +`gear-product-lines` own the rest of the gear. + +### Body facts + +Catalog decode method for CG1-CG4: `cultcache_py` `SingleFileMessagePackBackingStore. +pull_all()` over `GameData/Aetheria.cc` at `4d664b53` (LFS pulled into the worktree), each payload +`msgpack.unpackb`ed and read by MessagePack key (the M28 method); the store is `cultcache.store.v1` +with 13 schemas and 217 records. Scratch scripts were not committed. + +- **CG1. The catalog's record census at 4d664b53.** 64 FactionProductData, 51 CompoundCommodityData, + 32 GearData, 18 WeaponItemData, 13 SimpleCommodityData, 12 Faction, 12 NameFile, 5 HullData, + 4 CargoBayData, 1 FieldKindData, 1 DockingBayData, 1 InputLayout, 0 Loadout. Factions (short + names): AU, Adrasteia, Alakrita, DME, Ewan Hart, Finch, Lightsail, Lucent, Miss Terri's, + NiteLife, R&D, Zhestokost. No Pirates record yet (H1). +- **CG2. The data model already separates design from product; the content does not** (source read + plus CG1). `FactionProductData` (`ServerShared/FactionProduct.cs:14-31`: Name 1, Description 2, + Design 3 `CultRecordRef`, Manufacturer 4 `CultRecordRef`, Roles 5 + `ProductRole{Role, Mean, StandardDeviation}`) is a manufacturer's branded variant of a design; its + own comment says a market segment is "a second product with a role pumped up". Price is on the + design, not the product (`HullData.Price`, H1). Generation and station stock pick products, not + designs (`LoadoutGenerator.RandomProducts`, `:142-171`; `GenerateStationLoadout` stocks 16 random + products, `:107-114`). Presets build a design by its first available product in record-key order + (`Loadout.Resolve`, `Loadout.cs:160-191`). But of the 59 sold designs only Targeting Computer and + Fire Control Array carry more than one product (3 each: Panopticon Prime by Finch, ClapBack Ultra + by Lucent, LockOn Pro by NiteLife on the array, and Panopticon, ClapBack, LockOn on the + computer); every other design has exactly one product, and in 49 of them the design's own name is + the brand (product name equals design name: ClearPath, Spectra, Earp, Manhattan, Store-All Plus + ...). Three designs already have generic names under a brand product (Core Power -> Steadfast, + plight -> DragOnBreath, pswarm -> Leonid); seven generic-named designs sell a product of the same + generic name (Autocannon, Cockpit 2x2, Turret Control Module, Industrial Thermostatic Heater, + Medium Drive, Small Drive, Medium Docking Bay). Most designs' `Description` is the brand's voice + ("Defend your precious cargo and have fun doing it"); 20 products carry an empty or `[TODO]` + description. 64 products over 60 designs; 0 duplicate (design, maker) pairs. +- **CG3. Where the player sees a name** (source read, master). `PropertiesPanel.GetTitle` + (`UI/Properties Panel/PropertiesPanel.cs:431-437`, `data.Name`) and the trade list + (`UI/Menu/TradeMenu.cs:193-197`, the Name column, tier-coloured) and buy dialog (`:355`) show the + design `Name`. The product name and description appear only as extra property lines after `Brand` + (`PropertiesPanel.cs:370-380`); `Inspect(ItemData)` shows the design as `Type` (`:530`). So today + the brand reaches the headline only because design names are brand names, and a product named in + emoji would be listed under its design's name. `product-titles` moves the headline to the + product (see LP3). +- **CG4. A lot does not record its product** (source read, master). `Lot` + (`ServerShared/Provenance.cs:64-70`) holds Design 0, Origin 1, Quality 2, Roles 3. + `ItemManager.CreateLot(product)` (`ItemManager.cs:134-155`) writes the maker into `Origin` and + drops the product. `ItemManager.Brand` (`:206-222`) re-derives the product as the lowest-key + `FactionProductData` with that maker and design. Two products of one design by one maker (the + market segment `ProductRole`'s comment designs for) are therefore indistinguishable after + creation: the player sees the lower-key product's name and text whatever was bought. Today no + maker sells two products of one design (CG2), so nothing shows it yet. +- **CG5. Weapon fields and the reference small guns** (source read `Behaviors/Weapon.cs:20-72`, + `Behaviors/InstantWeapon.cs:10-20`, `ItemData.cs:477-509`; catalog decode). WeaponData keys: + DamageType 1, Damage 2, Penetration 3, DamageSpread 4, MinRange 5, Range 6, DamageCurve 7, + EffectPrefab 8, Energy 9, Heat 10, Visibility 11, AmmoType 12, MagazineSize 13, ReloadTime 14, + Spread 15, Velocity 16, Tracking 33; InstantWeaponData Count 17, BurstTime 18, Cooldown 19; + WeaponItemData WeaponRange 24, Caliber 25, WeaponType 26, FireTypes 27, Modifiers 28, + BlastRadius 29, Fuse 32; GearData Hardpoint 23 (enum order Hull..AetherDrive: Energy 9, + Ballistic 10, Launcher 11). Shape is `[width, height, cells]`. Evaluated (Min-Max): + + | Design | HP | Mass | Price | Dmg | Range | Cooldown | Count | Spread | Velocity | Energy | Ammo/Mag | Tracking | + |---|---|---|---|---|---|---|---|---|---|---|---|---| + | ClearPath | Ballistic 2 cells | 50 | 50,000 | 2-6 | 250-600 | 0.1-0.033 | 1 | 1.25-0.25 | 700-1250 | 1-0.5 | none / 0 | 12 | + | Spectra | Energy 2 | 25 | 125,000 | 20-85 | 800-1750 | 0.5-0.25 | 1 | 0.1-0 | 0 (beam) | 20-40 | none | 10 | + | FastBlast+- | Energy 2 | 125 | 150,000 | 20-80 | 500-1250 | 0.33-0.1 | 12 | 3-1 | 400-900 | 0 | cells / 12 | 9 | + | Autocannon | Ballistic 4 | 75 | 250,000 | 3 | 1000 | 0.5 | 1 | 0.5 | 500 | 5 | Ammo / 50 | 4 | + | DeathCluster | Ballistic 4 | 250 | 450,000 | 100-250 | 500-1000 | 1-0.25 | 12-15 | 3-0.5 | 750-1500 | 25-5 | Ammo / 8 | 3 | + + Damage, Heat and Energy are divided by the burst count per pellet (`InstantWeapon.cs:128-134`). + The ammunition commodity is the SimpleCommodity that Autocannon's AmmoType names (`3085fc8a...`). +- **CG6. Homes for one-cell guns** (source read `LoadoutGenerator.HasHome`, `:173-176`; + `HardpointData.Takes`, M30). A design is offered only if some catalog hull has a hardpoint that + `Takes` it, which is any design of the slot's type whose shape lies within the slot. Longinus has + two Energy 1x2 hardpoints, Djinni two Ballistic 1x2, the station Turret two Ballistic 2x4. So a + 1x1 Ballistic or Energy gun and a 1x2 Ballistic gun all have homes on master. +- **CG7. Munitions on master.** `missile-records`, `missile-stats`, `munition-shots`, + `autofire-threshold` and `autofire-controls` had no report: master has no munition record yet + (MA7). missile-stats authors munition `Durability 20`. `Detonate` is the one blast path and + damages entities, not munitions (missile-odds, "Detonate is the one blast path"). +- **CG8. A shipped weapon may not carry a fuse today.** + `FireControlCut124Tests.ShippedCatalogIsNeutralOnBlastRadiusAndFuse` + (`tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs:2591-2610`) asserts every shipped weapon's + BlastRadius and Fuse are null. An airburst flak gun would break it; the flak below is a pellet gun. +- **CG9. The catalog tracking test** (`tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs:290-313`, + `EveryCatalogGunTracks`) requires every non-launcher gun's Tracking finite, Min = Max > 0. New + guns must author it. +- **CG10. Design names in code at 4d664b53** (`git grep -F` over `*.cs` for the quoted names of + the generic-names table). 61 hits in 12 files: scenarios Arcs.cs 14, Duel.cs 11, + LauncherAngles.cs 8, StarvedReactor.cs 6; tests EvasionTermTests 10, BrownoutTests, + EvasionTrackingTests, PowerCurveTests, RestoredHullsTests, RunStartTests, ScenarioTests 1 each; + tools/AetherDb/Program.cs 6. Loadout and product references are by record key, unaffected by a + rename. +- **CG11. Behaviour index 31 is CapacitorData** (`ServerShared/Behaviors/Behaviors.cs:178`), so + PotaT+- is NiteLife's capacitor. The wishlist's "capacitors: no products" line is stale. +- **CG12. Vault sources read** (AetheriaLore `33cd06a`): `Aetheria/Game Design/Faction Play.md` + (loadout and quirk lines 118-266, 307-357), `Aetheria/Brainstorming/Faction Flavor and Visual + Identity.md` (Aya point defense and interceptors :458, Megiddo decoys and interception :566), + `Aetheria/Brainstorming/Corporate Roster and Item Wishlist.md` (item ideas :57-87, gaps :97-146; + its "What this is for" names the design/product text split). Zhestokost's loadout names "flak and + shrapnel (MFer, DeathCluster)" against missiles; Lightsail's names ClearPath as its machine gun + and Cetacean Navigators' Waykeeper escorts carry point defense with Lightsail haulers; Aya and + Megiddo are the heavy-PD and decoy factions but are not catalog factions. `Ship Play Concepts.md` + was not found in either repo at these heads. +- **LP1. `Lot` keys** (`Assets/Scripts/ServerShared/Provenance.cs:63-80`, at 7f700d3c). Keys 0-3 + (Design, Origin, Quality, Roles); key 4 is free. Lots and origins already carry + `CultRecordRef` through MessagePack (:66, :101, :108, :119), + so a fourth `CultRecordRef` needs no formatter work. +- **LP2.** `ItemManager.Brand` (ItemManager.cs:206-222) derives the product at :217-220 with + `GetAll().Where(maker and design).OrderBy(key).FirstOrDefault()`. It is the + only such derivation in Assets/ (git grep `GetAll`: ItemManager :217, plus + Loadout :194 and LoadoutGenerator :145, which choose a product to mint, not read an item's). +- **LP3.** `Title` (ItemManager.cs:226-227) reads `Brand(crafted).Product?.Name`. Brand's readers: + Title, PropertiesPanel.cs:374 (Manufacturer and description), tests LoadoutTests + :336/:359/:395/:844 and RunStartTests :136/:678. Nothing else reads an item's brand, so fixing + Brand fixes every reader. +- **LP4.** Lot writers in Assets/: only `CreateLot(product)` (:136-158) and `CreateLot(design, + maker, quality)` (:161-170). Production callers of the second: ActionGameManager.cs:544/:548 + (console give, no maker) and Loadout.cs:182 (unbranded preset, no maker). No `new Produced` in + Assets/ or tools/; Produced lots exist only in tests. +- **LP5.** Behaviour change: a lot from `CreateLot(design, maker, q)` whose maker sells that design + used to brand as the maker's key-first product; once the lot records its product it brands + (maker, null) and titles by design. No production path mints such a lot (LP4). +- **LP6.** Tests pinning the derivation: LoadoutTests + `BrandPicksFirstProductInKeyOrderForSameMakerAndDesign` (:375-397) pins the tie-break that goes + away; its comment says the census's duplicate detector exists because of it. Stale comment in + LoadoutTests :315-317 ("Brand() requires at most one product per (maker, design)"). +- **LP7.** AetherDb census (tools/AetherDb/Program.cs:124-138) prints "(maker, design) pairs with + more than one product" as a fault, justified by Brand's tie-break. Under the ruling a design + carries several products, and a maker may sell two of one design; with `Lot.Product` the pair is + no longer ambiguous. +- **LP8.** Run save: `RunSave.Commit` (SavedGame.cs:138) writes the reachable ledger; + `RunSave.Lots(cache)` (:132) reads it back. RunSaveTests :382-400 shows the fixture shape (a + ledger plus a SavedZone whose pack references the lot). +- **LP9.** ItemTitleTests (tests/Aetheria.Shared.Tests/ItemTitleTests.cs) builds Array with + products Panopticon Prime (Finch, key array-finch) and ClapBack Ultra (Lucent); Soul's probe added + a second Finch product of Array and saw Title return the first (finding `title-key-first-product`). + +### Content tables + +Drafted content that specs cite. Names are drafts the operator may rename. + +**Maker profiles** (one Mean / StandardDeviation applied to every role of the design, from Faction +Play's loadout lines and the existing products' roles): + +| Maker | Mean | Dev | Why | +|---|---|---|---| +| Zhestokost | .50 | .08 | armour and consistency, no finesse | +| AU | .50 | .12 | "mid-quality, consistent: the all-rounder" | +| Lightsail | .55 | .10 | freight reliability | +| R&D | .60 | .15 | compute, contractor quality | +| Lucent | .60 | .15 | premium, loud | +| NiteLife | .55 | .12 | energy specialist | +| Finch | .65 | .20 | "high variance in role quality" | +| Alakrita | .60 | .14 | light, fragile, excellent | +| DME | .55 | .20 | runs hot, uneven | +| Adrasteia | .60 | .10 | exacting | + +No product is authored for Miss Terri's (ruling `miss-terris-belongs-to-emily-r3`) or the Pirates +(the Pirates record owns their products, ruling `pirates-record-content`). + +**PD products** (`pd-gear`; the three new designs are the Point Defense Gun, Point Defense Laser and +Flak Gun, whose stats follow under the rationale): + +| Design | Product | Maker | Description | +|---|---|---|---| +| Point Defense Gun | Waykeeper | Lightsail | Freight first, questions later. Keeps a hauler's paint unscratched through a full salvo. | +| Point Defense Gun | Rampart | Zhestokost | A column does not dodge. It does not need to. | +| Point Defense Gun | Swatter | AU | Keeps the rocks and the rockets off your back while you work. | +| Point Defense Gun | Catch-All | R&D | Guaranteed to stop the missile!* *Missile defined as whatever it stops. | +| Point Defense Laser | Glare | Lucent | Every missile gets its moment in the spotlight. A short one. | +| Point Defense Laser | Nightlight | NiteLife | Lights out for anything with a warhead. Shares the bus with your blasters, so mind the meter. | +| Point Defense Laser | Wren | Finch | Small, quiet and quicker than it looks, most days. | +| Point Defense Laser | Gnat | Alakrita | Weighs less than your conscience and keeps the fast pass fast. | +| Flak Gun | MFer | Zhestokost | Our shrapnel turns your enemies into scrapnel. (the operator's own line, Item Wishlist :57) | +| Flak Gun | Hailstorm | AU | A wall of steel for crews who would rather not count missiles. | +| Flak Gun | sky full of teeth | DME | light it up/ shake it loose/ nothing gets through tonight | + +**Generic design names** (`generic-design-names`; hulls, Traction and already generic names +excluded: hull names are the model names other passes are revising, retire-longinusx deletes +Traction): + +| Old design name | Generic design | Functional description | +|---|---|---| +| ClearPath | Light Machine Gun | Light ballistic hose: many small rounds at short range. | +| 6k Shooter | Slug Rifle | Single heavy slugs, fired as fast as the trigger. | +| Earp | Long Slug Rifle | A lighter slug rifle with the longest ballistic reach. | +| DeathCluster | Cluster Cannon | Medium shotgun: a heavy volley of pellets. | +| pretty pretty bang bang | Charged Scattergun | Ballistic shotgun that charges for a heavier volley. | +| Spectra | Light Laser | The lightest beam: low damage, quick cycle, long reach. | +| FastBlast+- | Cell Blaster | Rapid energy bursts fed by cells that cut its reactor draw. | +| ColdFire | Long Laser | Heavy small beam: slow, hard-hitting, long reach. | +| CShot RainbowLite Lazer | Heavy Beam Laser | The heaviest beam: nine cells, enormous damage and reach. | +| ChargeBlast+- | Charged Blaster | One charged knockout shot. | +| ChargeBlast SG | Charged Scatter Blaster | Charged energy shotgun. | +| plight | Lightning Projector | Charged chain lightning. | +| GT 3K | Guided Missile Launcher | Fires guided missiles. | +| LRMM72 | Long-Range Missile Rack | Large missile rack for long engagements. | +| SRMM72 | Short-Range Missile Rack | Extra-large rack of short-range missiles. | +| pswarm | Swarm Rocket Pod | Dumbfire rockets in a swarm. | +| scorched void policy | Incendiary Rocket Pod | Incendiary dumbfire rockets. | +| Arctica | Adaptive Radiator | Radiator whose output scales widely with conditions. | +| Iapyx | Radiator | Standard two-cell radiator. | +| Skiron | Long Radiator | Four-cell radiator with a large surface. | +| OK Disperser | Heat Pump | Moves heat to where it can be shed. | +| cold like my heart | Cryogenic Heat Pump | Heavy heat pump that drives temperatures far down. | +| Core Power | Compact Reactor | Small reactor. | +| Manhattan | Heavy Reactor | Large three-by-three reactor. | +| Notorious | High-Yield Reactor | Large reactor with a wide output range. | +| Vulcan | Ring Reactor | Ring-shaped reactor, four by four. | +| MoveOnPro | Muon Fusion Reactor | Compact fusion reactor that draws a reservoir. | +| Core Power Station Reactor | Station Reactor | A reactor sized for a station's core. | +| Manhattan Station Reactor | Heavy Station Reactor | A heavy reactor sized for a station's core. | +| Notorious Station Reactor | High-Yield Station Reactor | A high-yield reactor sized for a station's core. | +| Vulcan Station Reactor | Ring Station Reactor | A ring reactor sized for a station's core. | +| MoveOnPro Station Reactor | Muon Fusion Station Reactor | A muon fusion reactor sized for a station's core. | +| RevvITup 2.0 | High-Thrust Drive | Large drive: huge thrust, little torque. | +| deep space burnout | Burn Drive | Large drive that runs very hot. | +| Talaria | Rugged Drive | Two-cell working drive. | +| Victoire | Racing Drive | Feather-light two-cell drive with extreme response. | +| The Bat | Sensor Array | Two-by-two long-range sensor array. | +| not if i see you first | Compact Sensor | One-cell sensor. | +| PotaT+- | Capacitor | Stores charge for burst draw. | +| SafeStuff II | Secure Cargo Bay | Three-by-three secured hold. | +| Store-All Enterprise Edition | Large Cargo Bay | Six-by-six hold. | +| Store-All Plus | Cargo Bay | Two-by-two hold. | +| Store-All Plus Mini | Small Cargo Bay | One-cell hold. | + +The product keeps the old name, so the brand survives as the product. Products whose name equals a +generic design get a brand: Autocannon (Zhestokost) -> Hammerfall; Cockpit 2x2 (Lightsail) -> +Wheelhouse; Industrial Thermostatic Heater (Lightsail) -> Hearth; Medium Drive (Lightsail) -> Steady +Haul; Small Drive (Lightsail) -> Short Hop; Turret Control Module (AU) -> Watchtower; Medium Docking +Bay (AU) -> Open Berth. The Bat's product is Miss Terri's: its record is not touched; only the +generic design under it is renamed. Description move: the design's old text goes to its product +when the product's is empty, `[TODO]` or identical; otherwise the product keeps its own and the +dropped design text is printed in the dry run and the commit message. + +**Weapon product lines** (`product-lines-weapons`): + +| Design | New product | Maker | Description | +|---|---|---|---| +| Light Machine Gun | Rattler | AU | Cheap, loud, and there when the rocks start moving. | +| Slug Rifle | SM-6 Marksman | R&D | Standardised, certified, guaranteed to hit the target.* | +| Long Slug Rifle | Longshot | Finch | Light, quiet and dead accurate, when it feels like it. | +| Autocannon | Workhorse | AU | Feeds from anything, fires at anything. | +| Cluster Cannon | everything at once | DME | why aim/ when you can be everywhere | +| Charged Scattergun | Grapeshot | Zhestokost | The old answer to boarders, sized for the void. | +| Light Laser | Sparrow | Finch | Barely registers on the scale, or on their sensors. | +| Light Laser | Spotlight | Lucent | Make sure they know who got them. | +| Cell Blaster | Strobe | Lucent | Fast, bright, and very hard to ignore. | +| Long Laser | Kestrel | Finch | Hovers at the edge of range, then stoops. | +| Heavy Beam Laser | Afterglow | NiteLife | Keeps glowing long after the fight is over. | +| Charged Blaster | Encore | Lucent | One more, for the fans. | +| Charged Scatter Blaster | nobody leaves early | DME | doors are locked/ lights are low/ stay for the finale | +| Lightning Projector | Static Shock | NiteLife | A jolt for every ship in reach. | +| Guided Missile Launcher | Homer | Finch | Finds its way, mostly. | +| Long-Range Missile Rack | Long Haul | AU | Delivers to the far side of the zone. | +| Short-Range Missile Rack | Short Fuse | AU | For problems already too close. | +| Swarm Rocket Pod | Confetti | Lucent | Every launch is a celebration. | +| Incendiary Rocket Pod | Trebuchet | Zhestokost | Siege fire for a column that does not stop. | + +### Model page rows + +| Kind | What names it | Over time | Who decides | +|---|---|---|---| +| `FactionProductData` (catalog) | Its record | Every sold design carries at least one product whose name differs from the design's, and is expected to carry several | The catalog author: the brand a player sees | +| Item title | `ItemManager.Title(item)` | Derived on each read from the lot's brand; the design name stays as `Type` | `ItemManager.Title`, the one owner; there is no `DisplayName` | +| `Lot` (run save) | `Lot.Product` (key 4) | Written once by `CreateLot(product)`; a lot made without a product (console give, extraction) has none and shows its maker only. A product that no longer resolves reads as unbranded | `CreateLot`; `Brand` reads it and nothing re-derives a product from maker and design | +| `WeaponItemData` (catalog) | Three new generic designs: Point Defense Gun, Point Defense Laser, Flak Gun | Authored once by `pd-gear` | The catalog author | + +### Rationale + +**Titles name the product.** One engine-free derivation in ServerShared, `ItemManager.Title( +CraftedItemInstance)`: the brand's product name, else the design name. Every title site reads it. +This is what makes "branding lives in products" visible, and what shows the Pirates' emoji names. +`lot-product` moves the brand's authority from a catalog query (LP2) into the lot, so that two +products of one design by one maker stay distinct (CG4, LP9). A first plan for it added a +`DisplayName` over the same hunks as `Title`; Soul verdict `cut-product-titles.s1` found two title +owners in force (`title-vs-displayname-split`) and Self ruled Title the one owner. A lot whose +product no longer resolves (a removed mod's product) reads as unbranded: `Brand` returns (maker, +null) and `Title` falls back to the design; `RequireDesigns` is not widened (ruling +`no-save-compatibility-before-players`). The census duplicate-brand block (LP7) goes away. + +**Why three PD designs and not five.** The missile-defence rulings in force are `pd-who-engages` +(any gun, priced by its own stats), `missiles-sim-side-light` (missiles are lightweight records PD +can engage), `weapon-tracking-authored` (a PD class near 40 deg/s exists to be authored) and +`speed-demon-counterplay`. They need guns with PD stats, which the catalog lacks: the PD Gun +(ballistic hose, the Djinni's slots), the PD Laser (energy, the Longinus's slots, so the demo's +starting hull can fit PD) and the Flak Gun (Zhestokost's flak doctrine as a pellet gun, which needs +no new rule: each pellet rolls). An interceptor launcher needs missiles that take munition subjects +and a decoy launcher needs a signature the seeker can read that is not a ship; both are new +mechanics, which the target's not-in-scope line excludes until ruled. They are follow-ups +`interceptor-launcher` and `missile-decoys-signature`, not catalog content. An airburst flak (a +blast that wears munitions in its disc) is follow-up `flak-airburst-at-munitions`. + +**Derivation of the PD stats** (from CG5 and the tracking table). Tracking 40 is the table's PD +anchor (0.82 / 0.94 against the speed demon at TC / FCA). PD Gun is ClearPath's role pushed to its +extreme: one cell, lighter (40 kg), less damage per round (1.5-4) at a higher rate (0.06-0.025 s) +and shorter reach (300-550 m), because a munition has 20 durability (CG7): 5 to 14 rounds kill one. +It draws ammunition (`ballistics-real-ammo`) and a small cycling energy, 0.4-0.2 +(`ballistics-cycle-power`). PD Laser is Spectra's role: instant beam, 5-12 per shot every 0.2-0.1 s +to 400-750 m, energy-limited at 6-10 a shot, 2 to 4 hits a missile. Flak Gun is DeathCluster's +shotgun at small calibre, 1x2: 6-8 pellets sharing 30-70 damage a volley, 350-800 m, Tracking 20 +(between PD and ClearPath, because pellets cover what tracking misses). + ## Retiring the LonginusX and parking the aether drive Ruling `retire-longinusx`, in the operator's words: "I want to retire the LonginusX, the aether