diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index aef753cef..f920522ae 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -341,6 +341,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(); @@ -1185,9 +1198,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); @@ -1195,6 +1208,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/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 60b84ce87..282f7050d 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/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/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..ee34b1563 --- /dev/null +++ b/Assets/Scripts/ServerShared/RunGoal.cs @@ -0,0 +1,42 @@ +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 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 && 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. + 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/Scenarios/DemoTerminus.cs b/Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs new file mode 100644 index 000000000..8cda7c600 --- /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; the player, who is not a Pirate, flies the base Longinus. +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("Longinus"), 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/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/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 75f278e34..df07d1f57 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -190,6 +190,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 2ff4fe005..156deb803 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -337,6 +337,24 @@ 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); + 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/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); } diff --git a/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index a4ee00ae4..97d5eee25 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) + }); } } diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index 4e0ce300e..9d83f1d89 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -377,6 +377,373 @@ 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. + +### 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. + +### 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 | +|---|---|---|---| +| 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 | +| 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 | +| 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 The operator ruled on 2026-10-03 that the faction dynamics may elaborate the NPC @@ -642,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`. | @@ -704,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 @@ -4050,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 @@ -4529,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 @@ -4640,6 +5195,379 @@ 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"). + +## 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 diff --git a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs new file mode 100644 index 000000000..577296ac3 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs @@ -0,0 +1,241 @@ +/* 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 CultMath; +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. 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() + { + 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)); + + [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); + // 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.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)); + } + } + + // 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], 0); + 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]); + 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); + } + + // 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 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_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("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); + } + } + + // 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] + 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() + { + 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); + } + + // 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] + public void TutorialGalaxy_unchanged() + { + var galaxy = RunStart.Generate(new TutorialGalaxy(), Inputs(() => GalaxySeed)); + Assert.Equal(TutorialSeed1, TutorialShape(galaxy)); + 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) => + 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/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs new file mode 100644 index 000000000..da7ff39ba --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -0,0 +1,278 @@ +/* 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(); + // 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); + 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], 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 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 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); + 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() + { + 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"); + } + } + } + + // 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; + // 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)) * 10 + }).ToList(); + float2 gate; + try { gate = RunGoal.ExitGatePosition(zone, ratio); } + 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; } + + 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}"); + } +} diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs index cfd5cc174..51554904e 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) { @@ -522,6 +523,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));