Faction.BossLoadout (key 22): the boss is its faction's Loadout, resolved by Loadouts.BossOf and built by Materialize
BossOf refuses typed (BossRefusal Unset or Missing, Describe names the field and echoes no input). ZoneGenerator builds the boss from the Loadout with ChooseProduct as the chooser; a refused or unbuildable boss logs and spawns none. Galaxy picks boss factions through BossOf; AetherDb factions prints the boss Loadout. Loadout vocabulary: no preset, no variant. Production builds; the tests project does not until the next commit (RunGoalTests still sets BossHull). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
900fa51e43
commit
352e221453
7 changed files with 78 additions and 33 deletions
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<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).
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<T>(Predicate<T> filter, bool required) where T : CraftedItemData
|
||||
|
|
|
|||
|
|
@ -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, 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;
|
||||
|
||||
|
|
|
|||
|
|
@ -583,7 +583,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)
|
||||
{
|
||||
|
|
@ -701,15 +701,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)));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue