Make loadouts authored catalog presets with an editor capture command

Loadouts are ship presets (variants) for spawners, not a player feature.

- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
  are deleted, since only the player menu priced loadouts. Availability,
  key-order product choice, all-or-nothing failures and the weapon-group
  rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
  RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
  editor-only console command capturepreset "<name>" [replace] captures the
  piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
  replace unless asked, and commits conditionally on that key. The single-file
  store then lands only that record onto the file as it is on disk. An
  unconditional commit would re-serialize every live catalog instance,
  including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
  no duplicate, and a preset commit that does not persist a mutated live
  Faction. The money-only tests are removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-14 23:09:43 +02:00
commit db83cd290d
6 changed files with 124 additions and 166 deletions

View file

@ -50,10 +50,12 @@ public class ActionGameManager : MonoBehaviour
// All three stores attach once and stay attached until the process exits. The run lifecycle is record-level
// inside this cache; reopening would replace the catalog instances the galaxy and live entities hold.
// The editor opens the catalog writable so the capturepreset console command can author presets in play.
_cultCache = AetheriaStores.Open(
Path.Combine(GameDataDirectory.FullName, "Aetheria.cc"),
runPath: Path.Combine(GameDataDirectory.FullName, "run.cc"),
playerPath: Path.Combine(GameDataDirectory.FullName, "player.cc"));
playerPath: Path.Combine(GameDataDirectory.FullName, "player.cc"),
catalogWritable: Application.isEditor);
return _cultCache;
}
@ -543,6 +545,25 @@ public class ActionGameManager : MonoBehaviour
});
//Temporary, or not
ConsoleController.AddCommand("tow", _ => TowShip());
// Editor only: capturepreset "<name>" [replace] writes the piloted ship as a catalog preset.
if (Application.isEditor)
ConsoleController.AddCommand("capturepreset", args =>
{
var console = ConsoleController.Instance;
var name = args.Length > 0 ? args[0] : "";
if (string.IsNullOrWhiteSpace(name)) { console.AppendLogLine("usage: capturepreset \"<name>\" [replace]"); return; }
if (!(_currentEntity is Ship ship)) { console.AppendLogLine("capturepreset: pilot a ship first"); return; }
try
{
var written = Loadouts.Commit(CultCache, Loadouts.Capture(ItemManager, ship, name), args.Length > 1 && args[1] == "replace");
console.AppendLogLine(written ? $"Preset '{name}' written to the catalog" : $"Preset '{name}' changed on disk since load; nothing written");
}
catch (InvalidOperationException e)
{
console.AppendLogLine(e.Message);
}
});
}
public void BeginDrag(DragObject dragObject)

View file

@ -6,9 +6,9 @@ using GameCult.Caching.MessagePack;
// Aetheria's stores: one home store per type, composed here and nowhere else.
public static class AetheriaStores
{
public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout) };
public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout) };
public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame) };
public static readonly Type[] PlayerTypes = { typeof(PlayerSettings), typeof(Loadout) };
public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) };
// Attaches (hydrates) the catalog read-only unless catalogWritable, then the run and player stores when given.
// A read-only catalog throws when a [CultGlobal] type routed to it has no record. A writable catalog is being

View file

@ -5,9 +5,10 @@ using GameCult.Caching;
using MessagePack;
using CultMath;
// A ship build as 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 loadout is materialized in, which keeps a
// loadout portable across galaxies. Player-store state: a loadout outlives runs.
// 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.
[CultDocument("aetheria.loadout", "1"), MessagePackObject]
public class Loadout
{
@ -56,24 +57,34 @@ public static class Loadouts
};
}
// One Commit to the player store. A loadout with the same name is replaced under its existing key; names are
// unique by construction because GetByName throws on duplicates.
public static void Save(CultCache cache, Loadout loadout)
// 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);
// Writes the preset to the catalog under KeyOf(Name). An existing preset of that name is replaced only when replace
// is set; otherwise this throws and writes nothing. The commit is conditional on the record at that key being what
// this cache holds, so it lands only this record onto the catalog file as it is on disk: no other in-memory catalog
// state is written, and a preset changed on disk since load (Studio) is not clobbered. Returns false on that conflict.
public static bool Commit(CultCache cache, Loadout loadout, bool replace)
{
var existing = cache.GetByName<Loadout>(loadout.Name);
var key = existing == null ? (CultRecordKey?) null : cache.TryGetHandle(existing)?.Key;
cache.Commit(batch => batch.Upsert(typeof(Loadout), loadout, key));
var key = KeyOf(loadout.Name);
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);
});
}
// All-or-nothing: returns a ship and charges Price only when credits cover Price, 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. Affordability is decided here and nowhere else. Every failure is listed; on any failure nothing is
// returned and nothing is charged. The ship, credits, zone and cache are unchanged, but a failed fit has already
// drawn from itemManager.Random. A design is built by its first available product in record-key order. The game
// passes 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.
// 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
// itemManager.Random. A design is built by its first available product in record-key order. The game passes
// 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.
public static Ship Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout,
Predicate<FactionProductData> isAvailable, ref int credits, List<string> failures)
Predicate<FactionProductData> isAvailable, List<string> failures)
{
var cache = itemManager.ItemData;
var products = cache.GetAll<FactionProductData>().OrderBy(p => cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray();
@ -104,8 +115,6 @@ public static class Loadouts
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");
}
var price = Price(itemManager, loadout);
if (credits < price) failures.Add($"cannot afford {price:n0} credits");
if (failures.Count > reported) return null;
var hull = (EquippableItem) itemManager.CreateInstance(hullProduct);
@ -136,17 +145,8 @@ public static class Loadouts
built[g] = (items.Select(item => item.GetBehavior<Weapon>()).ToList(), items);
}
ship.WeaponGroups = built;
credits -= (int) price;
return ship;
}
// The hull design's price plus each slot design's price. Summed as long so no loadout wraps to a negative price;
// a price beyond any int credit balance is simply unaffordable.
public static long Price(ItemManager itemManager, Loadout loadout)
{
var cache = itemManager.ItemData;
return (long) (cache.Get(loadout.Hull)?.Price ?? 0) + loadout.Slots.Sum(slot => (long) (cache.Get(slot.Design)?.Price ?? 0));
}
private static string Cell(LoadoutSlot slot) => $"slot {slot.Position.x},{slot.Position.y}";
}

