Merge origin/master into eureka/aetheria-release-product-titles

This commit is contained in:
Meta Vens 2026-10-08 16:27:49 +02:00
commit ace8171929
21 changed files with 1704 additions and 37 deletions

View file

@ -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(_ =>

View file

@ -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;

View file

@ -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> 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;

View file

@ -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<Faction, FactionRelationship> FactionRelationships { get; } = new Dictionary<Faction, FactionRelationship>();
private Action<string> Log { get; }
public bool IsPrelude { get; }
@ -143,6 +143,18 @@ public class Galaxy
return _cache.GetAll<Faction>().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<Faction>();
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]]);

View file

@ -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) =>

View file

@ -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<T>(int count, float sizeExponent, Predicate<T> 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<T>(Predicate<T> filter, bool required) where T : EquippableItemData
{
var hulls = ItemManager.ItemData.GetAll<HullData>().ToArray();
var candidates = ItemManager.ItemData.GetAll<FactionProductData>()
@ -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<HullData>(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

View file

@ -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;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: de03c3545cb04ce38bf85b713dc5df74

View file

@ -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);
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5ba7152d3c8b46a98924a4148be550db
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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 =
{

View file

@ -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;
}

View file

@ -190,6 +190,7 @@ public class Zone
Orbits = Orbits.Keys.Select(key => new CultRecordRef<OrbitData>(key)).ToList(),
Planets = Planets.Keys.Select(key => new CultRecordRef<BodyData>(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.

View file

@ -43,6 +43,11 @@ public class ZonePack
// default instance.
[JsonProperty("chunkWear"), Key(6)]
public List<ChunkWearPack> 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

View file

@ -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;

View file

@ -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);
}

View file

@ -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)
});
}
}

View file

@ -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/<id>/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 "<sprite"` outside the TMP folder: nothing. EmojiOne's guid is
referenced only by its own meta and TMP Settings; no prefab or scene sets `m_spriteAsset`. Its
attribution file says only "review their licensing terms" (EmojiOne/JoyPixels art is not openly
licensed).
- **E4. TMP's emoji path, by source read.**
- Lookup: `TextMeshProUGUI.cs:1932-1944` (and `TextMeshPro.cs:1619`): when a code point is an
emoji-presentation form not followed by U+FE0E, or an emoji followed by U+FE0F, TMP searches
`TMP_Settings.emojiFallbackTextAssets` first, through
`TMP_FontAssetUtilities.GetTextElementFromTextAssets`. That list takes font assets.
- Sprite assets resolve one code point each (`TMP_SpriteAsset.GetSpriteIndexFromUnicode`,
`:216`, a `Dictionary<uint, TMP_SpriteCharacter>`), 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
`<color>` 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:<id>:<maker key>` 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/<set>.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 <tree>:<path>`. 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 <branch>`: 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<HullData>)` `: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 <branch>`, 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<TargetCandidate> 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<CraftedItemData>`, Manufacturer 4 `CultRecordRef<Faction>`, 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<ItemData|Faction|SimpleCommodityData>` through MessagePack (:66, :101, :108, :119),
so a fourth `CultRecordRef<T>` needs no formatter work.
- **LP2.** `ItemManager.Brand` (ItemManager.cs:206-222) derives the product at :217-220 with
`GetAll<FactionProductData>().Where(maker and design).OrderBy(key).FirstOrDefault()`. It is the
only such derivation in Assets/ (git grep `GetAll<FactionProductData>`: 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

View file

@ -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<NameFile>().First())
};
foreach (var faction in _cache.GetAll<Faction>().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<SavedGame>(), _ => { });
Dictionary<string, FactionRelationship> Standing(Galaxy g) => g.Factions.ToDictionary(faction => faction.Name, faction => g.FactionRelationships[faction]);
Assert.Equal(new Dictionary<string, FactionRelationship>
{
["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 => faction.Name.StartsWith("Pirates", StringComparison.Ordinal));
var refusal = Assert.Throws<InvalidOperationException>(() => 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<DemoTerminus>(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<FactionProductData>(), 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<InvalidOperationException>(() => 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,-,-";
}

View file

@ -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<string> log) =>
new ItemManager(_cache, new ProvenanceLedger(), AuthoredSettings.Load(FindRepoRoot()).Read<GameplaySettings>("GameplaySettings"), log.Add);
private static string HullName(ItemManager items, EntityPack pack) => items.GetData(pack.Hull).Name;
private HullData[] SoldShipHulls() => _cache.GetAll<FactionProductData>()
.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<ShipPack>(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<string>();
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<string>();
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<SavedGame>(), _ => { });
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<string>();
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>("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}");
}
}

View file

@ -44,7 +44,7 @@ public sealed partial class RunStartTests
public override void Stage(ScenarioStage stage) => _stage(stage);
}
private static IEnumerable<Scenario> EveryScenario => Scenarios.Modes.Concat(Scenarios.Tests);
private static IEnumerable<Scenario> 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<uint> 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));