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(MessagePack.MessagePackSerializer.Serialize(faction, CultDocumentMessagePackSerialization.OptionsFor(typeof(Faction).Assembly))); - Assert.Equal(22, slots.Length); // 19 is Assembly, 21 is Places + Assert.Equal(23, slots.Length); // 19 is Assembly, 21 is Places, 22 is BossLoadout Assert.Null(slots[16]); var doctrine = Assert.IsType(slots[17]); Assert.Equal(6f, Convert.ToSingle(doctrine[1])); From 195039763094f5c174bfc3fe2c99a1a4fa098223 Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 00:04:45 +0200 Subject: [PATCH 11/19] RunGoalTests: Soul's s1 probes as behaviour tests (referenced Loadout wins on a shared hull, refusals fly no catalog Loadout, no maker fallback, boss path draws only what Materialize draws) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 76 +++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 34eb50c69..8f7977943 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -553,4 +553,80 @@ public sealed partial class RunStartTests Assert.Equal(0, gate.x, 3); Assert.Equal(zone.Pack.Radius * ratio, gate.y, 2); } + + // Soul's probes of cut boss-loadout pass s1, kept as behaviour: the rules the first eight tests let a mutant past. + + [Fact] + public void The_boss_is_the_loadout_its_faction_names_even_when_another_loadout_shares_its_hull() + { + var galaxy = DemoGalaxy(1); + var decoy = MakeBossLoadout(BossTestHull(), "Aaa Same Hull"); + decoy.Slots = decoy.Slots.Take(1).ToList(); + decoy.WeaponGroups = new int[0][]; + StoreLoadout(decoy); + var boss = GiveAntagonistABoss(galaxy); + Assert.True(boss.Slots.Count > decoy.Slots.Count); + var pack = Assert.Single(BossZonePack(galaxy, _items, galaxy.Exit).Entities, e => e.Boss); + Assert.Equal(LoadoutCells(boss), Cells(_items, pack)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void A_refused_boss_flies_no_catalog_loadout_and_draws_nothing(bool dangling) + { + var galaxy = DemoGalaxy(1); + var hull = SoldShipHulls().OrderBy(h => h.Name, StringComparer.Ordinal).First(); + StoreLoadout(new Loadout { Name = "Aaa Decoy", Hull = _cache.RefOf(hull) }); + if (dangling) Zhestokost(galaxy).BossLoadout = new CultRecordRef(Loadouts.KeyOf("CANARY-7f3a")); + var log = new List(); + var items = LoggingItems(log); + var pack = BossZonePack(galaxy, items, galaxy.Exit); + var next = items.Random.NextFloat(); + Assert.False(pack.BossSpawned); + Assert.DoesNotContain(pack.Entities, e => e.Boss); + Assert.DoesNotContain(log, l => l.Contains("CANARY")); + galaxy.BossZones.Clear(); + var bareItems = LoggingItems(new List()); + var bare = BossZonePack(galaxy, bareItems, galaxy.Exit); + Assert.Equal(Roster(bareItems, bare), Roster(items, pack)); + Assert.Equal(bareItems.Random.NextFloat(), next); + } + + [Fact] + public void A_design_only_boss_slot_whose_design_only_outsiders_make_refuses_the_boss() + { + var galaxy = DemoGalaxy(1); + var faction = Zhestokost(galaxy); + var loadout = GiveAntagonistABoss(galaxy); + var outfitter = GeneratorFor(galaxy, _items, faction, 1); + var open = loadout.Slots.First(slot => _cache.GetAll().Any(p => p.Design.Key.Equals(slot.Design.Key) && outfitter.IsAvailable(p))); + var outsider = _cache.GetAll().OrderBy(f => f.Name, StringComparer.Ordinal).First(f => f.Name != faction.Name && !faction.Allegiance.ContainsKey(_cache.RefOf(f))); + foreach (var p in _cache.GetAll().Where(p => p.Design.Key.Equals(open.Design.Key))) p.Manufacturer = _cache.RefOf(outsider); + Assert.Null(outfitter.ChooseProduct(_cache.Get(open.Design))); + open.Product = default; + StoreLoadout(loadout); + var log = new List(); + var pack = BossZonePack(galaxy, LoggingItems(log), galaxy.Exit, ambient: false); + Assert.False(pack.BossSpawned); + Assert.DoesNotContain(pack.Entities, e => e.Boss); + Assert.Contains(log, l => l.Contains("Antagonist Boss") && l.Contains("no available product")); + } + + [Fact] + public void The_boss_path_draws_only_what_building_its_loadout_draws() + { + var galaxy = DemoGalaxy(1); + var loadout = GiveAntagonistABoss(galaxy); + var withBoss = LoggingItems(new List()); + Assert.True(BossZonePack(galaxy, withBoss, galaxy.Exit, ambient: false).BossSpawned); + var next = withBoss.Random.NextFloat(); + galaxy.BossZones.Clear(); + var expected = LoggingItems(new List()); + BossZonePack(galaxy, expected, galaxy.Exit, ambient: false); + var failures = new List(); + Assert.NotNull(Loadouts.Materialize(expected, null, loadout, GeneratorFor(galaxy, expected, Zhestokost(galaxy), 7).ChooseProduct, failures)); + Assert.Empty(failures); + Assert.Equal(expected.Random.NextFloat(), next); + } } From 35c93dd6ab34362a2dc1c1a02e668e26726aa24c Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 00:13:44 +0200 Subject: [PATCH 12/19] aetheria-release map: body facts and rationale for cut-package-agent-hull-guards Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-release-map.md | 89 +++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index 50128adac..f3e709f3d 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -11337,7 +11337,7 @@ PIDs are in `S\out\pids.log`, the first 3404 and the last 20348. It then writes | "Agent-built" status | Presence of `hull.scad`. No field. | Ends when the file is deleted. | Derived. "Which hulls are agent-built" is a directory listing. | | Zone meshes and anchor objects in Blender | Marked `aetheria.hull_source`, with ids `aetheria.id`. | Replaced on every Build. Never hand-edited. | Build From Hull Source. Build refuses a collection holding meshes it did not make. | | `ship.glb` and ShipAuthoring anchors | `ShipAuthoring.ModelAsset`, with anchor ids equal to mount ids. | Rewritten by every Package, whether from an agent hull or operator art. The ids are stable across the swap, so loadouts and saves survive it. | Package Ship, the only writer. | -| HullData layout (schematic) | `mod-hull:`. | Authored through Save Layout. Linked to the model by mount name only. | The schematic author: agents for agent hulls, the operator for hers. Never Build. AH-B3 is the open conflict. | +| HullData layout (schematic) | `mod-hull:`. | Authored through Save Layout. Linked to the model by mount name only. | The schematic author: agents for agent hulls, the operator for hers. Never Build. AH-B3 is closed by cut `package-agent-hull-guards` (section PG). | | Hero asset | A package with no `hull.scad` that is named as its faction's hero. | Set by an operator ruling (`agent-hull-hero-assets`). | The operator. | ### Questions admitted @@ -11369,3 +11369,90 @@ Neither blocks the first cut. - The model read was one agent judging its own sheets. - Lucent and AU languages are not drafted. - The hero comparison is weak until the heroes exist. + +## Package on agent hulls (PG) + +Imagination pass `imag-ag7-hull-guards`, session `self-2026-10-19-ag7`, 2026-10-19, pinned to Aetheria `origin/master` +5acbaa1b (worktree `C:\aeth-wt\ag7-hull-guards-imag`). It maps cut `package-agent-hull-guards` (spec +`aetheria-release:cut_spec:cut-package-agent-hull-guards.r1`) for follow-ups `package-agent-hull-guards` and +`hull-copy-refusals` and finding `cut-agent-hull-source.s2.hull-names-its-own-faction`. It rests on rulings +`remove-package-schematic-derivation`, `hull-faction-is-product-maker`, `schematic-and-model-are-separate-spaces` and +`package-carries-product`. + +### Body facts (source reads and one probe at 5acbaa1b) + +- **PG-B1. The derivation has three writers.** `_derived_position` (`__init__.py:253`) maps a model point to a cell + through `hull_grid.cell_at` and `mounted_position`. Add Weapon (`_add_mount`, :302) and Mirror Mount (:359) call it. + Package calls `_plan_mounts` (:409), which plans every weapon row's Position, and then `_apply_mounts` (:432), which + writes those Positions, sets each mount object's yaw from its row's Rotation, and saves the layout. So Package writes + each space from the other: the schematic from the model, and the model's yaw from the schematic. Package would turn + a hull source's turned mount (smoke step 1.10, yaw 90) back to its row's facing. +- **PG-B2. After the delete, `cell_at`, `mounted_position`, `mounted_cell` and `_centre_offset` have no caller** outside + `tests/test_hull_grid.py` (grep of `tools/blender`). `cell_centre`, `mirror_position`, `mirror_footprint`, + `mirror_rotation` and `facing_yaw` keep callers (Rasterise, the grid drawing, `_frame_ship`, Mirror Mount, Add + Weapon). +- **PG-B3. A product names its maker by an opaque Faction record key.** `FactionProductData.Manufacturer` is + `CultRecordRef` at `[Key(4)]` (`Assets/Scripts/ServerShared/FactionProduct.cs:26`), and + `CultRecordRefFormatter` writes it as the key string. Probe `probe_factions.py` (cultcache-py from + `F:\Projects\CultLib\packages\cultcache-py\src`, read of `GameData/Aetheria.cc`) listed 16 `aetheria.faction` + records. Among them are Zhestokost `16bad4f9-7fb5-41c5-8da0-dc76709eb7d3`, Pirates + `1d8a168def994a76a1ce7cb18f59c038` and Rossum & Douglas `adfb9b08-aabc-45c6-9586-915698a9c4b0`. Product makers are + these keys: Juggernaut's, for example, is Zhestokost's. The language files are named by slug (`zhestokost`, `pirate`, + `rnd`), and nothing maps a slug to a Faction key today. +- **PG-B4. New Ship makes no product.** `AetherDb ship-authoring create` writes only a HullData and a ShipAuthoring + (`tools/AetherDb/ShipAuthoringCommands.cs:56`). `ship-authoring product --name ` (:94) + writes a product, resolving the maker by Faction `Name` or `ShortName` (:136). The agent-hull smoke packages ships + that have no product (Soul s2 noted it). +- **PG-B5. The duplicate-id refusal names nothing.** `ship_cc.replace_visual` raises "An anchor ID is used twice" + (`ship_cc.py:171`), and it does so after Package has framed the ship and written `ship.glb`. Build never looks at + ids, because `_own_meshes` skips every object that carries `aetheria.role`. The add-on already has a way to point at + offenders without echoing input: `_refuse_selecting` (`__init__.py:1139`), which `_refuse_loose_meshes` uses. +- **PG-B6. The tests split by host.** `tools/blender/tests/test_*.py` import `ship_cc`, `hull_grid` and `hull_source` + by path with no bpy (`CULTLIB_PACKAGES=/packages python -m unittest discover -s tools/blender/tests`), so + they run on Yggdrasil in a python image with msgpack. The `smoke_*.py` files need Blender 5.2's bpy and run + headless (`blender --background --factory-startup --python-use-system-env --python ...`). They run on Starfire, one + process at a time, as Soul ran them for agent-hull-source. `ShipSchemaPinTests` is C# and pins the slot constants of + `ship_cc.py` by regex. + +### Rationale + +- **The language file names its maker.** The ruling needs a comparison between the brief's faction (a language slug) + and the product's maker (a Faction key). Matching names (`Pirates` against `pirate`, `Rossum & Douglas` against `rnd`) + would be a fuzzy rule. A table in `hull_source.py` would be a second owner of a fact the language file already + stands for. So each `factions/.scad` gains its Faction record key as an eighth element of its `lang` echo. + The file that owns the language then also says whose language it is, and the existing "includes its language + unchanged" check covers that element too. +- **Build records the maker it judged by, and Package compares that record.** Re-reading `hull.scad` at Package would + compare the brief as it is now, which may differ from the brief Build judged: an author could edit the brief after + Build and then Package. Build already records what it made (`aetheria.hull_built`), so it also records + `aetheria.hull_maker`, the maker key of the language it judged against. Package then refuses unless the package + holds at least one product and every product's Manufacturer is that key. The check runs whenever the package still + ships Build's zones. When her art takes over (the same predicate `_retire_hull_source` uses), the brief no longer + governs and no check runs. An operator could edit the record by hand, since it is a custom property; that is the same + trust as the fingerprint record, and a hull source cannot reach it. +- **This cut fixes the finding.** After it, the faction a shipped hull was judged by is its product's maker. Drafting + under another faction stays possible, as the ruling allows. A separate cut would only repeat the Package edit. +- **Mirror Mount mirrors each space within itself.** The twin row's Position is `mirror_position` of the row's own + Position and footprint on the layout's width. The twin object stands at the reflection of the mount's model point + across Ship Root's X = 0. Mirror Mount refuses when either twin would coincide with its source: the same Position, + or a model point within 1 mm of X = 0. `CENTRELINE` (half a schematic cell, measured in model metres) was a + cross-space constant, so it goes. +- **Add Weapon's yaw stays a creation default.** One Rotation choice sets both the new row and the new empty's facing, + once. Nothing re-derives either afterwards. The ruling took only the Position derivation out of Add Weapon, so the + yaw stays. +- **Copies are refused by id, in Build and Package alike.** One helper refuses while two role-bearing objects share an + `aetheria.id`. It selects them and names a count, never an object. That covers the collider, emitter, radiator and + mount copies Soul built (s2 probes A2 and A5 to A7). The duplicate check in `ship_cc` stays, as the file writer's + invariant. + +### Model page rows + +| Kind | Identity | Lifecycle | Authority | +|---|---|---|---| +| A language's maker: the eighth element of `lang` in `factions/.scad` | The Faction record key in `GameData/Aetheria.cc`. | Written when the language is drafted. Changes only if the language is reassigned to another faction. | The language file, operator-approved like the rest of it. | +| `aetheria.hull_maker` on a ship collection | The maker key of the language Build judged against. | Set by every Build. Deleted with `aetheria.hull_built` when Package retires `hull.scad`. | Build From Hull Source writes it; Package reads it. | +| HullData layout Position | As before. | Authored in the layout panel only. Add Weapon creates a row at the default Position. | The schematic author. Package and the mount helpers no longer write it (this cut closes AH-B3). | + +### Admitted + +- `aetheria-release:cut_spec:cut-package-agent-hull-guards.r1`. From af993acf07f6d427c31ed07126eab91743fbaf64 Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 00:21:11 +0200 Subject: [PATCH 13/19] ZoneGenerator: an unbuildable boss Loadout restores itemManager.Random, so the zone's later draws equal the no-boss zone's Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/ZoneGenerator.cs | 2 ++ tests/Aetheria.Shared.Tests/RunGoalTests.cs | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 089ce3026..3db7c07ea 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -433,9 +433,11 @@ public static class ZoneGenerator continue; } var failures = new List(); + var streamBefore = itemManager.Random; var entity = Loadouts.Materialize(itemManager, null, loadout, GetLoadoutGenerator(bossFaction).ChooseProduct, failures); if (entity == null) { + itemManager.Random = streamBefore; // a failed fit has drawn lots; an unbuildable boss draws nothing, as a refused one does itemManager.Log($"{bossFaction.Name}'s boss Loadout {loadout.Name} cannot be built: {string.Join("; ", failures)}"); continue; } diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 8f7977943..65ccd65a5 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -629,4 +629,22 @@ public sealed partial class RunStartTests Assert.Empty(failures); Assert.Equal(expected.Random.NextFloat(), next); } + + [Fact] + public void An_unbuildable_boss_loadout_draws_nothing() + { + var galaxy = DemoGalaxy(1); + var loadout = GiveAntagonistABoss(galaxy); + loadout.Slots[0].Position = new int2(999, 999); + StoreLoadout(loadout); + var items = LoggingItems(new List()); + var pack = BossZonePack(galaxy, items, galaxy.Exit); + Assert.False(pack.BossSpawned); + var next = items.Random.NextFloat(); + galaxy.BossZones.Clear(); + var bareItems = LoggingItems(new List()); + var bare = BossZonePack(galaxy, bareItems, galaxy.Exit); + Assert.Equal(Roster(bareItems, bare), Roster(items, pack)); + Assert.Equal(bareItems.Random.NextFloat(), next); + } } From 8ae052bb47dd104d6abc65239d28a540dcebb319 Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 01:22:38 +0200 Subject: [PATCH 14/19] Materialize refuses an unbuildable Loadout before minting or drawing; delete the boss item-stream restore Materialize now settles what the catalog alone decides, tries the cells on a throwaway ItemManager (own ledger and stream, designs built unbranded), and only then asks the chooser and mints lots. ZoneGenerator's copy-and-restore of itemManager.Random is deleted, and the boss faction's LoadoutGenerator is created on the first choice instead of as an argument. Resolve keeps its signature and is Settle then Build. Tests: an unbuildable boss zone equals the no-boss zone (serialized pack, ledger, roster and gear, next item draw) for one failure, several failures and design-only slots, across every faction as boss faction. Not yet built or run when committed. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Loadout.cs | 143 ++++++++++++++----- Assets/Scripts/ServerShared/ZoneGenerator.cs | 4 +- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 71 +++++++-- 3 files changed, 166 insertions(+), 52 deletions(-) diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index 592c827e2..eec005f46 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -128,15 +128,19 @@ public static class Loadouts public const float UnbrandedQuality = .95f; // All-or-nothing: returns an entity only when every design resolved and fitted, the loadout has at most - // 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 + // WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure of a stage is listed; on any + // failure nothing is returned and the zone and cache are unchanged. The stages run in the order that puts refusal + // first: what the catalog alone decides (designs, pinned products, hull kind, weapon groups), then whether the cells + // fit, tried on a throwaway item manager with its own ledger and stream, and only then the choice of a design-only + // slot's product (choose may draw) and the minting of lots. So a loadout that cannot be built mints no lot in the live + // ledger and draws nothing from itemManager.Random or from choose. The one exception is a design-only slot with no + // available product, which only choose can tell: it is found after an earlier slot's choice, and it too mints nothing. + // 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, 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, - // so the next commit drops them. A stated quality mints every lot of the fit at it, drawing nothing (a scenario states + // padded with empty groups. A stated quality mints every lot of the fit at it, drawing nothing (a scenario states // one for a fit its checks rest on). public static Entity Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout, Func choose, List failures, float? quality = null) @@ -144,11 +148,11 @@ public static class Loadouts var cache = itemManager.ItemData; var reported = failures.Count; - var hullBuild = Resolve(itemManager, loadout.Hull, loadout.HullProduct, choose, "hull", failures, quality); + var hullSettled = Settle(itemManager, loadout.Hull, loadout.HullProduct, "hull", failures); var hullData = cache.Get(loadout.Hull); if (hullData is { HullType: HullType.Station }) 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 slotsSettled = loadout.Slots.Select(slot => Settle(itemManager, slot.Design, slot.Product, Cell(slot), failures)).ToArray(); var groups = loadout.WeaponGroups ?? new int[0][]; var groupCount = itemManager.GameplaySettings.WeaponGroupCount; if (groups.Length > groupCount) failures.Add($"weapon groups: {groups.Length} groups, at most {groupCount}"); @@ -160,12 +164,43 @@ public static class Loadouts } if (failures.Count > reported) return null; + // The cells fit or they do not whatever brand a design-only slot takes: the shape is the design's. Tried on its own + // item manager, so nothing is minted into the live ledger and nothing is drawn. + var scratch = new ItemManager(cache, new ProvenanceLedger(), itemManager.GameplaySettings, _ => { }); + Assemble(scratch, null, hullData, loadout, Unbranded(scratch, hullSettled), slotsSettled.Select(settled => Unbranded(scratch, settled)).ToArray(), failures, out _); + if (failures.Count > reported) return null; + + var hullBuild = Build(itemManager, hullSettled, choose, quality, failures); + var slotBuilds = slotsSettled.Select(settled => Build(itemManager, settled, choose, quality, failures)).ToArray(); + if (failures.Count > reported) return null; + + var entity = Assemble(itemManager, liveZone, hullData, loadout, hullBuild, slotBuilds, failures, out var equipped); + if (entity == null) return null; + + var built = new (List weapons, List items)[groupCount]; + for (var g = 0; g < groupCount; g++) + { + var items = g < groups.Length ? groups[g].Select(i => equipped[i]).ToList() : new List(); + built[g] = (items.Select(item => item.GetBehavior()).ToList(), items); + } + entity.WeaponGroups = built; + return entity; + } + + // The hull, then each slot's gear, made by the given builders and equipped to the loadout's cells: the one placement of + // a loadout, run once on the throwaway manager to decide the fit and once on the live one to build. Null with every + // misfit listed when any cell does not take its gear. + private static Entity Assemble(ItemManager itemManager, Zone liveZone, HullData hullData, Loadout loadout, + Func hullBuild, Func[] slotBuilds, List failures, out EquippedItem[] equipped) + { + var reported = failures.Count; + var cache = itemManager.ItemData; var hull = (EquippableItem) hullBuild(); var settings = itemManager.GameplaySettings.DefaultEntitySettings; Entity entity = hullData.HullType == HullType.Turret ? new OrbitalEntity(itemManager, liveZone, hull, default, settings) : new Ship(itemManager, liveZone, hull, settings); - var equipped = new EquippedItem[loadout.Slots.Count]; + equipped = new EquippedItem[loadout.Slots.Count]; for (var i = 0; i < loadout.Slots.Count; i++) { var slot = loadout.Slots[i]; @@ -182,26 +217,22 @@ public static class Loadouts .Concat(entity.DockingBays) .First(item => item.EquippableItem == unit); } - if (failures.Count > reported) return null; - - var built = new (List weapons, List items)[groupCount]; - for (var g = 0; g < groupCount; g++) - { - var items = g < groups.Length ? groups[g].Select(i => equipped[i]).ToList() : new List(); - built[g] = (items.Select(item => item.GetBehavior()).ToList(), items); - } - entity.WeaponGroups = built; - return entity; + return failures.Count > reported ? null : entity; } - // How one design is built, or null with the failure listed; nothing is minted until the returned builder runs. A - // pinned product is built as named, whatever choose says; it must be a product of the design. A design with no - // product at all is outside the economy (docs/scenarios-cut.md, Q3: generation, stock and sale all go through - // products) and is built unbranded, with no maker, at UnbrandedQuality (a stated quality replaces it). Otherwise choose names the product, and - // null (nothing available) or a product of another design is a failure. - public static Func Resolve(ItemManager itemManager, CultRecordRef design, - CultRecordRef product, Func choose, string where, - List failures, float? quality = null) where T : CraftedItemData + // What the catalog alone says about one design of a loadout: the design, and whether it pins a product, has no product + // at all, or needs a chooser to name one. Nothing is minted and nothing drawn. + private sealed class Settled + { + public CraftedItemData Data; + public FactionProductData Pinned; // the product the loadout names, if it does + public bool Unbranded; // a design no product makes + public string Where; + } + + // Null with the failure listed. + private static Settled Settle(ItemManager itemManager, CultRecordRef design, CultRecordRef product, + string where, List failures) where T : CraftedItemData { var cache = itemManager.ItemData; var data = cache.Get(design); @@ -211,35 +242,69 @@ public static class Loadouts return null; } - FactionProductData made; if (product.IsSet()) { - made = cache.Get(product); + var made = cache.Get(product); if (made == null) { failures.Add($"{where}: product {product.Key} is not in the catalog"); return null; } - } - else - { - if (!cache.GetAll().Any(p => p.Design.Key.Equals(design.Key))) - return () => itemManager.CreateInstance(itemManager.CreateLot(data, default, quality ?? UnbrandedQuality)); - made = choose(data); - if (made == null) + if (!made.Design.Key.Equals(design.Key)) { - failures.Add($"{where}: no available product of {data.Name}"); + failures.Add($"{where}: product {made.Name} is not a product of {data.Name}"); return null; } + return new Settled { Data = data, Pinned = made, Where = where }; } - if (!made.Design.Key.Equals(design.Key)) + return new Settled { - failures.Add($"{where}: product {made.Name} is not a product of {data.Name}"); - return null; + Data = data, Where = where, + Unbranded = !cache.GetAll().Any(p => p.Design.Key.Equals(design.Key)) + }; + } + + // A design made with no product, at UnbrandedQuality: no maker, no draw. It is also the stand-in the fit trial builds + // every design as, since a brand never changes a shape. A settled design that failed is never built. + private static Func Unbranded(ItemManager itemManager, Settled settled) => + () => itemManager.CreateInstance(itemManager.CreateLot(settled.Data, default, UnbrandedQuality)); + + // How a settled design is built, or null with the failure listed; nothing is minted until the returned builder runs. + // A design-only slot is where choose is asked, and may draw. + private static Func Build(ItemManager itemManager, Settled settled, + Func choose, float? quality, List failures) + { + if (settled == null) return null; + if (settled.Unbranded) + return () => itemManager.CreateInstance(itemManager.CreateLot(settled.Data, default, quality ?? UnbrandedQuality)); + var made = settled.Pinned; + if (made == null) + { + made = choose(settled.Data); + if (made == null) + { + failures.Add($"{settled.Where}: no available product of {settled.Data.Name}"); + return null; + } + if (!made.Design.Key.Equals(itemManager.ItemData.RefOf(settled.Data).Key)) + { + failures.Add($"{settled.Where}: product {made.Name} is not a product of {settled.Data.Name}"); + return null; + } } return () => itemManager.CreateInstance(quality is { } q ? itemManager.CreateLot(made, q) : itemManager.CreateLot(made)); } + // How one design is built, or null with the failure listed; nothing is minted until the returned builder runs. A + // pinned product is built as named, whatever choose says; it must be a product of the design. A design with no + // product at all is outside the economy (docs/scenarios-cut.md, Q3: generation, stock and sale all go through + // products) and is built unbranded, with no maker, at UnbrandedQuality (a stated quality replaces it). Otherwise choose names the product, and + // null (nothing available) or a product of another design is a failure. + public static Func Resolve(ItemManager itemManager, CultRecordRef design, + CultRecordRef product, Func choose, string where, + List failures, float? quality = null) where T : CraftedItemData => + Build(itemManager, Settle(itemManager, design, product, where, failures), choose, quality, failures); + private static string Cell(LoadoutSlot slot) => $"slot {slot.Position.x},{slot.Position.y}"; } diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 3db7c07ea..412b0e9ab 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -433,11 +433,9 @@ public static class ZoneGenerator continue; } var failures = new List(); - var streamBefore = itemManager.Random; - var entity = Loadouts.Materialize(itemManager, null, loadout, GetLoadoutGenerator(bossFaction).ChooseProduct, failures); + var entity = Loadouts.Materialize(itemManager, null, loadout, design => GetLoadoutGenerator(bossFaction).ChooseProduct(design), failures); // the generator exists only once the Loadout is known to fit if (entity == null) { - itemManager.Random = streamBefore; // a failed fit has drawn lots; an unbuildable boss draws nothing, as a refused one does itemManager.Log($"{bossFaction.Name}'s boss Loadout {loadout.Name} cannot be built: {string.Join("; ", failures)}"); continue; } diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 65ccd65a5..dc4b88fa6 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -630,21 +630,72 @@ public sealed partial class RunStartTests Assert.Equal(expected.Random.NextFloat(), next); } - [Fact] - public void An_unbuildable_boss_loadout_draws_nothing() + // Everything a generated zone exposes afterwards: the serialized pack, the ledger, the roster and the gear of its ships, and + // the next draw of the item stream. A faction's generator stream shows in the ships it outfitted, so it is in the pack. + private string ZoneFacts(ItemManager items, ZonePack pack) + { + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + var bytes = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(MessagePack.MessagePackSerializer.Serialize(pack, options))); + var gear = pack.Entities.Select(entity => $"{HullName(items, entity)}|{(entity.Faction.IsSet() ? entity.Faction.Key.ToString() : "-")}|{string.Join(";", Cells(items, entity))}"); + return $"pack {bytes} NextLot={items.Lots.NextLot} lots={items.Lots.Lots.Count}\n{string.Join("\n", gear)}\nnext item draw {items.Random.NextFloat():R}"; + } + + // The boss of the named faction, whose Loadout is made unbuildable by unbuild, refuses before anything is minted or drawn: the + // zone is the zone with no boss in every state it exposes. The faction need not be the zone's nearest. + private void AssertUnbuildableBossIsAsIfAbsent(string bossFaction, Action unbuild) { var galaxy = DemoGalaxy(1); var loadout = GiveAntagonistABoss(galaxy); - loadout.Slots[0].Position = new int2(999, 999); + unbuild(loadout); StoreLoadout(loadout); - var items = LoggingItems(new List()); - var pack = BossZonePack(galaxy, items, galaxy.Exit); - Assert.False(pack.BossSpawned); - var next = items.Random.NextFloat(); + var boss = galaxy.Factions.Single(faction => faction.Name == bossFaction); + Zhestokost(galaxy).BossLoadout = default; + SetBoss(boss, loadout); galaxy.BossZones.Clear(); + galaxy.BossZones[boss] = galaxy.Exit; + var log = new List(); + var items = LoggingItems(log); + var pack = BossZonePack(galaxy, items, galaxy.Exit); + Assert.False(pack.BossSpawned, bossFaction); + Assert.Contains(log, line => line.Contains("Antagonist Boss") && line.Contains("cannot be built")); + + var bare = DemoGalaxy(1); + bare.BossZones.Clear(); var bareItems = LoggingItems(new List()); - var bare = BossZonePack(galaxy, bareItems, galaxy.Exit); - Assert.Equal(Roster(bareItems, bare), Roster(items, pack)); - Assert.Equal(bareItems.Random.NextFloat(), next); + var barePack = BossZonePack(bare, bareItems, bare.Exit); + Assert.Equal($"{bossFaction}\n{ZoneFacts(bareItems, barePack)}", $"{bossFaction}\n{ZoneFacts(items, pack)}"); + } + + private IEnumerable AllFactionNames() => DemoGalaxy(1).Factions.Select(faction => faction.Name).OrderBy(name => name, StringComparer.Ordinal); + + private static void MoveOut(Loadout loadout, int slots) + { + for (var i = 0; i < slots; i++) loadout.Slots[i].Position = new int2(999 - i, 999 - i); + } + + [Fact] + public void An_unbuildable_boss_loadout_with_one_failure_leaves_the_zone_as_if_it_had_no_boss() + { + foreach (var faction in AllFactionNames()) AssertUnbuildableBossIsAsIfAbsent(faction, loadout => MoveOut(loadout, 1)); + } + + [Fact] + public void An_unbuildable_boss_loadout_with_several_failures_leaves_the_zone_as_if_it_had_no_boss() + { + foreach (var faction in AllFactionNames()) AssertUnbuildableBossIsAsIfAbsent(faction, loadout => MoveOut(loadout, 3)); + } + + // A design-only slot asks the chooser, which draws its faction's stream; the fit is decided before it is asked. + [Fact] + public void An_unbuildable_boss_loadout_with_design_only_slots_leaves_the_zone_as_if_it_had_no_boss() + { + foreach (var faction in AllFactionNames()) + AssertUnbuildableBossIsAsIfAbsent(faction, loadout => + { + foreach (var slot in loadout.Slots) slot.Product = default; + Assert.Contains(loadout.Slots, slot => _cache.GetAll().Any(p => p.Design.Key.Equals(slot.Design.Key))); + MoveOut(loadout, 1); + loadout.Slots[loadout.Slots.Count - 1].Position = new int2(900, 900); + }); } } From 66510b003f2617c07a2743cd9eeb18d8d936312f Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 01:25:40 +0200 Subject: [PATCH 15/19] Boss refusal tests: gear projection reads ships only (turrets are not ships) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index dc4b88fa6..261c91a7c 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -636,7 +636,7 @@ public sealed partial class RunStartTests { var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); var bytes = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(MessagePack.MessagePackSerializer.Serialize(pack, options))); - var gear = pack.Entities.Select(entity => $"{HullName(items, entity)}|{(entity.Faction.IsSet() ? entity.Faction.Key.ToString() : "-")}|{string.Join(";", Cells(items, entity))}"); + var gear = pack.Entities.Select(entity => $"{HullName(items, entity)}|{(entity.Faction.IsSet() ? entity.Faction.Key.ToString() : "-")}|{(EntitySerializer.Unpack(items, null, entity) is Ship ? string.Join(";", Cells(items, entity)) : "-")}"); return $"pack {bytes} NextLot={items.Lots.NextLot} lots={items.Lots.Lots.Count}\n{string.Join("\n", gear)}\nnext item draw {items.Random.NextFloat():R}"; } From 820ec73f7671e9e1855a45773b7743937d713ac9 Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 01:29:03 +0200 Subject: [PATCH 16/19] Boss refusal tests: a mismatch prints both zones and the log Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 261c91a7c..8a00db639 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -663,7 +663,9 @@ public sealed partial class RunStartTests bare.BossZones.Clear(); var bareItems = LoggingItems(new List()); var barePack = BossZonePack(bare, bareItems, bare.Exit); - Assert.Equal($"{bossFaction}\n{ZoneFacts(bareItems, barePack)}", $"{bossFaction}\n{ZoneFacts(items, pack)}"); + var expected = ZoneFacts(bareItems, barePack); + var actual = ZoneFacts(items, pack); + Assert.True(expected == actual, $"{bossFaction}: the zone with an unbuildable boss differs from the zone with none.\nno boss:\n{expected}\nunbuildable boss:\n{actual}\nlog: {string.Join(" / ", log)}"); } private IEnumerable AllFactionNames() => DemoGalaxy(1).Factions.Select(faction => faction.Name).OrderBy(name => name, StringComparer.Ordinal); From f3719d99432a9e8f7b13a120ed793a8e762aa7b7 Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 01:30:10 +0200 Subject: [PATCH 17/19] Boss refusal tests: compare the serialized ledger, not the pack (pack bytes differ between identical runs) 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 8a00db639..c618d712e 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -630,14 +630,14 @@ public sealed partial class RunStartTests Assert.Equal(expected.Random.NextFloat(), next); } - // Everything a generated zone exposes afterwards: the serialized pack, the ledger, the roster and the gear of its ships, and + // Everything a generated zone exposes afterwards: the serialized ledger, the roster and the gear of its ships, and // the next draw of the item stream. A faction's generator stream shows in the ships it outfitted, so it is in the pack. private string ZoneFacts(ItemManager items, ZonePack pack) { var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); - var bytes = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(MessagePack.MessagePackSerializer.Serialize(pack, options))); + var bytes = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(MessagePack.MessagePackSerializer.Serialize(items.Lots, options))); var gear = pack.Entities.Select(entity => $"{HullName(items, entity)}|{(entity.Faction.IsSet() ? entity.Faction.Key.ToString() : "-")}|{(EntitySerializer.Unpack(items, null, entity) is Ship ? string.Join(";", Cells(items, entity)) : "-")}"); - return $"pack {bytes} NextLot={items.Lots.NextLot} lots={items.Lots.Lots.Count}\n{string.Join("\n", gear)}\nnext item draw {items.Random.NextFloat():R}"; + return $"ledger {bytes} NextLot={items.Lots.NextLot} lots={items.Lots.Lots.Count}\n{string.Join("\n", gear)}\nnext item draw {items.Random.NextFloat():R}"; } // The boss of the named faction, whose Loadout is made unbuildable by unbuild, refuses before anything is minted or drawn: the From 3ac185a84e66b86fd3b3e8522ee8f850b9c1fe5e Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 02:07:26 +0200 Subject: [PATCH 18/19] aetheria-release map: hull collection states (HS) and the agent-built row Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-release-map.md | 117 ++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index f3e709f3d..88267df37 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -11334,7 +11334,7 @@ PIDs are in `S\out\pids.log`, the first 3404 and the last 20348. It then writes |---|---|---|---| | Faction language, `factions/.scad` | The faction slug. One file per faction. A Pirate hull includes its donor's file as well. | Drafted by an agent from the lore and the operator's art, marked draft, then approved by an operator ruling. A revision rebuilds every hull of that faction at its next Build. | The operator decides the language (`zhestokost-language-source` is the first). Agents propose; nothing else edits it. | | Hull source, `GameData/Mods//hull.scad` | The package id, equal to `ShipAuthoring.Id`. One per package. | Created by an agent pass for a hull with no operator art and revised by agents. Deleted by Package in the same act that packages operator art into that package. | Agents (Imagination designs, Hands lands). The operator decides when it dies, by packaging her art. | -| "Agent-built" status | Presence of `hull.scad`. No field. | Ends when the file is deleted. | Derived. "Which hulls are agent-built" is a directory listing. | +| "Agent-built" status | Derived by `_hull_state` from hull.scad, Build's maker record, its untouched zones and her meshes (section HS). No field. | Ends when the state leaves agent and unbuilt: Package retiring hull.scad for her art. | Derived. The state table is in section HS. | | Zone meshes and anchor objects in Blender | Marked `aetheria.hull_source`, with ids `aetheria.id`. | Replaced on every Build. Never hand-edited. | Build From Hull Source. Build refuses a collection holding meshes it did not make. | | `ship.glb` and ShipAuthoring anchors | `ShipAuthoring.ModelAsset`, with anchor ids equal to mount ids. | Rewritten by every Package, whether from an agent hull or operator art. The ids are stable across the swap, so loadouts and saves survive it. | Package Ship, the only writer. | | HullData layout (schematic) | `mod-hull:`. | Authored through Save Layout. Linked to the model by mount name only. | The schematic author: agents for agent hulls, the operator for hers. Never Build. AH-B3 is closed by cut `package-agent-hull-guards` (section PG). | @@ -11456,3 +11456,118 @@ Imagination pass `imag-ag7-hull-guards`, session `self-2026-10-19-ag7`, 2026-10- ### Admitted - `aetheria-release:cut_spec:cut-package-agent-hull-guards.r1`. + +## Hull collection states (HS) + +Imagination pass `imag-ag7-hull-states`, session `self-2026-10-19-ag7`, 2026-10-19, pinned to Aetheria f121b464 (head +of `eureka/aetheria-release-package-agent-hull-guards`, worktree `C:\aeth-wt\ag7-hull-states-f121`). Three Soul passes +(verdicts `cut-package-agent-hull-guards.s1` to `.s3`) each found the one decision "is this hull Build's, hers, or +neither" answered by two readers that disagreed. This section is the model that gives that decision one owner. It +revises spec `cut-package-agent-hull-guards` to r2 for findings `.s3.never-built-art-deadlock` and +`.s3.art-requirement-unpinned`, and admits question `art-over-unbuilt-hull-source`. + +### Body facts (source reads and one probe at f121b464) + +- **HS-B1. Four inputs name the state, and each has one writer.** `hull.scad` in the package (`_hull_source_file`, + `__init__.py:1499`): written by agents, deleted by `_retire_hull_source` (:1645) or by hand. `aetheria.hull_maker` + and `aetheria.hull_built` on the collection (:1494-1495): written together by Build (:1622-1623), deleted together by + `_retire_hull_source` (:1648). Build's untouched zone meshes: derived by `_hull_built` (:1518) from the record and each + object's fingerprint. Her meshes: `_own_meshes` (:789) minus Build's untouched objects, so a Build zone she edited + counts as hers. +- **HS-B2. The regime has two readers today.** `_art_takes_over` (:1627) reads the maker record, Ship Root, the + untouched zones and her meshes. `_refuse_wrong_maker` (:1654) calls it, then reads `hull.scad` again on its own + (:1663) when the record is absent. `_retire_hull_source` (:1639) reads only `_art_takes_over`. The panel reads + `hull.scad` to show the Build button (:1789). Build (:1556) reads `hull.scad` as its input file and refuses her + meshes and edited objects of its own (:1562-1568), which is its write-safety rule, not the regime. +- **HS-B3. Fourteen states are reachable, and the two readers disagree in exactly one.** Probe + `probe_states.py` (scratchpad `imag-ag7-hull-states`) transcribes the f121b464 conditions and enumerates every + combination of hull.scad (S), the maker record (M), an untouched Build zone (Z), her meshes (A) and Ship Root (R), + with Z implying M and R, A implying R, and M without R (a hand-deleted Ship Root) left out. Of 14 states, one is a + deadlock: S, no M, no Z, A, R (an agent's hull.scad Build never ran, with her art under Ship Root). Package refuses + "Build From Hull Source first" and Build refuses her meshes. That is Soul's s3 case A. The proposed classifier below + gives the same Package and retirement outcome as f121b464 in the other 13 states. +- **HS-B4. One state is pinned by nothing.** M, no Z, no A (Build's zones deleted, nothing put in their place): the + smoke has no step for it, so the art test inside `_art_takes_over` can be replaced with True and the smoke stays + green (Soul s3 mutation `mut.art-needs-no-art`). +- **HS-B5. Products matter only in the agent state.** `product_makers` (`ship_cc.py`) is read only by + `_refuse_wrong_maker`, after the regime says the hull is Build's. Her art and a plain hull ship with any product or + none (smoke step 1.9 removes the product before her art is packaged). + +### The state table + +S is hull.scad in the package, M is Build's maker record, Z is an untouched zone mesh of Build's, and A is her meshes +under Ship Root. "Check" means that Package refuses unless the package holds a product and every product's +Manufacturer is the recorded maker: no product gives "no product", and a wrong or mixed maker gives "not by the +maker". Every Package outcome holds only when the validator accepts. Retirement deletes hull.scad (if present), M and +`aetheria.hull_built`, after the validator has accepted. + +| # | S | M | Z | A | State | Build | Package | Retired | How it is reached | +|---|---|---|---|---|---|---|---|---|---| +| H1 | - | - | - | - | plain | refuses: no hull.scad | no check | nothing | New Ship, no agent hull | +| H2 | - | - | - | yes | plain | refuses: no hull.scad | no check | nothing | Her hero or hand-made hull. It is also H6 or H11 after the Package that retires | +| H3 | yes | - | - | - | unbuilt | builds | refuses: "Build From Hull Source first, or put your own meshes under Ship Root" | nothing | An agent's hull.scad dropped in. Smoke PASS 3 | +| H4 | yes | - | - | yes | **art** (question `art-over-unbuilt-hull-source`) | refuses: not its meshes | no check | hull.scad | Her art over a hull.scad Build never ran. **The f121b464 deadlock** | +| H5 | yes | yes | yes | - | agent | builds (replaces its own) | check | nothing | After Build. Smoke 1.1 to 1.5 | +| H6 | yes | yes | - | yes | art | refuses: not its meshes | no check | hull.scad, M | Her art replaced every Build zone, or she edited them all. Smoke 1.9, odd hull | +| H7 | yes | yes | yes | yes | agent | refuses: not its meshes | check | nothing | Her mesh beside Build's zones, or one zone edited. Smoke 1.8 | +| H8 | yes | yes | - | - | agent | builds | check | nothing | Build's zones deleted, nothing in their place. **Unpinned (s3 art-requirement)** | +| H9 | - | yes | yes | - | agent | refuses: no hull.scad | check | nothing | hull.scad deleted by hand after Build. Smoke 1.5's hull.scad-gone row | +| H10 | - | yes | yes | yes | agent | refuses: no hull.scad | check | nothing | H7 with hull.scad deleted by hand | +| H11 | - | yes | - | yes | art | refuses: no hull.scad | no check | M | H6 with hull.scad deleted by hand. Smoke 1.9, even hull | +| H12 | - | yes | - | - | agent | refuses: no hull.scad | check | nothing | H8 with hull.scad deleted by hand | + +Ship Root absent (states H1 and H3 before New Ship's root, or after a hand deletion) changes nothing in the table: +`_own_meshes` needs a root, so with no root A is false and the row is H1 or H3. A maker record with no root (hand +deletion) is agent. + +### Authority + +- **Owner of the state: `_hull_state(collection)`**, one classifier in `__init__.py`, evaluated in this order. Build's + untouched zone present gives agent. Otherwise her meshes present together with Build's record or hull.scad gives art. + Otherwise Build's record present gives agent. Otherwise hull.scad present gives unbuilt. Otherwise plain. It reads + `_hull_built`, `_own_meshes`, `HULL_MAKER in collection` and `_hull_source_file`, and nothing else does so to decide + the regime. +- **Owner of the maker of a shipped agent hull: its products** (ruling `hull-faction-is-product-maker`). + `aetheria.hull_maker` is a record of what Build judged against, read only as the value to compare in the agent state. +- **Owner of when hull.scad dies: Package**, through `_retire_hull_source` in the art state after the validator + accepts, or the operator by hand. Ruling `hull-art-communicates-form` keeps agent hulls until her art replaces them. + The model row of section "Agent-built hulls" names Package's packaging of her art as that act. +- **Owner of whether Build may write: Build** (`_build_hull_source`), on its own rule that it never replaces a mesh it + did not make or an object of its own that was changed. This is a separate decision from the regime, and it reads + hull.scad as its input. +- **Display-only:** the panel's read of hull.scad (:1789), which shows the Build button. In H4 the button shows and + Build refuses. It decides nothing. +- **Forbidden readers of the regime:** `_refuse_wrong_maker` reading `hull.scad` or deciding by the presence of the + maker record; `_retire_hull_source` testing anything but `_hull_state`; any reader of `aetheria.hull_built` other + than `_hull_built`; the smoke asserting the classifier's return value as its only proof. +- **Deletion line:** `_art_takes_over` (:1627-1636) and the hull.scad branch of `_refuse_wrong_maker` (:1661-1665) go + before `_hull_state` is added. + +### Rationale + +- **The body decides, not a file or a record alone.** The guard exists so that a hull shipping Build's judged body + ships under Build's judged maker. Whether Build's untouched zones are in the collection is the fact that says so. + hull.scad (keyed by r1, s1 `guard-keyed-on-hull-scad`) and the maker record (keyed by h3, s3 `never-built-art-deadlock`) + each stand in for that fact in only some states, which is why each fix moved the hole somewhere else. +- **Order is the model.** Z before A keeps H7 and H10 under the guard: one of Build's zones still ships. A with M or S + before M alone makes her art win once no Build zone is left (H4, H6, H11). A must be required (H8 and H12 stay agent), + and M or S must be required (H2 stays plain and retires nothing). +- **H4 is the operator's call.** Two readings are coherent. Under the first, her art over a hull.scad is hers and + Package retires the file, which is what 70c366b5 did and what the "Agent-built hulls" model row says. Under the + second, Package never deletes an agent's hull.scad that Build never ran, and refuses with text naming the exit + (delete hull.scad). The recommendation is the first, in question `art-over-unbuilt-hull-source`. Either answer + changes one condition in `_hull_state` and one message, and both remove the deadlock. +- **The "Agent-built" status row above is superseded.** It said the status is the presence of hull.scad. That holds + only for H3 to H8. The status is `_hull_state` being agent or unbuilt. + +### Model page rows + +| Kind | Identity | Lifecycle | Authority | +|---|---|---|---| +| Hull state (plain, unbuilt, agent, art) of a ship collection | Derived per call from hull.scad, `aetheria.hull_maker`, Build's untouched zones and her meshes. No field. | Recomputed at every Package and retirement. It changes when Build runs, when she adds or removes meshes, at retirement, and when hull.scad or a record is deleted by hand. | `_hull_state`, alone. | + +### Admitted + +- `aetheria-release:question:art-over-unbuilt-hull-source` (recommended `art-takes-over`, raised in the spec, which it + blocks). +- `aetheria-release:cut_spec:cut-package-agent-hull-guards.r2`, superseding r1. From 96cb6d20f1d40b0f53c3d419e6cd2dbf3ed3810b Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 13:01:22 +0200 Subject: [PATCH 19/19] Research: prior art for agent-authored stand-in hulls (render-critique loops, part libraries, self-judging limits) Co-Authored-By: Claude Opus 5.5 --- ...-agent-authored-standin-hulls-prior-art.md | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 docs/research/2026-10-19-agent-authored-standin-hulls-prior-art.md diff --git a/docs/research/2026-10-19-agent-authored-standin-hulls-prior-art.md b/docs/research/2026-10-19-agent-authored-standin-hulls-prior-art.md new file mode 100644 index 000000000..9e6c6eb5d --- /dev/null +++ b/docs/research/2026-10-19-agent-authored-standin-hulls-prior-art.md @@ -0,0 +1,101 @@ +# Prior art for agent-authored stand-in hulls (aetheria-release, 2026-10-19) + +Serves: the stand-in hull prompt bench for the demo cast and freelancer factions +(operator 2026-10-19: "Let's see if we can figure out a prompt that gets Opus to +spit out some decent stand-in hulls for the demo cast (including ships from +non-present factions to be piloted by other freelancers)"). Gathered by Eyes +pass `eyes-ag7-llm-cad-prior-art`, session `self-2026-10-19-ag7`. Companion to +`2026-10-18-agent-built-hull-art-prior-art.md` and +`2026-10-18-faction-form-language-prior-art.md`. + +Evidence levels: **read** (HTML or abstract page read), **snippet** (search +excerpt only). + +## Loops and their measured gains + +- **SceneCraft** (ICML 2024, arXiv 2403.01248, read). Removing the render-review + inner loop dropped constraint score 88.9 to 26.1, the largest loss in its + ablation. Removing the learned code-skill library dropped it 88.9 to 64.5 and + CLIP 69.8 to 48.3. Human eval put its aesthetics win rate at 74.5% against + BlenderGPT's 14.5%. It measures scene layout from given assets, not part form. +- **CADCodeVerify** (ICLR 2025, arXiv 2410.05340, read). Two refinement rounds; + no gain observed beyond round 2. Point-cloud distance 0.156 to 0.126. Dropping + the reference views or the few-shot questions made it worse. The VLM's own + verification answers were correct only 64.6% and 68.2% of the time; human + gold feedback (0.120) beat VLM feedback (0.137) on a 50-sample subset. +- **BlenderGym** (CVPR 2025, arXiv 2504.01786, read). VLM verifier agreement + with human pairwise judgments 0.66 (Claude 3.5 Sonnet) against 0.79 + human-to-human; Claude was the only verifier that consistently gave full + reasoning. Scarce compute favours generation, abundant compute favours + verification: a scaled small verifier over 32 Claude candidates beat unscaled + GPT-4o and Claude. Generators hallucinate differences, and edits mismatch the + diagnosis in scripts over about 80 lines. Metrics miss physical + implausibility such as floating objects. +- **LL3M** (arXiv 2508.08228, read). Planner, retrieval, coder, critic and + verifier agents; the critic sees 5 renders, the verifier checks each fix was + applied, refinement loops until the verifier approves. Blender-API retrieval + raised complex-operation use 1.20 to 5.86 and cut execution errors 26%. + Without retrieval the coder sometimes deleted offending lines to silence + errors. Without shared prior code the coder regenerates a substantially + different asset. Disconnected handles survive auto-refinement; VLMs struggle + with spatial artifacts. Agent ablations are qualitative only; no user study. +- **Procedura** (arXiv 2608.26238, read). Named parts with typed mates, per-part + compile, mate and connectivity gates, and a vision critic that fixes one + diagnosed issue at a time. Ablation on MechBench-36 (judge score): full 0.828, + no refine 0.800, no planning 0.791, no 3D feedback 0.819; dropping planning + caused the biggest aesthetics drop, 0.827 to 0.775. A floater at 70% span was + 1.9% of volume, so a volume check misses it. Case study: 24 to 3 to 2 HIGH + issues over three critic cycles. CSG suits hard-surface better than organic. +- **P3D-Bench** (arXiv 2606.11152, abstract). Multi-part code generation is much + harder than single-part: models get identity right, dimensions, part count + and part geometry wrong. +- **ModelRift Pantheon benchmark** (vendor blog, one run per cell, read). Opus + 4.7 scored 3.0/5 with "uniform colour and weight"; Sonnet 4.6 3.4/5 with the + cleanest massing. Explicit measured dimensions helped; human annotations drawn + on the render beat text corrections. A preview PNG looked better than the + exported STL, which had non-manifold surfaces. + +## Self-judgement + +- Huang et al., ICLR 2024 (arXiv 2310.01798, snippet): intrinsic + self-correction without external feedback often fails and can hurt; reported + gains partly came from oracle labels or weak first prompts. +- Panickssery et al., NeurIPS 2024 (arXiv 2404.13076, snippet): LLM evaluators + recognise and favour their own generations; text domain. +- Multimodal self-judging leans on text priors over the image (arXiv 2406.11280, + 2508.20655, snippets). + +## Failure modes in CAD code + +- BenchCAD and ASSEMCAD (snippets): successful renders still show floating + components; union without face glue gives floating subpieces; polar patterns + are an error cluster. ASSEMCAD mitigates with safe-union and overlap + validators. +- Shape-grammar ships (CESCG 2016; 2022 Hacettepe thesis; snippets): primitive + sets plus rules give similar outlines with varied detail; unconstrained + derivation tends toward unstructured results. + +## Game-art practice (criteria a critic can check) + +- Stellaris Dev Diary 230 (search excerpt): silhouette and proportions read + first at distance; big shapes settled before small; shared motifs unify a + family. +- Fractured Space art direction, Creative Bloq (search excerpt): an arrowhead + silhouette reads as a threat across the map; three manufacturers each get a + distinct vocabulary (stacked cylinders, brutalist cubes, naval modular + shells); human figures dropped in as scale checks. +- "Moth" analysis, John Liberto, ArtStation (search excerpt): 3 large, 4 mid + and many small volumes; the large volumes dictate the silhouette. +- Primary/secondary/tertiary shapes (steveyang.readthedocs.io, read, after Neil + Blevins): primary defines silhouette, secondary individuality, tertiary story. + +## Gaps + +No controlled study of design-language or style-spec conditioning for +LLM-authored CAD; no measurement of a parts library against floating-part or +symmetry errors (nearest: SceneCraft's skill library, LL3M's retrieval); no +sweep of render-critique round counts for aesthetic quality; no direct +measurement of a model praising its own render (inferred from text-domain +work); no source on boxy "lego" look or scale confusion in LLM 3D code; no +authoritative game-art source for 60/30/10 ship proportions. 3D-PreMise +(2401.06437) and MeshCoder (2508.14879) seen as abstracts only.