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 c242e3e42..3b676aef4 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; @@ -66,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; @@ -74,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 e9e75b239..26b08c22d 100644 --- a/Assets/Scripts/ServerShared/Galaxy.cs +++ b/Assets/Scripts/ServerShared/Galaxy.cs @@ -289,9 +289,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..eec005f46 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); @@ -101,14 +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 - // spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does. The hull's type + // 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) @@ -116,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 preset builds a ship or a turret"); - var slotBuilds = loadout.Slots.Select(slot => Resolve(itemManager, slot.Design, slot.Product, choose, Cell(slot), failures, quality)).ToArray(); + failures.Add($"hull: {hullData.Name} is a station hull; a loadout builds a ship or a turret"); + 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}"); @@ -132,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]; @@ -154,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); @@ -183,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/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 834e4dc7c..2e925a201 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -184,6 +184,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 @@ -210,26 +212,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/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 4d4c2613e..412b0e9ab 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, design => GetLoadoutGenerator(bossFaction).ChooseProduct(design), failures); // the generator exists only once the Loadout is known to fit + 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/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index 50128adac..88267df37 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -11334,10 +11334,10 @@ 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 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,205 @@ 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`. + +## 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. 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. 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])); diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 25b46fdd3..c618d712e 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -8,17 +8,19 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; 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 +40,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 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(); + + 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 +130,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,59 +143,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_hull_from_BossHull_else_fallback() + public void Boss_flies_its_factions_boss_loadout() { 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"); + var sold = SoldShipHulls().OrderBy(hull => hull.Name, StringComparer.Ordinal).First(); + StoreLoadout(new Loadout { Name = "Aaa Decoy", Hull = _cache.RefOf(sold) }); + var loadout = GiveAntagonistABoss(galaxy); - // 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")); + 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)); } - // Set but sold by no product: the boss falls back the same way, and the log says which hull has no seller. + // 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_hull_nobody_sells_falls_back() + 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(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 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 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(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")); + 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)); @@ -140,6 +374,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); @@ -175,7 +410,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() { @@ -196,14 +431,21 @@ 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. This Loadout pins its hull's brand, so any faction + // builds it whatever it can get. var faction = galaxy.Factions.First(); - faction.BossHull = _cache.RefOf(SoldShipHulls().First()); + var hull = SoldShipHulls().OrderBy(h => h.Name, StringComparer.Ordinal).First(); + 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 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); @@ -211,27 +453,12 @@ 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() { 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(); @@ -326,4 +553,151 @@ 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); + } + + // 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(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 $"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 + // 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); + unbuild(loadout); + StoreLoadout(loadout); + 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 barePack = BossZonePack(bare, bareItems, bare.Exit); + 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); + + 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); + }); + } } diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index fec3d6d3e..c69d120aa 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -593,7 +593,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) { @@ -711,15 +711,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))); }