Merge remote-tracking branch 'origin/master' into eureka/aetheria-release-package-agent-hull-guards

This commit is contained in:
soul 2026-10-19 14:25:09 +02:00
commit 2f4b617e50
11 changed files with 913 additions and 147 deletions

View file

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

View file

@ -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<HullData> 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<FactionPlace>();
// 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<Loadout> BossLoadout;
}
// Historical, narrative and strategic intent join as keys 2+ when economics-driven placement lands (ruling faction-curated-place-names).

View file

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

View file

@ -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<FactionProductData> 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<FactionProductData> 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<Loadout>().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<Loadout>(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<CraftedItemData, FactionProductData> choose, List<string> 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<Weapon> weapons, List<EquippedItem> items)[groupCount];
for (var g = 0; g < groupCount; g++)
{
var items = g < groups.Length ? groups[g].Select(i => equipped[i]).ToList() : new List<EquippedItem>();
built[g] = (items.Select(item => item.GetBehavior<Weapon>()).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<CraftedItemInstance> hullBuild, Func<CraftedItemInstance>[] slotBuilds, List<string> 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<Weapon> weapons, List<EquippedItem> items)[groupCount];
for (var g = 0; g < groupCount; g++)
{
var items = g < groups.Length ? groups[g].Select(i => equipped[i]).ToList() : new List<EquippedItem>();
built[g] = (items.Select(item => item.GetBehavior<Weapon>()).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<CraftedItemInstance> Resolve<T>(ItemManager itemManager, CultRecordRef<T> design,
CultRecordRef<FactionProductData> product, Func<CraftedItemData, FactionProductData> choose, string where,
List<string> 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<T>(ItemManager itemManager, CultRecordRef<T> design, CultRecordRef<FactionProductData> product,
string where, List<string> 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<FactionProductData>().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<FactionProductData>().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<CraftedItemInstance> 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<CraftedItemInstance> Build(ItemManager itemManager, Settled settled,
Func<CraftedItemData, FactionProductData> choose, float? quality, List<string> 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<CraftedItemInstance> Resolve<T>(ItemManager itemManager, CultRecordRef<T> design,
CultRecordRef<FactionProductData> product, Func<CraftedItemData, FactionProductData> choose, string where,
List<string> 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}";
}

View file

@ -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<T>(Predicate<T> 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<HullData>(hull => hull.HullType == HullType.Ship, required: true);
if (available.Length == 0) return null;
var priciest = available.OrderByDescending(entry => entry.design.Price).ThenBy(entry => entry.design.Name, StringComparer.Ordinal).First().design;
return GenerateShipLoadout(hull => hull == priciest);
}
// Hardpoint gear that no hull in the catalog can mount has no home (operator, 2026-09-30: "we'll need to author a
// 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.

View file

@ -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<string>();
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;

View file

@ -11334,10 +11334,10 @@ PIDs are in `S\out\pids.log`, the first 3404 and the last 20348. It then writes
|---|---|---|---|
| Faction language, `factions/<faction>.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/<id>/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:<id>`. | 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:<id>`. | 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<Faction>` 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 <ship.cc> <maker> --name <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=<CultLib>/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/<faction>.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/<faction>.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.

View file

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

View file

@ -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<object[]>(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<object[]>(slots[17]);
Assert.Equal(6f, Convert.ToSingle(doctrine[1]));

View file

@ -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<HullData>().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<HullData>(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<Loadout>(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<ShipPack>(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<string>();
var items = LoggingItems(log);
var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
Assert.Equal(cheaper.Name, HullName(items, boss));
Assert.DoesNotContain(log, line => line.Contains("BossHull"));
// Unset: the most expensive ship hull on offer, and the log names the missing BossHull.
antagonist.BossHull = default;
log.Clear();
boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
Assert.Equal(priciest.Name, HullName(items, boss));
Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("no BossHull"));
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<ShipPack>(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<string>());
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<FactionProductData>().Any(product => product.Design.Key.Equals(slot.Design.Key) && outfitter.IsAvailable(product)));
var template = _cache.GetAll<FactionProductData>().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<FactionProductData>(MessagePack.MessagePackSerializer.Serialize(template, options), options);
outsider.Name = "Aaa Outsider";
foreach (var faction in _cache.GetAll<Faction>().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<FactionProductData>().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<string>());
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<string>();
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<string>()), 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<Loadout>(Loadouts.KeyOf("No Such Loadout"));
Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal));
Assert.Equal(BossRefusal.Missing, refusal);
var log = new List<string>();
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<string>();
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<string>());
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<Faction>().OrderBy(faction => faction.Name, StringComparer.Ordinal).ToArray();
factions[0].BossLoadout = new CultRecordRef<Loadout>(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<FactionProductData>().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<string>();
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<FactionProductData>().Where(product => _cache.Get(product.Design) is HullData { HullType: HullType.Ship }).ToArray())
Assert.True(_cache.Remove(_cache.RefOf(product).Key));
var log = new List<string>();
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<Loadout>(Loadouts.KeyOf("CANARY-7f3a"));
var log = new List<string>();
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<string>());
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<FactionProductData>().Any(p => p.Design.Key.Equals(slot.Design.Key) && outfitter.IsAvailable(p)));
var outsider = _cache.GetAll<Faction>().OrderBy(f => f.Name, StringComparer.Ordinal).First(f => f.Name != faction.Name && !faction.Allegiance.ContainsKey(_cache.RefOf(f)));
foreach (var p in _cache.GetAll<FactionProductData>().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<string>();
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<string>());
Assert.True(BossZonePack(galaxy, withBoss, galaxy.Exit, ambient: false).BossSpawned);
var next = withBoss.Random.NextFloat();
galaxy.BossZones.Clear();
var expected = LoggingItems(new List<string>());
BossZonePack(galaxy, expected, galaxy.Exit, ambient: false);
var failures = new List<string>();
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<Loadout> 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<string>();
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<string>());
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<string> 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<FactionProductData>().Any(p => p.Design.Key.Equals(slot.Design.Key)));
MoveOut(loadout, 1);
loadout.Slots[loadout.Slots.Count - 1].Position = new int2(900, 900);
});
}
}

View file

@ -593,7 +593,7 @@ public static class Program
// Every CultRecordRef in the catalog that resolves to nothing, as "<record> <DeclaringType.Member> -> <key>".
// Each "clear <DeclaringType.Member>" 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<FactionProductData>().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<Faction>().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)));
}