View file

@ -149,7 +149,7 @@ public class LoadoutGenerator
// No galaxy means no availability to filter by: every product is on offer. A fixture generates loadouts that
// way, so a test can exercise placement and products without standing up a whole galaxy; no game path does.
// Restoring a loadout passes this to Loadouts.Materialize, so a loadout and generation share one availability rule.
// Materializing a preset passes this to Loadouts.Materialize, so presets and generation share one availability rule.
public bool IsAvailable(FactionProductData product) =>
Galaxy == null || Galaxy.IsPrelude ||
Galaxy.ContainsFaction(product.Manufacturer) && (Faction == null || Faction.Allegiance.ContainsKey(product.Manufacturer));

View file

@ -167,64 +167,11 @@ public class InventoryPanel : MonoBehaviour, IPointerClickHandler
if(GameManager.DockingBay!=null && _displayedCargo!=GameManager.DockingBay)
ContextMenu.AddOption(GameManager.DockingBay.Name, () => Display(GameManager.DockingBay));
if (_displayedEntity is Ship displayedShip)
{
ContextMenu.AddOption("Save Loadout",
() =>
{
var loadoutName = displayedShip.Name;
Dialog.Clear();
Dialog.Title.text = "Save Loadout";
Dialog.AddField("Name", () => loadoutName, s => loadoutName = s);
Dialog.Show(() => Loadouts.Save(ActionGameManager.CultCache,
Loadouts.Capture(GameManager.ItemManager, displayedShip, loadoutName)));
Dialog.MoveToCursor();
});
}
var loadouts = ActionGameManager.CultCache.GetAll<Loadout>().ToArray();
if (GameManager.DockedEntity != null && loadouts.Any())
{
ContextMenu.AddDropdown("Restore Loadout",
loadouts.Select<Loadout, (string text, Action action, bool enabled)>(loadout =>
{
var price = Loadouts.Price(GameManager.ItemManager, loadout);
return ($"{loadout.Name} - {price:n0}", () => RestoreLoadout(loadout), true);
}));
}
ContextMenu.Show();
});
}
}
// Builds the loadout against the current galaxy at the docked station. On failure nothing changes: no ship, no
// charge, and a dialog lists every slot that could not be built here.
private void RestoreLoadout(Loadout loadout)
{
var station = GameManager.DockedEntity;
var failures = new List<string>();
var availability = new LoadoutGenerator(ref GameManager.ItemManager.Random, GameManager.ItemManager,
ActionGameManager.CurrentGalaxy, GameManager.Zone.GalaxyZone, station.Faction, 0);
var ship = Loadouts.Materialize(GameManager.ItemManager, GameManager.Zone, loadout, availability.IsAvailable,
ref GameManager.Credits, failures);
if (ship == null)
{
Dialog.Clear();
Dialog.Title.text = "Loadout cannot be built here";
foreach (var failure in failures) Dialog.AddProperty(failure);
Dialog.Show();
Dialog.MoveToCursor();
return;
}
ship.SetParent(station);
ship.IsPlayerShip = true;
GameManager.DockingBay.DockedShip = ship;
GameManager.CurrentEntity = ship;
Display(ship);
}
private void Update()
{
if (_displayedEntity != null && TemperatureDisplay)