From 5d624f9e6d1bdf212de1adc3e8845b05facefa62 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:25:42 +0200
Subject: [PATCH] Loadouts.BossOf: a faction's boss flies the Loadout on its
boss hull's product (builds not yet checked)
BossOf keys on BossHullsOf(items, faction)[0] and Loadout.HullProduct, refuses typed (BossRefusal),
reads no Random, Prefab or Visual. ZoneGenerator builds the boss through Loadouts.Materialize with
ChooseProduct as the only generator member; Galaxy picks boss factions through BossOf. Loadout.cs
vocabulary: loadout, not preset or variant.
Co-Authored-By: Claude Sonnet 5.5
---
Assets/Scripts/ServerShared/Galaxy.cs | 4 +-
Assets/Scripts/ServerShared/ItemData.cs | 3 +-
Assets/Scripts/ServerShared/Loadout.cs | 65 +++++++++++++++-----
Assets/Scripts/ServerShared/ZoneGenerator.cs | 23 +++++--
4 files changed, 70 insertions(+), 25 deletions(-)
diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs
index 6e28cf49e..86cd34125 100644
--- a/Assets/Scripts/ServerShared/Galaxy.cs
+++ b/Assets/Scripts/ServerShared/Galaxy.cs
@@ -288,9 +288,9 @@ public class Galaxy
progressCallback?.Invoke("Placing Factions");
if (progressCallback != null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
- // Choose some megas to have bosses placed: the factions that make a Boss hull
+ // Choose some megas to have bosses placed: the factions with a boss to fly, a Boss hull and its Loadout
var bossMegas = Factions
- .Where(m => LoadoutGenerator.BossHullsOf(_cache, m).Length > 0)
+ .Where(m => Loadouts.BossOf(_cache, m, out _) != null)
.Take(bossCount)
.ToArray();
diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs
index 431acc7ce..6d9e79ef9 100644
--- a/Assets/Scripts/ServerShared/ItemData.cs
+++ b/Assets/Scripts/ServerShared/ItemData.cs
@@ -556,7 +556,8 @@ public class HullData : EquippableItemData
public CultRecordRef Visual;
// Only bosses fly this hull. Which factions it is a boss for comes from its products' makers
- // (LoadoutGenerator.BossHullsOf), so the flag names no faction and reads no model.
+ // (LoadoutGenerator.BossHullsOf), so the flag names no faction and reads no model. A boss's gear is its faction's
+ // fixed Loadout on this hull's product (Loadouts.BossOf), never generated.
[Inspectable, JsonProperty("boss"), Key(33)]
public bool Boss;
diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs
index bf8a2ac47..c2252087f 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. 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,8 @@ public class LoadoutSlot
[Key(3)] public CultRecordRef Product; // unset: design-only, chosen at materialization
}
+public enum BossRefusal { None, NoBossHull, NoLoadout }
+
// 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 +68,60 @@ 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 owner of what a faction's boss flies: the Loadout whose HullProduct is the product of the faction's first boss
+ // hull (LoadoutGenerator.BossHullsOf, dearest first), first by Name ordinal. Keyed on the boss hull's product through
+ // Loadout.HullProduct, never generated; reads no Prefab, Visual or Random. Null with the refusal when there is no boss
+ // hull or no Loadout for it.
+ public static Loadout BossOf(CultCache items, Faction faction, out BossRefusal refusal)
+ {
+ var hulls = LoadoutGenerator.BossHullsOf(items, faction);
+ if (hulls.Length == 0)
+ {
+ refusal = BossRefusal.NoBossHull;
+ return null;
+ }
+
+ var key = items.RefOf(hulls[0].product).Key;
+ var loadout = items.GetAll()
+ .Where(candidate => candidate.HullProduct.IsSet() && candidate.HullProduct.Key.Equals(key))
+ .OrderBy(candidate => candidate.Name, StringComparer.Ordinal)
+ .FirstOrDefault();
+ refusal = loadout == null ? BossRefusal.NoLoadout : BossRefusal.None;
+ return loadout;
+ }
+
+ // Names the field that is missing; echoes no input value and no record key.
+ public static string Describe(BossRefusal refusal) => refusal switch
+ {
+ BossRefusal.NoBossHull => "makes no ship hull with HullData.Boss set",
+ BossRefusal.NoLoadout => "no Loadout's HullProduct names its boss hull's product",
+ _ => ""
+ };
+
+ // The only writer of Loadout records. It opens its own short-lived cache over the catalog file, writable, and disposes it
// after the one commit. No other cache is touched, so no catalog instance play holds (or has mutated) can reach the
- // file; a cache that already has the catalog open sees the preset only once it reloads.
- // Throws and writes nothing when the loadout names a mod design (hull or slot), when the file is missing (capture never creates a catalog), when a preset of this name
+ // file; a cache that already has the catalog open sees the loadout only once it reloads.
+ // Throws and writes nothing when the loadout names a mod design (hull or slot), when the file is missing (capture never creates a catalog), when a loadout of this name
// exists under another key, or when one exists under KeyOf(Name) and replace is not set. The commit is conditional
- // on the record at KeyOf(Name), so a preset changed on disk since this open is not clobbered: returns false then.
+ // on the record at KeyOf(Name), so a loadout changed on disk since this open is not clobbered: returns false then.
public static bool Commit(string catalogPath, Loadout loadout, bool replace)
{
var modDesigns = new[] { loadout.Hull.Key }.Concat(loadout.Slots.Select(slot => slot.Design.Key)).Where(ShipModCatalog.IsModKey).Select(key => key.Value).Distinct().ToArray();
if (modDesigns.Length > 0)
- throw new InvalidOperationException($"Preset '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a preset.");
+ throw new InvalidOperationException($"Loadout '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a loadout.");
if (!File.Exists(catalogPath))
- throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a preset capture never creates one.");
+ throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a loadout commit never creates one.");
using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true);
var key = KeyOf(loadout.Name);
var namesake = cache.GetAll().FirstOrDefault(other => other.Name == loadout.Name && !cache.RefOf(other).Key.Equals(key));
if (namesake != null)
- throw new InvalidOperationException($"Preset '{loadout.Name}' already exists under key {cache.RefOf(namesake).Key}; rename or remove it first.");
+ throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists under key {cache.RefOf(namesake).Key}; rename or remove it first.");
var existing = cache.Get(key);
if (existing != null && !replace)
- throw new InvalidOperationException($"Preset '{loadout.Name}' already exists; replace it explicitly.");
+ throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists; replace it explicitly.");
return cache.Commit(batch =>
{
batch.Expect(key, existing);
@@ -104,7 +136,8 @@ public static class Loadouts
// WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure is listed; on any failure
// nothing is returned and the zone and cache are unchanged, but a failed fit has already drawn from
// itemManager.Random. How each design is built is Resolve's rule. A design-only slot is chosen by choose; a game
- // spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does. The hull's type
+ // spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does, and so does the
+ // boss in ZoneGenerator, which is built here too from its faction's Loadout (Loadouts.BossOf). The hull's type
// picks the entity: a ship hull builds a Ship, a turret hull an OrbitalEntity with no orbit, which stays where it is
// put; a station hull is refused. The entity always gets exactly WeaponGroupCount groups; a loadout with fewer is
// padded with empty groups. A failed materialization leaves any minted lots in the live ledger; they are not roots,
@@ -119,7 +152,7 @@ public static class Loadouts
var hullBuild = Resolve(itemManager, loadout.Hull, loadout.HullProduct, choose, "hull", failures, quality);
var hullData = cache.Get(loadout.Hull);
if (hullData is { HullType: HullType.Station })
- failures.Add($"hull: {hullData.Name} is a station hull; a preset builds a ship or a turret");
+ failures.Add($"hull: {hullData.Name} is a station hull; a loadout builds a ship or a turret");
var slotBuilds = loadout.Slots.Select(slot => Resolve(itemManager, slot.Design, slot.Product, choose, Cell(slot), failures, quality)).ToArray();
var groups = loadout.WeaponGroups ?? new int[0][];
var groupCount = itemManager.GameplaySettings.WeaponGroupCount;
diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs
index c0c8cf16f..80ec0d372 100644
--- a/Assets/Scripts/ServerShared/ZoneGenerator.cs
+++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs
@@ -397,17 +397,28 @@ 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. A boss flies its faction's fixed Loadout, never generated gear (ruling boss-gear-is-a-loadout);
+ // ChooseProduct only picks the brand of 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)}.");
continue;
}
- boss.Boss = true;
- pack.Entities.Add(boss);
+ var failures = new List();
+ var boss = Loadouts.Materialize(itemManager, null, loadout, GetLoadoutGenerator(bossFaction).ChooseProduct, failures);
+ if (boss == null)
+ {
+ itemManager.Log($"{bossFaction.Name}'s boss Loadout {loadout.Name} cannot be built: {string.Join("; ", failures)}");
+ continue;
+ }
+ boss.Faction = bossFaction;
+ var bossPack = EntitySerializer.Pack(boss);
+ bossPack.Boss = true;
+ pack.Entities.Add(bossPack);
pack.BossSpawned = true;
}