diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index e24a6e395..9b4c6ad59 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -642,9 +642,9 @@ public class ActionGameManager : MonoBehaviour { var written = Loadouts.Commit(CatalogPath, Loadouts.Capture(ItemManager, ship, name), replace); console.AppendLogLine(written - ? $"Preset '{name}' written to {CatalogPath}. This session sees it after the catalog reloads (next launch). " + - "Reopen CultCache Studio before saving there: a Studio session opened earlier overwrites captured presets." - : $"Preset '{name}' changed on disk since load; nothing written"); + ? $"Loadout '{name}' written to {CatalogPath}. This session sees it after the catalog reloads (next launch). " + + "Reopen CultCache Studio before saving there: a Studio session opened earlier overwrites captured loadouts." + : $"Loadout '{name}' changed on disk since load; nothing written"); } catch (InvalidOperationException e) { diff --git a/Assets/Scripts/ServerShared/Corporations.cs b/Assets/Scripts/ServerShared/Corporations.cs index 9d24bc39e..3b676aef4 100644 --- a/Assets/Scripts/ServerShared/Corporations.cs +++ b/Assets/Scripts/ServerShared/Corporations.cs @@ -65,7 +65,7 @@ public class Faction // The maker's final assembly: the distribution every lot of theirs draws its own quality from, and the hard ceiling on each // of its roles (quality-lean-assembly-hard-ceiling). Null is not authored: a lot of theirs assembles at AssemblyDistribution's // defaults. assembly-from-maker derives it once at the part mean plus a tolerance margin (quality-min-tolerance-binned-assembly); - // tune it in Studio after that. Key 16 is reserved for Relations and 18 for Livery. + // tune it in Studio after that. Key 16 is reserved for Relations and 18 for Livery. Key 20 was assemblyDeviation (retired); never reuse it. [Inspectable, JsonProperty("assembly"), Key(19)] public AssemblyDistribution Assembly; @@ -73,6 +73,11 @@ public class Faction // Authored by AetherDb place-names, which restores the table on every apply. [Inspectable, JsonProperty("places"), Key(21)] public FactionPlace[] Places = Array.Empty(); + + // The Loadout this faction's boss flies; unset means the faction has no boss (ruling faction-boss-is-a-loadout-reference). + // The one owner of which Loadout is its boss, read through Loadouts.BossOf. + [Inspectable, JsonProperty("bossLoadout"), Key(22)] + public CultRecordRef BossLoadout; } // Historical, narrative and strategic intent join as keys 2+ when economics-driven placement lands (ruling faction-curated-place-names). diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs index 0b2f71387..ce7b30630 100644 --- a/Assets/Scripts/ServerShared/Galaxy.cs +++ b/Assets/Scripts/ServerShared/Galaxy.cs @@ -288,9 +288,9 @@ public class Galaxy progressCallback?.Invoke("Placing Factions"); if (progressCallback != null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao - // Choose some megas to have bosses placed based on whether a boss hull is assigned + // Choose some megas to have bosses placed: the factions whose boss Loadout resolves var bossMegas = Factions - .Where(m => m.BossHull.IsSet()) + .Where(m => Loadouts.BossOf(_cache, m, out _) != null) .Take(bossCount) .ToArray(); diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index bf8a2ac47..592c827e2 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -6,11 +6,11 @@ using GameCult.Caching; using MessagePack; using CultMath; -// A ship preset (a variant): the hull's design, and per hull cell a design and its rotation. A slot names a design and -// may name a product of it; a design-only slot is open on purpose, and whoever materializes the preset chooses its -// product from the galaxy it is built in (ruling loadout-product-optional-context-chooses), which keeps a preset -// portable across galaxies. A preset that needs a brand names it. Authored catalog data: written in CultCache Studio or by the editor capture -// command, read at runtime. +// A Loadout: a hull together with item designs or products equipped to specific cells (ruling boss-gear-is-a-loadout). +// A slot names a design, and names a product only where the loadout demands one; a design-only slot's product is chosen +// by whoever materializes it from the galaxy it is built in (ruling loadout-product-optional-context-chooses), which +// keeps a loadout portable across galaxies. A faction's boss flies one (Faction.BossLoadout). Authored catalog data: +// written in CultCache Studio or by the AetherDb and editor capture commands, read at runtime. [CultDocument("aetheria.loadout", "1"), MessagePackObject] public class Loadout { @@ -30,6 +30,9 @@ public class LoadoutSlot [Key(3)] public CultRecordRef Product; // unset: design-only, chosen at materialization } +// Why a faction has no boss Loadout to build; see Loadouts.BossOf. +public enum BossRefusal { None, Unset, Missing } + // The owner of loadouts: capture from a live ship, the only writer, and the only builder of an entity from a loadout. public static class Loadouts { @@ -66,30 +69,54 @@ public static class Loadouts private static CultRecordRef ProductRef(ItemManager itemManager, CraftedItemInstance unit) => itemManager.Brand(unit).Product is { } product ? itemManager.ItemData.RefOf(product) : default; - // A preset's key derives from its name, so capturing the same name again addresses the same record. + // A loadout's key derives from its name, so capturing the same name again addresses the same record. public static CultRecordKey KeyOf(string name) => new CultRecordKey("loadout:" + name); - // The only writer of presets. It opens its own short-lived cache over the catalog file, writable, and disposes it + // The one resolver of a faction's boss reference (ruling faction-boss-is-a-loadout-reference): the Loadout + // Faction.BossLoadout names. The boss is never generated, so this reads nothing but the reference and the catalog. Null with + // Unset when the faction sets none, and null with Missing when it sets one the catalog does not hold. + public static Loadout BossOf(CultCache items, Faction faction, out BossRefusal refusal) + { + if (!faction.BossLoadout.IsSet()) + { + refusal = BossRefusal.Unset; + return null; + } + + var loadout = items.Get(faction.BossLoadout); + refusal = loadout == null ? BossRefusal.Missing : BossRefusal.None; + return loadout; + } + + // Names the faction's field, and echoes no key or name of the input. + public static string Describe(BossRefusal refusal) => refusal switch + { + BossRefusal.Unset => "sets no Faction.BossLoadout", + BossRefusal.Missing => "names a Faction.BossLoadout that is not in the catalog", + _ => "" + }; + + // The only writer of Loadout records. It opens its own short-lived cache over the catalog file, writable, and disposes it // after the one commit. No other cache is touched, so no catalog instance play holds (or has mutated) can reach the - // file; a cache that already has the catalog open sees the preset only once it reloads. - // Throws and writes nothing when the loadout names a mod design (hull or slot), when the file is missing (capture never creates a catalog), when a preset of this name + // file; a cache that already has the catalog open sees the loadout only once it reloads. + // Throws and writes nothing when the loadout names a mod design (hull or slot), when the file is missing (capture never creates a catalog), when a loadout of this name // exists under another key, or when one exists under KeyOf(Name) and replace is not set. The commit is conditional - // on the record at KeyOf(Name), so a preset changed on disk since this open is not clobbered: returns false then. + // on the record at KeyOf(Name), so a loadout changed on disk since this open is not clobbered: returns false then. public static bool Commit(string catalogPath, Loadout loadout, bool replace) { var modDesigns = new[] { loadout.Hull.Key }.Concat(loadout.Slots.Select(slot => slot.Design.Key)).Where(ShipModCatalog.IsModKey).Select(key => key.Value).Distinct().ToArray(); if (modDesigns.Length > 0) - throw new InvalidOperationException($"Preset '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a preset."); + throw new InvalidOperationException($"Loadout '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a loadout."); if (!File.Exists(catalogPath)) - throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a preset capture never creates one."); + throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a loadout commit never creates one."); using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true); var key = KeyOf(loadout.Name); var namesake = cache.GetAll().FirstOrDefault(other => other.Name == loadout.Name && !cache.RefOf(other).Key.Equals(key)); if (namesake != null) - throw new InvalidOperationException($"Preset '{loadout.Name}' already exists under key {cache.RefOf(namesake).Key}; rename or remove it first."); + throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists under key {cache.RefOf(namesake).Key}; rename or remove it first."); var existing = cache.Get(key); if (existing != null && !replace) - throw new InvalidOperationException($"Preset '{loadout.Name}' already exists; replace it explicitly."); + throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists; replace it explicitly."); return cache.Commit(batch => { batch.Expect(key, existing); @@ -104,7 +131,8 @@ public static class Loadouts // WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure is listed; on any failure // nothing is returned and the zone and cache are unchanged, but a failed fit has already drawn from // itemManager.Random. How each design is built is Resolve's rule. A design-only slot is chosen by choose; a game - // spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does. The hull's type + // spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does, and so does the + // boss in ZoneGenerator, which is built here too from its faction's Loadout (Loadouts.BossOf). The hull's type // picks the entity: a ship hull builds a Ship, a turret hull an OrbitalEntity with no orbit, which stays where it is // put; a station hull is refused. The entity always gets exactly WeaponGroupCount groups; a loadout with fewer is // padded with empty groups. A failed materialization leaves any minted lots in the live ledger; they are not roots, @@ -119,7 +147,7 @@ public static class Loadouts var hullBuild = Resolve(itemManager, loadout.Hull, loadout.HullProduct, choose, "hull", failures, quality); var hullData = cache.Get(loadout.Hull); if (hullData is { HullType: HullType.Station }) - failures.Add($"hull: {hullData.Name} is a station hull; a preset builds a ship or a turret"); + failures.Add($"hull: {hullData.Name} is a station hull; a loadout builds a ship or a turret"); var slotBuilds = loadout.Slots.Select(slot => Resolve(itemManager, slot.Design, slot.Product, choose, Cell(slot), failures, quality)).ToArray(); var groups = loadout.WeaponGroups ?? new int[0][]; var groupCount = itemManager.GameplaySettings.WeaponGroupCount; diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 7de56b40b..849f0c352 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -183,6 +183,8 @@ public class LoadoutGenerator count); } + // An item is on offer only through a product, so a design no product makes is never drawn, stocked or chosen; it reaches a ship + // only through a Loadout (ruling off-market-is-general). // The products this faction can be offered for a design kind (only gear needs a home), and whether manufacturer preference applies (it does // not once a required item has fallen back to any manufacturer). The one availability rule for every selection. private ((FactionProductData product, T design)[] available, bool preferManufacturers) AvailableProducts(Predicate filter, bool required) where T : CraftedItemData diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 4d4c2613e..089ce3026 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -421,22 +421,33 @@ public static class ZoneGenerator } // A boss zone gets its faction's boss whether or not the zone is ambient: the boss is the run's goal, not - // wandering traffic. RunGoal reads the mark; nothing else decides whether a boss lives. + // wandering traffic. The boss is its faction's boss Loadout, built whole by Loadouts.Materialize and never drawn + // (ruling boss-gear-is-a-loadout); ChooseProduct only brands a design-only slot. RunGoal reads the mark; nothing + // else decides whether a boss lives. foreach (var bossFaction in galaxy.BossZones.Where(entry => entry.Value == galaxyZone).Select(entry => entry.Key)) { - var boss = GetLoadoutGenerator(bossFaction).GenerateBossLoadout(); - if (boss == null) + var loadout = Loadouts.BossOf(itemManager.ItemData, bossFaction, out var refusal); + if (loadout == null) { - itemManager.Log($"No boss could be generated for {bossFaction.Name}; its boss zone has no boss to kill!"); + itemManager.Log($"{bossFaction.Name} has no boss: it {Loadouts.Describe(refusal)}. Its boss zone has no boss to kill."); continue; } + var failures = new List(); + var entity = Loadouts.Materialize(itemManager, null, loadout, GetLoadoutGenerator(bossFaction).ChooseProduct, failures); + if (entity == null) + { + itemManager.Log($"{bossFaction.Name}'s boss Loadout {loadout.Name} cannot be built: {string.Join("; ", failures)}"); + continue; + } + entity.Faction = bossFaction; + var boss = EntitySerializer.Pack(entity); boss.Boss = true; pack.Entities.Add(boss); pack.BossSpawned = true; } if (galaxyZone == galaxy.Exit && !pack.BossSpawned) - itemManager.Log("Galaxy.Exit has no boss: Galaxy.BossZones does not map a boss faction to the exit zone, or its boss could not be generated (ZoneGenerator exit-without-boss). The exit gate stays sealed."); + itemManager.Log("Galaxy.Exit has no boss: Galaxy.BossZones does not map a boss faction to the exit zone, or its boss Loadout is unset, missing or cannot be built (ZoneGenerator exit-without-boss). The exit gate stays sealed."); if (!ambient) return pack; diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 5df9ed99c..103915d86 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -583,7 +583,7 @@ public static class Program // Every CultRecordRef in the catalog that resolves to nothing, as " -> ". // Each "clear " argument unsets that member's dangling refs, or removes that dictionary's - // dangling keys (Faction.BossHull clears boss hulls that would claim a chokepoint no boss can spawn in). Dry + // dangling keys (a faction's BossLoadout member clears boss references that name no Loadout). Dry // run unless passed "apply", which alone opens the catalog writable and lands every changed record in one commit. private static int Dangling(string[] args) { @@ -701,15 +701,14 @@ public static class Program { var db = AetherDb.Open(); var products = db.Cache.GetAll().ToArray(); - Console.WriteLine($"{"faction",-26} {"short",-13} {"places",-22} {"boss hull",-14} {"influence",-9} {"doctrine",-10} products"); + Console.WriteLine($"{"faction",-26} {"short",-13} {"places",-22} {"boss loadout",-14} {"influence",-9} {"doctrine",-10} products"); var broken = 0; foreach (var faction in db.Cache.GetAll().OrderBy(f => f.Name)) { var key = db.Cache.RefOf(faction).Key; - var boss = !faction.BossHull.IsSet() - ? "none" - : db.Cache.Get(faction.BossHull)?.Name ?? "DANGLING"; - if (boss == "DANGLING") broken++; + var loadout = Loadouts.BossOf(db.Cache, faction, out var refusal); + var boss = refusal == BossRefusal.Unset ? "none" : loadout?.Name ?? "MISSING"; + if (refusal == BossRefusal.Missing) broken++; Console.WriteLine($"{faction.Name,-26} {faction.ShortName,-13} {faction.Places.Length,-22} {boss,-14} {faction.InfluenceDistance,-9} {(faction.Doctrine == null ? "none" : faction.Doctrine.EngageOn.ToString()),-10} " + products.Count(p => p.Manufacturer.Key.Equals(key))); }