Aetheria/Assets/Scripts/ServerShared/Loadout.cs

162 lines
8.4 KiB
C#
Raw Permalink Normal View History

using System;
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
using System.Collections.Generic;
Capture presets through their own cache; keep the catalog read-only Opening the process cache's catalog writable in the editor was the wrong cut. It made catalog read-only only by convention, so a stray catalog write in play succeeded and a later flush could land play-mutated instances in Aetheria.cc. - The process cache opens the catalog read-only in every build again, so the missing-global check runs for it as it did before db83cd29. The writable empty-seed exemption from ab4ced0f stays for AetherDb and fixtures. - Loadouts.Commit(catalogPath, loadout, replace) is the only preset writer. It opens its own short-lived cache over the catalog, writable, commits the one preset conditionally on its key and disposes it. No other cache or instance is touched. The running game sees the preset only after the catalog reloads. CultLib at a0813c6 allows this: a read-only single-file store takes no lock and holds no handle, and the writer's commit takes the exclusive sidecar <path>.lock and replaces the file atomically. - Capture refuses, writing nothing, when Aetheria.cc is missing (it never creates the catalog), when a preset of that name exists under another key (CultCache does not reject duplicate names on write), or when the key holds a preset and replace is not given. - The console refuses a command with any character outside letters, digits, spaces and hyphens instead of stripping it, so "Mk.II" can no longer become "MkII". capturepreset refuses a second argument other than replace, so an unquoted capturepreset My Ship refuses instead of saving "My". - The capture log line says the session sees the preset after a reload, and that a Studio session opened earlier overwrites captured presets on save. - Tests: capture while the process cache holds the catalog read-only leaves it unchanged; a missing catalog refuses and creates nothing; a same name under a Studio-minted key refuses, with or without replace; a game-shaped read-only open missing a global throws. The existing preset tests now commit through the catalog path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:32:52 +02:00
using System.IO;
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
using System.Linq;
using GameCult.Caching;
using MessagePack;
using CultMath;
// A ship preset (a variant): item designs only, the hull's design, and per hull cell a design and its rotation. A design
// names no manufacturer; which product builds a slot is a fact of the galaxy the preset is materialized in, which keeps
// a preset portable across galaxies. Authored catalog data: written in CultCache Studio or by the editor capture
// command, read at runtime.
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
[CultDocument("aetheria.loadout", "1"), MessagePackObject]
public class Loadout
{
[CultName, Key(0)] public string Name;
[Key(1)] public CultRecordRef<HullData> Hull;
[Key(2)] public List<LoadoutSlot> Slots = new List<LoadoutSlot>();
[Key(3)] public int[][] WeaponGroups; // indices into Slots
}
[MessagePackObject]
public class LoadoutSlot
{
[Key(0)] public int2 Position; // hull cell passed to Entity.TryEquip(item, int2)
[Key(1)] public ItemRotation Rotation;
[Key(2)] public CultRecordRef<EquippableItemData> Design;
}
// The owner of loadouts: capture from a live ship, the only writer, and the only builder of a ship from a loadout.
public static class Loadouts
{
// Records the hull design and one slot per equipped item (gear, then cargo bays, then docking bays) with its cell,
// rotation and design. Entity.Equipment also holds the hull's own unit; that is Hull, not a slot. Nothing about the
// units themselves: no product, quality, ingredients, cargo or state.
public static Loadout Capture(ItemManager itemManager, Entity entity, string name)
{
var cache = itemManager.ItemData;
var items = entity.Equipment
.Concat<EquippedItem>(entity.CargoBays)
.Concat(entity.DockingBays)
.Where(item => item.EquippableItem != entity.Hull)
.Distinct()
.ToList();
return new Loadout
{
Name = name,
Hull = cache.RefOf((HullData) itemManager.GetData(entity.Hull)),
Slots = items.Select(item => new LoadoutSlot
{
Position = item.Position,
Rotation = item.EquippableItem.Rotation,
Design = cache.RefOf(itemManager.GetData(item.EquippableItem))
}).ToList(),
WeaponGroups = entity.WeaponGroups
.Select(group => group.items.Select(item => items.IndexOf(item)).ToArray())
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
.ToArray()
};
}
// A preset'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);
Capture presets through their own cache; keep the catalog read-only Opening the process cache's catalog writable in the editor was the wrong cut. It made catalog read-only only by convention, so a stray catalog write in play succeeded and a later flush could land play-mutated instances in Aetheria.cc. - The process cache opens the catalog read-only in every build again, so the missing-global check runs for it as it did before db83cd29. The writable empty-seed exemption from ab4ced0f stays for AetherDb and fixtures. - Loadouts.Commit(catalogPath, loadout, replace) is the only preset writer. It opens its own short-lived cache over the catalog, writable, commits the one preset conditionally on its key and disposes it. No other cache or instance is touched. The running game sees the preset only after the catalog reloads. CultLib at a0813c6 allows this: a read-only single-file store takes no lock and holds no handle, and the writer's commit takes the exclusive sidecar <path>.lock and replaces the file atomically. - Capture refuses, writing nothing, when Aetheria.cc is missing (it never creates the catalog), when a preset of that name exists under another key (CultCache does not reject duplicate names on write), or when the key holds a preset and replace is not given. - The console refuses a command with any character outside letters, digits, spaces and hyphens instead of stripping it, so "Mk.II" can no longer become "MkII". capturepreset refuses a second argument other than replace, so an unquoted capturepreset My Ship refuses instead of saving "My". - The capture log line says the session sees the preset after a reload, and that a Studio session opened earlier overwrites captured presets on save. - Tests: capture while the process cache holds the catalog read-only leaves it unchanged; a missing catalog refuses and creates nothing; a same name under a Studio-minted key refuses, with or without replace; a game-shaped read-only open missing a global throws. The existing preset tests now commit through the catalog path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:32:52 +02:00
// The only writer of presets. 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 file is missing (capture never creates a catalog), when a preset 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.
public static bool Commit(string catalogPath, Loadout loadout, bool replace)
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
{
Capture presets through their own cache; keep the catalog read-only Opening the process cache's catalog writable in the editor was the wrong cut. It made catalog read-only only by convention, so a stray catalog write in play succeeded and a later flush could land play-mutated instances in Aetheria.cc. - The process cache opens the catalog read-only in every build again, so the missing-global check runs for it as it did before db83cd29. The writable empty-seed exemption from ab4ced0f stays for AetherDb and fixtures. - Loadouts.Commit(catalogPath, loadout, replace) is the only preset writer. It opens its own short-lived cache over the catalog, writable, commits the one preset conditionally on its key and disposes it. No other cache or instance is touched. The running game sees the preset only after the catalog reloads. CultLib at a0813c6 allows this: a read-only single-file store takes no lock and holds no handle, and the writer's commit takes the exclusive sidecar <path>.lock and replaces the file atomically. - Capture refuses, writing nothing, when Aetheria.cc is missing (it never creates the catalog), when a preset of that name exists under another key (CultCache does not reject duplicate names on write), or when the key holds a preset and replace is not given. - The console refuses a command with any character outside letters, digits, spaces and hyphens instead of stripping it, so "Mk.II" can no longer become "MkII". capturepreset refuses a second argument other than replace, so an unquoted capturepreset My Ship refuses instead of saving "My". - The capture log line says the session sees the preset after a reload, and that a Studio session opened earlier overwrites captured presets on save. - Tests: capture while the process cache holds the catalog read-only leaves it unchanged; a missing catalog refuses and creates nothing; a same name under a Studio-minted key refuses, with or without replace; a game-shaped read-only open missing a global throws. The existing preset tests now commit through the catalog path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:32:52 +02:00
if (!File.Exists(catalogPath))
throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a preset capture never creates one.");
using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true);
var key = KeyOf(loadout.Name);
Capture presets through their own cache; keep the catalog read-only Opening the process cache's catalog writable in the editor was the wrong cut. It made catalog read-only only by convention, so a stray catalog write in play succeeded and a later flush could land play-mutated instances in Aetheria.cc. - The process cache opens the catalog read-only in every build again, so the missing-global check runs for it as it did before db83cd29. The writable empty-seed exemption from ab4ced0f stays for AetherDb and fixtures. - Loadouts.Commit(catalogPath, loadout, replace) is the only preset writer. It opens its own short-lived cache over the catalog, writable, commits the one preset conditionally on its key and disposes it. No other cache or instance is touched. The running game sees the preset only after the catalog reloads. CultLib at a0813c6 allows this: a read-only single-file store takes no lock and holds no handle, and the writer's commit takes the exclusive sidecar <path>.lock and replaces the file atomically. - Capture refuses, writing nothing, when Aetheria.cc is missing (it never creates the catalog), when a preset of that name exists under another key (CultCache does not reject duplicate names on write), or when the key holds a preset and replace is not given. - The console refuses a command with any character outside letters, digits, spaces and hyphens instead of stripping it, so "Mk.II" can no longer become "MkII". capturepreset refuses a second argument other than replace, so an unquoted capturepreset My Ship refuses instead of saving "My". - The capture log line says the session sees the preset after a reload, and that a Studio session opened earlier overwrites captured presets on save. - Tests: capture while the process cache holds the catalog read-only leaves it unchanged; a missing catalog refuses and creates nothing; a same name under a Studio-minted key refuses, with or without replace; a game-shaped read-only open missing a global throws. The existing preset tests now commit through the catalog path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:32:52 +02:00
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.");
var existing = cache.Get<Loadout>(key);
if (existing != null && !replace)
throw new InvalidOperationException($"Preset '{loadout.Name}' already exists; replace it explicitly.");
return cache.Commit(batch =>
{
batch.Expect(key, existing);
batch.Upsert(typeof(Loadout), loadout, key);
});
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
}
// All-or-nothing: returns a ship only when every design resolved, had an available product 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
Capture presets through their own cache; keep the catalog read-only Opening the process cache's catalog writable in the editor was the wrong cut. It made catalog read-only only by convention, so a stray catalog write in play succeeded and a later flush could land play-mutated instances in Aetheria.cc. - The process cache opens the catalog read-only in every build again, so the missing-global check runs for it as it did before db83cd29. The writable empty-seed exemption from ab4ced0f stays for AetherDb and fixtures. - Loadouts.Commit(catalogPath, loadout, replace) is the only preset writer. It opens its own short-lived cache over the catalog, writable, commits the one preset conditionally on its key and disposes it. No other cache or instance is touched. The running game sees the preset only after the catalog reloads. CultLib at a0813c6 allows this: a read-only single-file store takes no lock and holds no handle, and the writer's commit takes the exclusive sidecar <path>.lock and replaces the file atomically. - Capture refuses, writing nothing, when Aetheria.cc is missing (it never creates the catalog), when a preset of that name exists under another key (CultCache does not reject duplicate names on write), or when the key holds a preset and replace is not given. - The console refuses a command with any character outside letters, digits, spaces and hyphens instead of stripping it, so "Mk.II" can no longer become "MkII". capturepreset refuses a second argument other than replace, so an unquoted capturepreset My Ship refuses instead of saving "My". - The capture log line says the session sees the preset after a reload, and that a Studio session opened earlier overwrites captured presets on save. - Tests: capture while the process cache holds the catalog read-only leaves it unchanged; a missing catalog refuses and creates nothing; a same name under a Studio-minted key refuses, with or without replace; a game-shaped read-only open missing a global throws. The existing preset tests now commit through the catalog path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:32:52 +02:00
// itemManager.Random. A design is built by its first available product in record-key order. A game spawner is to
// pass LoadoutGenerator.IsAvailable as isAvailable, with no fallback to any manufacturer. The ship 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.
public static Ship Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout,
Predicate<FactionProductData> isAvailable, List<string> failures)
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
{
var cache = itemManager.ItemData;
var products = cache.GetAll<FactionProductData>().OrderBy(p => cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray();
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
var reported = failures.Count;
FactionProductData Resolve<T>(CultRecordRef<T> design, string where) where T : EquippableItemData
{
var data = cache.Get(design);
if (data == null)
{
failures.Add($"{where}: design {design.Key} is not in the catalog");
return null;
}
var product = products.FirstOrDefault(p => p.Design.Key.Equals(design.Key) && isAvailable(p));
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
if (product == null) failures.Add($"{where}: no available product of {data.Name}");
return product;
}
var hullProduct = Resolve(loadout.Hull, "hull");
var slotProducts = loadout.Slots.Select(slot => Resolve(slot.Design, Cell(slot))).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}");
foreach (var index in groups.SelectMany(group => group))
{
if (index < 0 || index >= loadout.Slots.Count) failures.Add($"weapon group: no slot {index}");
else if (cache.Get(loadout.Slots[index].Design) is { } design && !design.Behaviors.Any(behavior => behavior is WeaponData))
failures.Add($"weapon group: {Cell(loadout.Slots[index])}: {design.Name} is not a weapon");
}
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
if (failures.Count > reported) return null;
var hull = (EquippableItem) itemManager.CreateInstance(hullProduct);
var ship = new Ship(itemManager, liveZone, hull, itemManager.GameplaySettings.DefaultEntitySettings);
var equipped = new EquippedItem[loadout.Slots.Count];
for (var i = 0; i < loadout.Slots.Count; i++)
{
var slot = loadout.Slots[i];
var unit = (EquippableItem) itemManager.CreateInstance(slotProducts[i]);
unit.Rotation = slot.Rotation;
if (!ship.TryEquip(unit, slot.Position))
{
failures.Add($"{Cell(slot)}: {cache.Get(slot.Design).Name} does not fit");
continue;
}
equipped[i] = ship.Equipment
.Concat<EquippedItem>(ship.CargoBays)
.Concat(ship.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);
}
ship.WeaponGroups = built;
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
return ship;
}
private static string Cell(LoadoutSlot slot) => $"slot {slot.Position.x},{slot.Position.y}";
}