From 900fa51e437f1a140b548e292135fa083836b47e Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 21:12:58 +0200
Subject: [PATCH 01/19] Delete Faction.BossHull (key 10 retired),
GenerateBossLoadout and the three tests that pin its fallback
A boss is a Loadout the faction references, never drawn (rulings faction-boss-is-a-loadout-reference,
boss-gear-is-a-loadout, demo-boss-hull-invented). This commit does not build: ZoneGenerator, Galaxy,
AetherDb and the tests still read the deleted members; the next commit replaces them.
Co-Authored-By: Claude Sonnet 5.5
---
Assets/Scripts/ServerShared/Corporations.cs | 3 +-
.../Scripts/ServerShared/LoadoutGenerator.cs | 20 -------
tests/Aetheria.Shared.Tests/RunGoalTests.cs | 60 -------------------
3 files changed, 1 insertion(+), 82 deletions(-)
diff --git a/Assets/Scripts/ServerShared/Corporations.cs b/Assets/Scripts/ServerShared/Corporations.cs
index c242e3e42..9d24bc39e 100644
--- a/Assets/Scripts/ServerShared/Corporations.cs
+++ b/Assets/Scripts/ServerShared/Corporations.cs
@@ -39,8 +39,7 @@ public class Faction
// Key 9 was the faction's name-file link, retired by ruling faction-curated-place-names; never reuse it.
- [Inspectable, JsonProperty("bossHull"), Key(10)]
- public CultRecordRef BossHull;
+ // Key 10 was the faction's boss hull reference, retired by ruling faction-boss-is-a-loadout-reference; never reuse it.
[Inspectable, JsonProperty("influence"), Key(11)]
public int InfluenceDistance = 4;
diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs
index 55eea27f8..7de56b40b 100644
--- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs
+++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs
@@ -209,26 +209,6 @@ public class LoadoutGenerator
return (available, preferManufacturers);
}
- // A faction's boss: its BossHull when the faction sets one and a product sells it, else the most expensive ship hull
- // the faction can be offered, with the gap logged. Null only when no ship hull is on offer at all.
- public EntityPack GenerateBossLoadout()
- {
- var bossHull = Faction != null && Faction.BossHull.IsSet() ? ItemManager.ItemData.Get(Faction.BossHull) : null;
- if (bossHull != null)
- {
- var boss = GenerateShipLoadout(hull => hull == bossHull);
- if (boss != null) return boss;
- ItemManager.Log($"{Faction.Name}'s BossHull {bossHull.Name} is not sold by any product; its boss flies the most expensive ship hull instead.");
- }
- else
- ItemManager.Log($"{Faction?.Name ?? "This faction"} has no BossHull; its boss flies the most expensive ship hull instead.");
-
- var (available, _) = AvailableProducts(hull => hull.HullType == HullType.Ship, required: true);
- if (available.Length == 0) return null;
- var priciest = available.OrderByDescending(entry => entry.design.Price).ThenBy(entry => entry.design.Name, StringComparer.Ordinal).First().design;
- return GenerateShipLoadout(hull => hull == priciest);
- }
-
// Hardpoint gear that no hull in the catalog can mount has no home (operator, 2026-09-30: "we'll need to author a
// bunch more hulls before all the gear variety in the game has a home"): generation never offers it, so no station
// stocks it. It gains one the moment a hull with a hardpoint that takes it exists. Tool gear goes in any interior.
diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
index 25b46fdd3..4c51eca72 100644
--- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs
+++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
@@ -70,50 +70,6 @@ public sealed partial class RunStartTests
Assert.DoesNotContain(pack.Entities, entity => !entity.Boss && entity is ShipPack);
}
- [Fact]
- public void Boss_hull_from_BossHull_else_fallback()
- {
- var galaxy = DemoGalaxy(1);
- var antagonist = Zhestokost(galaxy);
- var hulls = SoldShipHulls().OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).ToArray();
- var priciest = hulls.First();
- var cheaper = hulls.Last();
- Assert.True(cheaper.Price < priciest.Price, "the catalog needs two ship hulls of different price for this test to tell them apart");
-
- // Set and sold: the boss flies it, and nothing is logged about a gap.
- antagonist.BossHull = _cache.RefOf(cheaper);
- var log = new List();
- var items = LoggingItems(log);
- var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
- Assert.Equal(cheaper.Name, HullName(items, boss));
- Assert.DoesNotContain(log, line => line.Contains("BossHull"));
-
- // Unset: the most expensive ship hull on offer, and the log names the missing BossHull.
- antagonist.BossHull = default;
- log.Clear();
- boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
- Assert.Equal(priciest.Name, HullName(items, boss));
- Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("no BossHull"));
- }
-
- // Set but sold by no product: the boss falls back the same way, and the log says which hull has no seller.
- [Fact]
- public void Boss_hull_nobody_sells_falls_back()
- {
- var galaxy = DemoGalaxy(1);
- var antagonist = Zhestokost(galaxy);
- var sold = SoldShipHulls();
- // A ship hull no product sells, dearer than any that is sold: the fallback must not pick it.
- var unsold = new HullData { Name = "Unsold Test Hull", HullType = HullType.Ship, Price = sold.Max(hull => hull.Price) + 1 };
- _cache.Upsert(unsold);
- antagonist.BossHull = _cache.RefOf(unsold);
- var log = new List();
- var items = LoggingItems(log);
- var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
- Assert.Equal(sold.OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).First().Name, HullName(items, boss));
- Assert.Contains(log, line => line.Contains(unsold.Name) && line.Contains("BossHull"));
- }
-
[Fact]
public void Exit_sealed_until_boss_dies()
{
@@ -211,22 +167,6 @@ public sealed partial class RunStartTests
Assert.False(RunGoal.ExitOpen(zone));
}
- // A boss that cannot be generated (no ship hull on offer at all) never marks the zone as having spawned one.
- [Fact]
- public void Exit_sealed_when_no_ship_hull_is_on_offer()
- {
- var galaxy = DemoGalaxy(1);
- foreach (var product in _cache.GetAll().Where(product => _cache.Get(product.Design) is HullData { HullType: HullType.Ship }).ToArray())
- Assert.True(_cache.Remove(_cache.RefOf(product).Key));
- var log = new List();
- var items = LoggingItems(log);
- var pack = BossZonePack(galaxy, items, galaxy.Exit, ambient: false);
- Assert.False(pack.BossSpawned);
- Assert.DoesNotContain(pack.Entities, entity => entity.Boss);
- Assert.Contains(log, line => line.Contains("No boss could be generated for Zhestokost"));
- Assert.False(RunGoal.ExitOpen(new Zone(items, _planetSettings, pack, galaxy.Exit, galaxy)));
- }
-
// 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()
From 352e221453bd2d5962c3ba2801d3f1d4ed37d66f Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 21:14:09 +0200
Subject: [PATCH 02/19] Faction.BossLoadout (key 22): the boss is its faction's
Loadout, resolved by Loadouts.BossOf and built by Materialize
BossOf refuses typed (BossRefusal Unset or Missing, Describe names the field and echoes no input).
ZoneGenerator builds the boss from the Loadout with ChooseProduct as the chooser; a refused or
unbuildable boss logs and spawns none. Galaxy picks boss factions through BossOf; AetherDb factions
prints the boss Loadout. Loadout vocabulary: no preset, no variant. Production builds; the tests
project does not until the next commit (RunGoalTests still sets BossHull).
Co-Authored-By: Claude Sonnet 5.5
---
Assets/Scripts/Gameplay/ActionGameManager.cs | 6 +-
Assets/Scripts/ServerShared/Corporations.cs | 7 ++-
Assets/Scripts/ServerShared/Galaxy.cs | 4 +-
Assets/Scripts/ServerShared/Loadout.cs | 60 ++++++++++++++-----
.../Scripts/ServerShared/LoadoutGenerator.cs | 2 +
Assets/Scripts/ServerShared/ZoneGenerator.cs | 21 +++++--
tools/AetherDb/Program.cs | 11 ++--
7 files changed, 78 insertions(+), 33 deletions(-)
diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs
index e24a6e395..9b4c6ad59 100644
--- a/Assets/Scripts/Gameplay/ActionGameManager.cs
+++ b/Assets/Scripts/Gameplay/ActionGameManager.cs
@@ -642,9 +642,9 @@ public class ActionGameManager : MonoBehaviour
{
var written = Loadouts.Commit(CatalogPath, Loadouts.Capture(ItemManager, ship, name), replace);
console.AppendLogLine(written
- ? $"Preset '{name}' written to {CatalogPath}. This session sees it after the catalog reloads (next launch). " +
- "Reopen CultCache Studio before saving there: a Studio session opened earlier overwrites captured presets."
- : $"Preset '{name}' changed on disk since load; nothing written");
+ ? $"Loadout '{name}' written to {CatalogPath}. This session sees it after the catalog reloads (next launch). " +
+ "Reopen CultCache Studio before saving there: a Studio session opened earlier overwrites captured loadouts."
+ : $"Loadout '{name}' changed on disk since load; nothing written");
}
catch (InvalidOperationException e)
{
diff --git a/Assets/Scripts/ServerShared/Corporations.cs b/Assets/Scripts/ServerShared/Corporations.cs
index 9d24bc39e..3b676aef4 100644
--- a/Assets/Scripts/ServerShared/Corporations.cs
+++ b/Assets/Scripts/ServerShared/Corporations.cs
@@ -65,7 +65,7 @@ public class Faction
// The maker's final assembly: the distribution every lot of theirs draws its own quality from, and the hard ceiling on each
// of its roles (quality-lean-assembly-hard-ceiling). Null is not authored: a lot of theirs assembles at AssemblyDistribution's
// defaults. assembly-from-maker derives it once at the part mean plus a tolerance margin (quality-min-tolerance-binned-assembly);
- // tune it in Studio after that. Key 16 is reserved for Relations and 18 for Livery.
+ // tune it in Studio after that. Key 16 is reserved for Relations and 18 for Livery. Key 20 was assemblyDeviation (retired); never reuse it.
[Inspectable, JsonProperty("assembly"), Key(19)]
public AssemblyDistribution Assembly;
@@ -73,6 +73,11 @@ public class Faction
// Authored by AetherDb place-names, which restores the table on every apply.
[Inspectable, JsonProperty("places"), Key(21)]
public FactionPlace[] Places = Array.Empty();
+
+ // The Loadout this faction's boss flies; unset means the faction has no boss (ruling faction-boss-is-a-loadout-reference).
+ // The one owner of which Loadout is its boss, read through Loadouts.BossOf.
+ [Inspectable, JsonProperty("bossLoadout"), Key(22)]
+ public CultRecordRef BossLoadout;
}
// Historical, narrative and strategic intent join as keys 2+ when economics-driven placement lands (ruling faction-curated-place-names).
diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs
index 0b2f71387..ce7b30630 100644
--- a/Assets/Scripts/ServerShared/Galaxy.cs
+++ b/Assets/Scripts/ServerShared/Galaxy.cs
@@ -288,9 +288,9 @@ public class Galaxy
progressCallback?.Invoke("Placing Factions");
if (progressCallback != null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
- // Choose some megas to have bosses placed based on whether a boss hull is assigned
+ // Choose some megas to have bosses placed: the factions whose boss Loadout resolves
var bossMegas = Factions
- .Where(m => m.BossHull.IsSet())
+ .Where(m => Loadouts.BossOf(_cache, m, out _) != null)
.Take(bossCount)
.ToArray();
diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs
index bf8a2ac47..592c827e2 100644
--- a/Assets/Scripts/ServerShared/Loadout.cs
+++ b/Assets/Scripts/ServerShared/Loadout.cs
@@ -6,11 +6,11 @@ using GameCult.Caching;
using MessagePack;
using CultMath;
-// A ship preset (a variant): the hull's design, and per hull cell a design and its rotation. A slot names a design and
-// may name a product of it; a design-only slot is open on purpose, and whoever materializes the preset chooses its
-// product from the galaxy it is built in (ruling loadout-product-optional-context-chooses), which keeps a preset
-// portable across galaxies. A preset that needs a brand names it. Authored catalog data: written in CultCache Studio or by the editor capture
-// command, read at runtime.
+// A Loadout: a hull together with item designs or products equipped to specific cells (ruling boss-gear-is-a-loadout).
+// A slot names a design, and names a product only where the loadout demands one; a design-only slot's product is chosen
+// by whoever materializes it from the galaxy it is built in (ruling loadout-product-optional-context-chooses), which
+// keeps a loadout portable across galaxies. A faction's boss flies one (Faction.BossLoadout). Authored catalog data:
+// written in CultCache Studio or by the AetherDb and editor capture commands, read at runtime.
[CultDocument("aetheria.loadout", "1"), MessagePackObject]
public class Loadout
{
@@ -30,6 +30,9 @@ public class LoadoutSlot
[Key(3)] public CultRecordRef Product; // unset: design-only, chosen at materialization
}
+// Why a faction has no boss Loadout to build; see Loadouts.BossOf.
+public enum BossRefusal { None, Unset, Missing }
+
// The owner of loadouts: capture from a live ship, the only writer, and the only builder of an entity from a loadout.
public static class Loadouts
{
@@ -66,30 +69,54 @@ public static class Loadouts
private static CultRecordRef ProductRef(ItemManager itemManager, CraftedItemInstance unit) =>
itemManager.Brand(unit).Product is { } product ? itemManager.ItemData.RefOf(product) : default;
- // A preset's key derives from its name, so capturing the same name again addresses the same record.
+ // A loadout's key derives from its name, so capturing the same name again addresses the same record.
public static CultRecordKey KeyOf(string name) => new CultRecordKey("loadout:" + name);
- // The only writer of presets. It opens its own short-lived cache over the catalog file, writable, and disposes it
+ // The one resolver of a faction's boss reference (ruling faction-boss-is-a-loadout-reference): the Loadout
+ // Faction.BossLoadout names. The boss is never generated, so this reads nothing but the reference and the catalog. Null with
+ // Unset when the faction sets none, and null with Missing when it sets one the catalog does not hold.
+ public static Loadout BossOf(CultCache items, Faction faction, out BossRefusal refusal)
+ {
+ if (!faction.BossLoadout.IsSet())
+ {
+ refusal = BossRefusal.Unset;
+ return null;
+ }
+
+ var loadout = items.Get(faction.BossLoadout);
+ refusal = loadout == null ? BossRefusal.Missing : BossRefusal.None;
+ return loadout;
+ }
+
+ // Names the faction's field, and echoes no key or name of the input.
+ public static string Describe(BossRefusal refusal) => refusal switch
+ {
+ BossRefusal.Unset => "sets no Faction.BossLoadout",
+ BossRefusal.Missing => "names a Faction.BossLoadout that is not in the catalog",
+ _ => ""
+ };
+
+ // The only writer of Loadout records. It opens its own short-lived cache over the catalog file, writable, and disposes it
// after the one commit. No other cache is touched, so no catalog instance play holds (or has mutated) can reach the
- // file; a cache that already has the catalog open sees the preset only once it reloads.
- // Throws and writes nothing when the loadout names a mod design (hull or slot), when the file is missing (capture never creates a catalog), when a preset of this name
+ // file; a cache that already has the catalog open sees the loadout only once it reloads.
+ // Throws and writes nothing when the loadout names a mod design (hull or slot), when the file is missing (capture never creates a catalog), when a loadout of this name
// exists under another key, or when one exists under KeyOf(Name) and replace is not set. The commit is conditional
- // on the record at KeyOf(Name), so a preset changed on disk since this open is not clobbered: returns false then.
+ // on the record at KeyOf(Name), so a loadout changed on disk since this open is not clobbered: returns false then.
public static bool Commit(string catalogPath, Loadout loadout, bool replace)
{
var modDesigns = new[] { loadout.Hull.Key }.Concat(loadout.Slots.Select(slot => slot.Design.Key)).Where(ShipModCatalog.IsModKey).Select(key => key.Value).Distinct().ToArray();
if (modDesigns.Length > 0)
- throw new InvalidOperationException($"Preset '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a preset.");
+ throw new InvalidOperationException($"Loadout '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a loadout.");
if (!File.Exists(catalogPath))
- throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a preset capture never creates one.");
+ throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a loadout commit never creates one.");
using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true);
var key = KeyOf(loadout.Name);
var namesake = cache.GetAll().FirstOrDefault(other => other.Name == loadout.Name && !cache.RefOf(other).Key.Equals(key));
if (namesake != null)
- throw new InvalidOperationException($"Preset '{loadout.Name}' already exists under key {cache.RefOf(namesake).Key}; rename or remove it first.");
+ throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists under key {cache.RefOf(namesake).Key}; rename or remove it first.");
var existing = cache.Get(key);
if (existing != null && !replace)
- throw new InvalidOperationException($"Preset '{loadout.Name}' already exists; replace it explicitly.");
+ throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists; replace it explicitly.");
return cache.Commit(batch =>
{
batch.Expect(key, existing);
@@ -104,7 +131,8 @@ public static class Loadouts
// WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure is listed; on any failure
// nothing is returned and the zone and cache are unchanged, but a failed fit has already drawn from
// itemManager.Random. How each design is built is Resolve's rule. A design-only slot is chosen by choose; a game
- // spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does. The hull's type
+ // spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does, and so does the
+ // boss in ZoneGenerator, which is built here too from its faction's Loadout (Loadouts.BossOf). The hull's type
// picks the entity: a ship hull builds a Ship, a turret hull an OrbitalEntity with no orbit, which stays where it is
// put; a station hull is refused. The entity always gets exactly WeaponGroupCount groups; a loadout with fewer is
// padded with empty groups. A failed materialization leaves any minted lots in the live ledger; they are not roots,
@@ -119,7 +147,7 @@ public static class Loadouts
var hullBuild = Resolve(itemManager, loadout.Hull, loadout.HullProduct, choose, "hull", failures, quality);
var hullData = cache.Get(loadout.Hull);
if (hullData is { HullType: HullType.Station })
- failures.Add($"hull: {hullData.Name} is a station hull; a preset builds a ship or a turret");
+ failures.Add($"hull: {hullData.Name} is a station hull; a loadout builds a ship or a turret");
var slotBuilds = loadout.Slots.Select(slot => Resolve(itemManager, slot.Design, slot.Product, choose, Cell(slot), failures, quality)).ToArray();
var groups = loadout.WeaponGroups ?? new int[0][];
var groupCount = itemManager.GameplaySettings.WeaponGroupCount;
diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs
index 7de56b40b..849f0c352 100644
--- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs
+++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs
@@ -183,6 +183,8 @@ public class LoadoutGenerator
count);
}
+ // An item is on offer only through a product, so a design no product makes is never drawn, stocked or chosen; it reaches a ship
+ // only through a Loadout (ruling off-market-is-general).
// The products this faction can be offered for a design kind (only gear needs a home), and whether manufacturer preference applies (it does
// not once a required item has fallen back to any manufacturer). The one availability rule for every selection.
private ((FactionProductData product, T design)[] available, bool preferManufacturers) AvailableProducts(Predicate filter, bool required) where T : CraftedItemData
diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs
index 4d4c2613e..089ce3026 100644
--- a/Assets/Scripts/ServerShared/ZoneGenerator.cs
+++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs
@@ -421,22 +421,33 @@ public static class ZoneGenerator
}
// 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.
+ // wandering traffic. The boss is its faction's boss Loadout, built whole by Loadouts.Materialize and never drawn
+ // (ruling boss-gear-is-a-loadout); ChooseProduct only brands a design-only slot. 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)
+ var loadout = Loadouts.BossOf(itemManager.ItemData, bossFaction, out var refusal);
+ if (loadout == null)
{
- itemManager.Log($"No boss could be generated for {bossFaction.Name}; its boss zone has no boss to kill!");
+ itemManager.Log($"{bossFaction.Name} has no boss: it {Loadouts.Describe(refusal)}. Its boss zone has no boss to kill.");
continue;
}
+ var failures = new List();
+ var entity = Loadouts.Materialize(itemManager, null, loadout, GetLoadoutGenerator(bossFaction).ChooseProduct, failures);
+ if (entity == null)
+ {
+ itemManager.Log($"{bossFaction.Name}'s boss Loadout {loadout.Name} cannot be built: {string.Join("; ", failures)}");
+ continue;
+ }
+ entity.Faction = bossFaction;
+ var boss = EntitySerializer.Pack(entity);
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.");
+ itemManager.Log("Galaxy.Exit has no boss: Galaxy.BossZones does not map a boss faction to the exit zone, or its boss Loadout is unset, missing or cannot be built (ZoneGenerator exit-without-boss). The exit gate stays sealed.");
if (!ambient) return pack;
diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs
index 5df9ed99c..103915d86 100644
--- a/tools/AetherDb/Program.cs
+++ b/tools/AetherDb/Program.cs
@@ -583,7 +583,7 @@ public static class Program
// Every CultRecordRef in the catalog that resolves to nothing, as " -> ".
// Each "clear " argument unsets that member's dangling refs, or removes that dictionary's
- // dangling keys (Faction.BossHull clears boss hulls that would claim a chokepoint no boss can spawn in). Dry
+ // dangling keys (a faction's BossLoadout member clears boss references that name no Loadout). Dry
// run unless passed "apply", which alone opens the catalog writable and lands every changed record in one commit.
private static int Dangling(string[] args)
{
@@ -701,15 +701,14 @@ public static class Program
{
var db = AetherDb.Open();
var products = db.Cache.GetAll().ToArray();
- Console.WriteLine($"{"faction",-26} {"short",-13} {"places",-22} {"boss hull",-14} {"influence",-9} {"doctrine",-10} products");
+ Console.WriteLine($"{"faction",-26} {"short",-13} {"places",-22} {"boss loadout",-14} {"influence",-9} {"doctrine",-10} products");
var broken = 0;
foreach (var faction in db.Cache.GetAll().OrderBy(f => f.Name))
{
var key = db.Cache.RefOf(faction).Key;
- var boss = !faction.BossHull.IsSet()
- ? "none"
- : db.Cache.Get(faction.BossHull)?.Name ?? "DANGLING";
- if (boss == "DANGLING") broken++;
+ var loadout = Loadouts.BossOf(db.Cache, faction, out var refusal);
+ var boss = refusal == BossRefusal.Unset ? "none" : loadout?.Name ?? "MISSING";
+ if (refusal == BossRefusal.Missing) broken++;
Console.WriteLine($"{faction.Name,-26} {faction.ShortName,-13} {faction.Places.Length,-22} {boss,-14} {faction.InfluenceDistance,-9} {(faction.Doctrine == null ? "none" : faction.Doctrine.EngageOn.ToString()),-10} " +
products.Count(p => p.Manufacturer.Key.Equals(key)));
}
From e2b1b9ca7b2cc9735ca72c5dbffdf2e1d38aad19 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 21:17:05 +0200
Subject: [PATCH 03/19] RunGoalTests: the boss flies its faction's boss
Loadout; refusals, exact cells, brand by context, market rule, main-galaxy
pick
Co-Authored-By: Claude Sonnet 5.5
---
tests/Aetheria.Shared.Tests/RunGoalTests.cs | 292 +++++++++++++++++++-
1 file changed, 285 insertions(+), 7 deletions(-)
diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
index 4c51eca72..d2bc84a8f 100644
--- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs
+++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
@@ -12,13 +12,13 @@ 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,
+// only when that boss is dead. The boss flies its faction's boss Loadout (cut boss-loadout). 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)
+ private ZonePack BossZonePack(Galaxy galaxy, ItemManager items, GalaxyZone zone, bool ambient = true, uint seed = 7)
{
- items.Random = new CultMath.Random(7);
+ items.Random = new CultMath.Random(seed);
return ZoneGenerator.GenerateZone(items, _zoneSettings, _planetSettings, galaxy, zone, galaxy.IsPrelude, ambient);
}
@@ -38,12 +38,87 @@ public sealed partial class RunStartTests
.Where(hull => hull != null && hull.HullType == HullType.Ship && hull.Price > 0)
.Distinct().ToArray();
+ // A hull no product makes, so the market never offers it: a MessagePack copy of the first sold ship hull by name, renamed. It has
+ // no authored model, which a boss does not need. Stored once; asking again returns the same record.
+ private HullData BossTestHull()
+ {
+ var existing = _cache.GetAll().FirstOrDefault(hull => hull.Name == "Boss Test Hull");
+ if (existing != null) return existing;
+ var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly);
+ var like = SoldShipHulls().OrderBy(hull => hull.Name, StringComparer.Ordinal).First();
+ var boss = MessagePack.MessagePackSerializer.Deserialize(MessagePack.MessagePackSerializer.Serialize(like, options), options);
+ boss.Name = "Boss Test Hull";
+ boss.Prefab = null;
+ boss.Visual = default;
+ return _cache.Get(_cache.Upsert(boss));
+ }
+
+ // A Loadout record at Loadouts.KeyOf(Name), the key Loadouts.Commit writes it under.
+ private void StoreLoadout(Loadout loadout) =>
+ Assert.True(_cache.Commit(batch => batch.Upsert(typeof(Loadout), loadout, Loadouts.KeyOf(loadout.Name))));
+
+ // A boss's fixed Loadout (ruling boss-gear-is-a-loadout), by the production capture: a seed-11 generated fit of the first sold
+ // ship hull by name, captured (Loadouts.Capture), cut down to its weapons so it is smaller than any generated fit (the tractor
+ // pickup and the rest are not in it), moved onto the given hull with no hull product, and stored under its name.
+ private Loadout MakeBossLoadout(HullData hull, string name)
+ {
+ var like = SoldShipHulls().OrderBy(h => h.Name, StringComparer.Ordinal).First();
+ var random = new CultMath.Random(11);
+ var generator = new LoadoutGenerator(ref random, _items, null, null, null, .5f);
+ var ship = (Ship) EntitySerializer.Unpack(_items, null, generator.GenerateShipLoadout(h => h == like));
+ var fit = Loadouts.Capture(_items, ship, name);
+ var armed = fit.Slots.Select((slot, index) => (slot, index))
+ .Where(entry => _cache.Get(entry.slot.Design).Behaviors.Any(behavior => behavior is WeaponData)).ToArray();
+ Assert.NotEmpty(armed);
+ Assert.True(armed.Length < fit.Slots.Count, "the generated fit needs gear besides weapons for this fixture to tell a Loadout from a fit");
+ var renumbered = armed.Select((entry, number) => (entry.index, number)).ToDictionary(entry => entry.index, entry => entry.number);
+ fit.WeaponGroups = fit.WeaponGroups.Select(group => group.Where(renumbered.ContainsKey).Select(index => renumbered[index]).ToArray()).ToArray();
+ fit.Slots = armed.Select(entry => entry.slot).ToList();
+ fit.Hull = _cache.RefOf(hull);
+ fit.HullProduct = default;
+ StoreLoadout(fit);
+ return fit;
+ }
+
+ private static void SetBoss(Faction faction, Loadout loadout) => faction.BossLoadout = new CultRecordRef(Loadouts.KeyOf(loadout.Name));
+
+ // The demo's antagonist gets a boss Loadout on a hull nothing sells: the shipped catalog authors none.
+ private Loadout GiveAntagonistABoss(Galaxy galaxy)
+ {
+ var boss = MakeBossLoadout(BossTestHull(), "Antagonist Boss");
+ SetBoss(Zhestokost(galaxy), boss);
+ return boss;
+ }
+
+ // What a ship carries, cell by cell, as a Loadout would name it: position, rotation, design, product.
+ private static string[] Cells(ItemManager items, EntityPack pack)
+ {
+ var captured = Loadouts.Capture(items, (Ship) EntitySerializer.Unpack(items, null, pack), "probe");
+ return captured.Slots.OrderBy(slot => slot.Position.x).ThenBy(slot => slot.Position.y)
+ .Select(slot => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{items.ItemData.Get(slot.Design).Name},{(slot.Product.IsSet() ? items.ItemData.Get(slot.Product).Name : "-")}").ToArray();
+ }
+
+ private static string[] LoadoutCells(CultCache cache, Loadout loadout) =>
+ loadout.Slots.OrderBy(slot => slot.Position.x).ThenBy(slot => slot.Position.y)
+ .Select(slot => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{cache.Get(slot.Design).Name},{(slot.Product.IsSet() ? cache.Get(slot.Product).Name : "-")}").ToArray();
+
+ private LoadoutGenerator GeneratorFor(Galaxy galaxy, ItemManager items, Faction faction, uint seed)
+ {
+ var random = new CultMath.Random(seed);
+ return new LoadoutGenerator(ref random, items, galaxy, galaxy.Exit, faction, .5f);
+ }
+
+ // What the pack holds, in order, by hull and faction: two zone packs that drew the same stream hold the same list.
+ private static string[] Roster(ItemManager items, ZonePack pack) =>
+ pack.Entities.Select(entity => $"{HullName(items, entity)}|{(entity.Faction.IsSet() ? entity.Faction.Key.ToString() : "-")}").ToArray();
+
[Fact]
public void Boss_spawns_once_in_boss_zone()
{
foreach (var seed in DemoSeeds.Take(3))
{
var galaxy = DemoGalaxy(seed);
+ GiveAntagonistABoss(galaxy);
var antagonist = Zhestokost(galaxy);
foreach (var zone in galaxy.Zones)
{
@@ -53,6 +128,7 @@ public sealed partial class RunStartTests
var boss = Assert.Single(bosses);
Assert.IsType(boss);
Assert.Same(antagonist, _cache.Get(boss.Faction));
+ Assert.Equal("Boss Test Hull", HullName(_items, boss));
// 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));
@@ -65,15 +141,215 @@ public sealed partial class RunStartTests
public void Boss_spawns_in_a_zone_that_is_not_ambient()
{
var galaxy = DemoGalaxy(1);
+ GiveAntagonistABoss(galaxy);
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);
}
+ // Faction.BossLoadout owns the boss. A decoy Loadout sorts before it by name and by key, so a resolver that takes the first
+ // Loadout in the cache, or any Loadout but the referenced one, flies the wrong hull.
+ [Fact]
+ public void Boss_flies_its_factions_boss_loadout()
+ {
+ var galaxy = DemoGalaxy(1);
+ var antagonist = Zhestokost(galaxy);
+ var sold = SoldShipHulls().OrderBy(hull => hull.Name, StringComparer.Ordinal).First();
+ StoreLoadout(new Loadout { Name = "Aaa Decoy", Hull = _cache.RefOf(sold) });
+ var loadout = GiveAntagonistABoss(galaxy);
+
+ Assert.Same(_cache.Get(antagonist.BossLoadout), Loadouts.BossOf(_cache, antagonist, out var refusal));
+ Assert.Equal(BossRefusal.None, refusal);
+ Assert.Equal("Antagonist Boss", loadout.Name);
+ var boss = Assert.Single(BossZonePack(galaxy, _items, galaxy.Exit).Entities, entity => entity.Boss);
+ Assert.IsType(boss);
+ Assert.Same(antagonist, _cache.Get(boss.Faction));
+ Assert.Equal("Boss Test Hull", HullName(_items, boss));
+ var again = Assert.Single(BossZonePack(galaxy, _items, galaxy.Exit).Entities, entity => entity.Boss);
+ Assert.Equal(Cells(_items, boss), Cells(_items, again));
+ }
+
+ // Ruling boss-gear-is-a-loadout: the boss carries exactly its Loadout, nothing drawn. The Loadout is weapons only, so any
+ // pickup, extra gear, cargo or consumable on the boss is a generator pass that should not run.
+ [Fact]
+ public void Boss_carries_exactly_its_loadouts_cells()
+ {
+ var galaxy = DemoGalaxy(1);
+ var loadout = GiveAntagonistABoss(galaxy);
+ Assert.True(loadout.Slots.Count > 1);
+ Assert.All(loadout.Slots, slot => Assert.True(slot.Product.IsSet(), "a captured fit pins every product"));
+
+ foreach (var seed in new uint[] { 7, 8 })
+ {
+ var items = LoggingItems(new List());
+ var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit, seed: seed).Entities, entity => entity.Boss);
+ Assert.Equal(LoadoutCells(_cache, loadout), Cells(items, boss));
+ Assert.All(boss.Equipment, entry => Assert.DoesNotContain(items.GetData(entry.item).Behaviors, behavior => behavior is PickupData));
+ Assert.Empty(boss.CargoContents.SelectMany(bay => bay));
+ Assert.Empty(boss.DockingBayContents.SelectMany(bay => bay));
+
+ var captured = Loadouts.Capture(items, (Ship) EntitySerializer.Unpack(items, null, boss), "probe");
+ string[][] Groups(Loadout l) => l.WeaponGroups.Select(group => group.Select(i => $"{l.Slots[i].Position.x},{l.Slots[i].Position.y}").OrderBy(x => x, StringComparer.Ordinal).ToArray()).Where(group => group.Length > 0).ToArray();
+ Assert.Equal(Groups(loadout), Groups(captured));
+ }
+ }
+
+ // Ruling loadout-product-optional-context-chooses: a design-only slot takes the brand the boss's faction can get. The design has
+ // an outsider's product that sorts first by name, so a chooser that takes the first product by name builds the wrong brand.
+ [Fact]
+ public void A_design_only_boss_slot_takes_a_brand_its_faction_can_get()
+ {
+ var galaxy = DemoGalaxy(1);
+ var antagonist = Zhestokost(galaxy);
+ var loadout = GiveAntagonistABoss(galaxy);
+ var outfitter = GeneratorFor(galaxy, _items, antagonist, 1);
+ var open = loadout.Slots.First(slot => _cache.GetAll().Any(product => product.Design.Key.Equals(slot.Design.Key) && outfitter.IsAvailable(product)));
+ var template = _cache.GetAll().First(product => product.Design.Key.Equals(open.Design.Key));
+ open.Product = default;
+ StoreLoadout(loadout);
+
+ var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly);
+ var outsider = MessagePack.MessagePackSerializer.Deserialize(MessagePack.MessagePackSerializer.Serialize(template, options), options);
+ outsider.Name = "Aaa Outsider";
+ foreach (var faction in _cache.GetAll().OrderBy(faction => faction.Name, StringComparer.Ordinal))
+ {
+ outsider.Manufacturer = _cache.RefOf(faction);
+ if (!outfitter.IsAvailable(outsider)) break;
+ }
+ Assert.False(outfitter.IsAvailable(outsider), "the outsider's maker is neither Zhestokost nor in its allegiance");
+ _cache.Upsert(outsider);
+
+ var gettable = _cache.GetAll().Where(product => product.Design.Key.Equals(open.Design.Key) && outfitter.IsAvailable(product)).Select(product => product.Name).ToArray();
+ Assert.NotEmpty(gettable);
+ foreach (var seed in new uint[] { 1, 2, 3, 4, 5 })
+ {
+ var items = LoggingItems(new List());
+ var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit, seed: seed).Entities, entity => entity.Boss);
+ var cell = Assert.Single(Cells(items, boss), c => c.StartsWith($"{open.Position.x},{open.Position.y},"));
+ var brand = cell.Split(',').Last();
+ Assert.NotEqual("Aaa Outsider", brand);
+ Assert.Contains(brand, gettable);
+ }
+ }
+
+ // No generated fallback (rulings boss-gear-is-a-loadout, demo-boss-hull-invented): the shipped catalog authors no boss Loadout,
+ // so Zhestokost has no boss. The refusal happens before anything is drawn.
+ [Fact]
+ public void Boss_without_a_boss_loadout_is_refused()
+ {
+ var galaxy = DemoGalaxy(1);
+ var antagonist = Zhestokost(galaxy);
+ Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal));
+ Assert.Equal(BossRefusal.Unset, refusal);
+
+ var log = new List();
+ var items = LoggingItems(log);
+ var pack = BossZonePack(galaxy, items, galaxy.Exit);
+ Assert.False(pack.BossSpawned);
+ Assert.DoesNotContain(pack.Entities, entity => entity.Boss);
+ Assert.Contains(log, line => line.Contains("Zhestokost has no boss") && line.Contains("sets no Faction.BossLoadout"));
+ Assert.Contains(log, line => line.Contains("Galaxy.Exit") && line.Contains("exit-without-boss"));
+ Assert.False(RunGoal.ExitOpen(new Zone(items, _planetSettings, pack, galaxy.Exit, galaxy)));
+
+ // Nothing was drawn for the refused boss: the zone is the zone with no boss mapped at all.
+ galaxy.BossZones.Clear();
+ var bare = BossZonePack(galaxy, LoggingItems(new List()), galaxy.Exit);
+ Assert.NotEmpty(bare.Entities);
+ Assert.Equal(Roster(items, bare), Roster(items, pack));
+ }
+
+ // A reference to a Loadout the catalog does not hold is refused like no reference, and the log names the field, not the value.
+ [Fact]
+ public void A_missing_boss_loadout_is_refused()
+ {
+ var galaxy = DemoGalaxy(1);
+ var antagonist = Zhestokost(galaxy);
+ antagonist.BossLoadout = new CultRecordRef(Loadouts.KeyOf("No Such Loadout"));
+ Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal));
+ Assert.Equal(BossRefusal.Missing, refusal);
+
+ var log = new List();
+ var items = LoggingItems(log);
+ var pack = BossZonePack(galaxy, items, galaxy.Exit);
+ Assert.False(pack.BossSpawned);
+ Assert.DoesNotContain(pack.Entities, entity => entity.Boss);
+ Assert.Contains(log, line => line.Contains("Zhestokost has no boss") && line.Contains("names a Faction.BossLoadout that is not in the catalog"));
+ Assert.DoesNotContain(log, line => line.Contains("No Such Loadout") || line.Contains("sets no"));
+ Assert.DoesNotContain(log, line => line.Contains("Zhestokost has no boss") && line.Contains("No Such"));
+ }
+
+ // A boss Loadout whose slots do not fit gives no boss: the log names the Loadout and every fit failure, and the gate stays sealed.
+ [Fact]
+ public void An_unbuildable_boss_loadout_spawns_no_boss()
+ {
+ var galaxy = DemoGalaxy(1);
+ var loadout = GiveAntagonistABoss(galaxy);
+ loadout.Slots[0].Position = new int2(999, 999);
+ loadout.Slots[1].Position = new int2(998, 998);
+ StoreLoadout(loadout);
+
+ var log = new List();
+ var items = LoggingItems(log);
+ var pack = BossZonePack(galaxy, items, galaxy.Exit, ambient: false);
+ Assert.False(pack.BossSpawned);
+ Assert.DoesNotContain(pack.Entities, entity => entity.Boss);
+ var refusal = Assert.Single(log, line => line.Contains("Antagonist Boss") && line.Contains("does not fit"));
+ Assert.Equal(2, Regex.Matches(refusal, "does not fit").Count);
+ Assert.Contains("; ", refusal);
+ Assert.False(RunGoal.ExitOpen(new Zone(items, _planetSettings, pack, galaxy.Exit, galaxy)));
+ }
+
+ // The existing market rule (ruling off-market-is-general): an item is on offer iff a product of it is. A hull no product makes
+ // is never drawn, stocked or chosen, yet the boss whose Loadout names it flies it.
+ [Fact]
+ public void A_hull_no_product_makes_is_never_on_offer()
+ {
+ var galaxy = DemoGalaxy(1);
+ var hull = BossTestHull();
+ GiveAntagonistABoss(galaxy);
+ Assert.DoesNotContain(hull, LoadoutGenerator.SoldHulls(_cache));
+ var items = LoggingItems(new List());
+
+ for (uint seed = 1; seed <= 20; seed++)
+ {
+ var generator = GeneratorFor(galaxy, items, Zhestokost(galaxy), seed);
+ var ship = generator.GenerateShipLoadout();
+ Assert.NotNull(ship);
+ Assert.NotEqual(hull.Name, HullName(items, ship));
+ var station = generator.GenerateStationLoadout();
+ Assert.DoesNotContain(EntitySerializer.Items(station), item => items.GetData(item).Name == hull.Name);
+ Assert.Null(generator.ChooseProduct(hull));
+ Assert.Null(generator.GenerateShipLoadout(candidate => candidate == hull));
+ }
+
+ var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
+ Assert.Equal(hull.Name, HullName(items, boss));
+ }
+
+ // PlaceFactionsMain reads BossOf: with no reference set there is no boss zone, a reference to nothing places none, and the one
+ // faction whose boss Loadout resolves is the one entry though another faction sorts first.
+ [Fact]
+ public void Main_galaxy_bosses_are_factions_with_a_boss_loadout()
+ {
+ Assert.Empty(RunStart.Generate(new MainGalaxy(), Inputs(() => 1)).BossZones);
+
+ var factions = _cache.GetAll().OrderBy(faction => faction.Name, StringComparer.Ordinal).ToArray();
+ factions[0].BossLoadout = new CultRecordRef(Loadouts.KeyOf("No Such Loadout"));
+ Assert.Empty(RunStart.Generate(new MainGalaxy(), Inputs(() => 1)).BossZones);
+
+ var armed = MakeBossLoadout(BossTestHull(), "Main Boss");
+ SetBoss(factions[1], armed);
+ var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1));
+ var bossFaction = Assert.Single(galaxy.BossZones.Keys);
+ Assert.Equal(factions[1].Name, bossFaction.Name);
+ Assert.NotNull(Loadouts.BossOf(_cache, bossFaction, out _));
+ }
+
[Fact]
public void Exit_sealed_until_boss_dies()
{
var galaxy = DemoGalaxy(1);
+ GiveAntagonistABoss(galaxy);
var exit = ZoneOf(galaxy, galaxy.Exit);
var boss = Assert.Single(exit.Entities, entity => entity.IsBoss);
Assert.True(RunGoal.BossAlive(exit));
@@ -96,6 +372,7 @@ public sealed partial class RunStartTests
{
var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => 1));
Assert.True(failures.Count == 0, string.Join("; ", failures));
+ GiveAntagonistABoss(galaxy);
galaxy.Entrance.Contents = arena;
galaxy.Exit.Contents = ZoneOf(galaxy, galaxy.Exit);
var before = Assert.Single(galaxy.Exit.Contents.Entities, entity => entity.IsBoss);
@@ -131,7 +408,7 @@ public sealed partial class RunStartTests
}
}
- // The same for a demo exit whose boss could not be generated: the zone logs the gap by name and the gate stays sealed.
+ // The same for a demo exit whose boss could not be built: 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()
{
@@ -152,10 +429,10 @@ public sealed partial class RunStartTests
public void Exit_open_only_in_the_exit_zone()
{
var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1));
- // The shipped catalog sets no BossHull outside the demo's antagonist, so the main galaxy places no boss: give a
- // faction one and a boss zone that is not the exit, as the generator would for a faction with a boss hull.
+ // The shipped catalog sets no boss Loadout, so the main galaxy places no boss: give a faction one and a boss zone that is
+ // not the exit, as the generator would for a faction with a boss Loadout.
var faction = galaxy.Factions.First();
- faction.BossHull = _cache.RefOf(SoldShipHulls().First());
+ SetBoss(faction, MakeBossLoadout(SoldShipHulls().First(), "Gate Boss"));
var bossZone = galaxy.Zones.First(zone => zone != galaxy.Exit);
galaxy.BossZones[faction] = bossZone;
var zone = ZoneOf(galaxy, bossZone);
@@ -172,6 +449,7 @@ public sealed partial class RunStartTests
public void Exit_open_after_save_of_a_slain_boss()
{
var galaxy = DemoGalaxy(1);
+ GiveAntagonistABoss(galaxy);
var exit = ZoneOf(galaxy, galaxy.Exit);
Assert.Single(exit.Entities, entity => entity.IsBoss).HeatstrokeDeath.OnNext(Unit.Default);
var repacked = exit.PackZone();
From ad503059e5f75d3ff298ff90bf7673c4f7340fc9 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 21:18:20 +0200
Subject: [PATCH 04/19] RunGoalTests: LoadoutCells reads the fixture's cache
Co-Authored-By: Claude Sonnet 5.5
---
tests/Aetheria.Shared.Tests/RunGoalTests.cs | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
index d2bc84a8f..9db173508 100644
--- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs
+++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
@@ -98,9 +98,9 @@ public sealed partial class RunStartTests
.Select(slot => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{items.ItemData.Get(slot.Design).Name},{(slot.Product.IsSet() ? items.ItemData.Get(slot.Product).Name : "-")}").ToArray();
}
- private static string[] LoadoutCells(CultCache cache, Loadout loadout) =>
+ private string[] LoadoutCells(Loadout loadout) =>
loadout.Slots.OrderBy(slot => slot.Position.x).ThenBy(slot => slot.Position.y)
- .Select(slot => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{cache.Get(slot.Design).Name},{(slot.Product.IsSet() ? cache.Get(slot.Product).Name : "-")}").ToArray();
+ .Select(slot => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{_cache.Get(slot.Design).Name},{(slot.Product.IsSet() ? _cache.Get(slot.Product).Name : "-")}").ToArray();
private LoadoutGenerator GeneratorFor(Galaxy galaxy, ItemManager items, Faction faction, uint seed)
{
@@ -183,7 +183,7 @@ public sealed partial class RunStartTests
{
var items = LoggingItems(new List());
var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit, seed: seed).Entities, entity => entity.Boss);
- Assert.Equal(LoadoutCells(_cache, loadout), Cells(items, boss));
+ Assert.Equal(LoadoutCells(loadout), Cells(items, boss));
Assert.All(boss.Equipment, entry => Assert.DoesNotContain(items.GetData(entry.item).Behaviors, behavior => behavior is PickupData));
Assert.Empty(boss.CargoContents.SelectMany(bay => bay));
Assert.Empty(boss.DockingBayContents.SelectMany(bay => bay));
From d2ec3a6678f9c549f956bfdb6c80db624ff5bdb0 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 21:18:49 +0200
Subject: [PATCH 05/19] RunGoalTests: using GameCult.Caching
Co-Authored-By: Claude Sonnet 5.5
---
tests/Aetheria.Shared.Tests/RunGoalTests.cs | 1 +
1 file changed, 1 insertion(+)
diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
index 9db173508..8c283213f 100644
--- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs
+++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
@@ -8,6 +8,7 @@ using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using CultMath;
+using GameCult.Caching;
using UniRx;
using Xunit;
From 3ad3fcc505288f926dbc7ce63c39eddb33d9df6e Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 21:19:14 +0200
Subject: [PATCH 06/19] RunGoalTests: using GameCult.Caching.MessagePack
Co-Authored-By: Claude Sonnet 5.5
---
tests/Aetheria.Shared.Tests/RunGoalTests.cs | 1 +
1 file changed, 1 insertion(+)
diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
index 8c283213f..ef714a5e4 100644
--- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs
+++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
@@ -9,6 +9,7 @@ using System.Linq;
using System.Text.RegularExpressions;
using CultMath;
using GameCult.Caching;
+using GameCult.Caching.MessagePack;
using UniRx;
using Xunit;
From f235eea3d98ad2b0bd886aed97c1885fa1954fe3 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 21:22:05 +0200
Subject: [PATCH 07/19] Exit_open_only_in_the_exit_zone: the faction with the
boss Loadout is one that can get its hull's brand
Co-Authored-By: Claude Sonnet 5.5
---
tests/Aetheria.Shared.Tests/RunGoalTests.cs | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
index ef714a5e4..804576a46 100644
--- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs
+++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
@@ -432,9 +432,12 @@ public sealed partial class RunStartTests
{
var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1));
// The shipped catalog sets no boss Loadout, so the main galaxy places no boss: give a faction one and a boss zone that is
- // not the exit, as the generator would for a faction with a boss Loadout.
- var faction = galaxy.Factions.First();
- SetBoss(faction, MakeBossLoadout(SoldShipHulls().First(), "Gate Boss"));
+ // not the exit, as the generator would for a faction with a boss Loadout. The Loadout leaves its hull's brand to the
+ // context, so the faction is one that can get a product of the hull: its own maker.
+ var hull = SoldShipHulls().OrderBy(h => h.Name, StringComparer.Ordinal).First();
+ var maker = _cache.Get(_cache.GetAll().First(product => _cache.Get(product.Design) == hull).Manufacturer);
+ var faction = galaxy.Factions.Single(candidate => candidate.Name == maker.Name);
+ SetBoss(faction, MakeBossLoadout(hull, "Gate Boss"));
var bossZone = galaxy.Zones.First(zone => zone != galaxy.Exit);
galaxy.BossZones[faction] = bossZone;
var zone = ZoneOf(galaxy, bossZone);
From d8afb9d18478847d150c567ea2481beabfa59bdf Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 21:24:46 +0200
Subject: [PATCH 08/19] Exit_open_only_in_the_exit_zone: a failed boss build
reports the log
Co-Authored-By: Claude Sonnet 5.5
---
tests/Aetheria.Shared.Tests/RunGoalTests.cs | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
index 804576a46..d4e865d9e 100644
--- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs
+++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
@@ -440,8 +440,10 @@ public sealed partial class RunStartTests
SetBoss(faction, MakeBossLoadout(hull, "Gate Boss"));
var bossZone = galaxy.Zones.First(zone => zone != galaxy.Exit);
galaxy.BossZones[faction] = bossZone;
- var zone = ZoneOf(galaxy, bossZone);
- Assert.True(zone.Pack.BossSpawned);
+ var log = new List();
+ var items = LoggingItems(log);
+ var zone = new Zone(items, _planetSettings, BossZonePack(galaxy, items, bossZone), bossZone, galaxy);
+ Assert.True(zone.Pack.BossSpawned, string.Join("; ", log));
Assert.NotEmpty(zone.Entities.Where(entity => entity.IsBoss));
foreach (var boss in zone.Entities.Where(entity => entity.IsBoss).ToArray())
boss.HeatstrokeDeath.OnNext(Unit.Default);
From 40db8291d874536152584fc1d0409f0787ec8dbc Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 21:25:21 +0200
Subject: [PATCH 09/19] Exit_open_only_in_the_exit_zone: the gate boss pins its
hull's brand, so any faction builds it
Co-Authored-By: Claude Sonnet 5.5
---
tests/Aetheria.Shared.Tests/RunGoalTests.cs | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
index d4e865d9e..34eb50c69 100644
--- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs
+++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
@@ -432,12 +432,14 @@ public sealed partial class RunStartTests
{
var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1));
// The shipped catalog sets no boss Loadout, so the main galaxy places no boss: give a faction one and a boss zone that is
- // not the exit, as the generator would for a faction with a boss Loadout. The Loadout leaves its hull's brand to the
- // context, so the faction is one that can get a product of the hull: its own maker.
+ // not the exit, as the generator would for a faction with a boss Loadout. This Loadout pins its hull's brand, so any faction
+ // builds it whatever it can get.
+ var faction = galaxy.Factions.First();
var hull = SoldShipHulls().OrderBy(h => h.Name, StringComparer.Ordinal).First();
- var maker = _cache.Get(_cache.GetAll().First(product => _cache.Get(product.Design) == hull).Manufacturer);
- var faction = galaxy.Factions.Single(candidate => candidate.Name == maker.Name);
- SetBoss(faction, MakeBossLoadout(hull, "Gate Boss"));
+ var gate = MakeBossLoadout(hull, "Gate Boss");
+ gate.HullProduct = _cache.RefOf(_cache.GetAll().OrderBy(product => product.Name, StringComparer.Ordinal).First(product => _cache.Get(product.Design) == hull));
+ StoreLoadout(gate);
+ SetBoss(faction, gate);
var bossZone = galaxy.Zones.First(zone => zone != galaxy.Exit);
galaxy.BossZones[faction] = bossZone;
var log = new List();
From f17b04ec1a9b8b2689972f29ce225278b9879eb8 Mon Sep 17 00:00:00 2001
From: soul
Date: Sun, 18 Oct 2026 21:31:57 +0200
Subject: [PATCH 10/19] FlightTests: Faction's wire array ends at slot 22
(BossLoadout), so it is 23 long
Co-Authored-By: Claude Sonnet 5.5
---
tests/Aetheria.Shared.Tests/FlightTests.cs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs
index b9ee7c09e..dc3f4da10 100644
--- a/tests/Aetheria.Shared.Tests/FlightTests.cs
+++ b/tests/Aetheria.Shared.Tests/FlightTests.cs
@@ -910,7 +910,7 @@ public sealed class FlightTests : IDisposable
Assert.Equal(turns, turn != 0f);
}
- // Faction.Doctrine rides at wire slot 17; slot 16 is reserved for Relations; Places rides at 21, which sets the array's length.
+ // Faction.Doctrine rides at wire slot 17; slot 16 is reserved for Relations; BossLoadout rides at 22, which sets the array's length.
// Kills: the key moved.
[Fact]
public void TheFactionDoctrineRidesAtWireSlot17()
@@ -920,7 +920,7 @@ public sealed class FlightTests : IDisposable
var slots = MessagePack.MessagePackSerializer.Deserialize