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>
This commit is contained in:
Meta Vens 2026-09-14 23:32:52 +02:00
commit d503f076e1
5 changed files with 121 additions and 38 deletions

View file

@ -42,6 +42,8 @@ public class ActionGameManager : MonoBehaviour
private static CultCache _cultCache;
private static string CatalogPath => Path.Combine(GameDataDirectory.FullName, "Aetheria.cc");
public static CultCache CultCache
{
get
@ -50,12 +52,11 @@ 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.
// The catalog is read-only in every build; capturepreset writes through its own cache (Loadouts.Commit).
_cultCache = AetheriaStores.Open(
Path.Combine(GameDataDirectory.FullName, "Aetheria.cc"),
CatalogPath,
runPath: Path.Combine(GameDataDirectory.FullName, "run.cc"),
playerPath: Path.Combine(GameDataDirectory.FullName, "player.cc"),
catalogWritable: Application.isEditor);
playerPath: Path.Combine(GameDataDirectory.FullName, "player.cc"));
return _cultCache;
}
@ -546,18 +547,27 @@ public class ActionGameManager : MonoBehaviour
//Temporary, or not
ConsoleController.AddCommand("tow", _ => TowShip());
// Editor only: capturepreset "<name>" [replace] writes the piloted ship as a catalog preset.
// Editor only: capturepreset "<name>" [replace] writes the piloted ship as a catalog preset. A multi-word name must
// be quoted; any other second argument than replace is refused rather than dropped.
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; }
var replace = args.Length == 2 && args[1] == "replace";
if (string.IsNullOrWhiteSpace(name) || args.Length > 2 || args.Length == 2 && !replace)
{
console.AppendLogLine("usage: capturepreset \"<name>\" [replace] (quote a name with spaces)");
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");
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");
}
catch (InvalidOperationException e)
{

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using GameCult.Caching;
using MessagePack;
@ -60,13 +61,21 @@ public static class Loadouts
// 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)
// 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)
{
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);
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.");
@ -80,9 +89,9 @@ public static class Loadouts
// 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.
// 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.
public static Ship Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout,
Predicate<FactionProductData> isAvailable, List<string> failures)
{

View file

@ -76,6 +76,10 @@ public class ConsoleController
string[] commandSplit = ParseArguments(commandString);
string[] args = {"",""};
if (commandSplit == null) {
AppendLogLine("Refused: commands take only letters, digits, spaces and hyphens");
return;
}
if (commandSplit.Length < 1) {
AppendLogLine($"Unable to process command '{commandString}'");
return;
@ -115,10 +119,7 @@ public class ConsoleController
var parmCharsArr = new char[parmChars.Count];
parmChars.CopyTo(parmCharsArr, 0);
var args = new string(parmCharsArr).Split(new[] {'\n'}, StringSplitOptions.RemoveEmptyEntries);
for (var i = 0; i < args.Length; i++)
{
args[i] = _permittedCharacters.Replace(args[i], "");
}
return args;
// An argument with any other character is refused (null), never silently stripped into a different one.
return args.Any(arg => _permittedCharacters.IsMatch(arg)) ? null : args;
}
}

View file

@ -136,6 +136,18 @@ public sealed class AetheriaStoresTests : IDisposable
Assert.Contains("aetheria.tests.catalogglobal", error.Message);
}
// The game opens the catalog read-only with its run and player stores, in the editor as in a build: a populated
// catalog missing a global refuses to open.
[Fact]
public void GameShapedOpenMissingGlobalIsLoud()
{
using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true))
Assert.True(cache.Commit(batch => batch.Remove(cache.RefOf(cache.GetGlobal<TestCatalogGlobal>()).Key)));
var error = Assert.Throws<InvalidOperationException>(() => AetheriaStores.Open(Catalog, Run, Player));
Assert.Contains("aetheria.tests.catalogglobal", error.Message);
}
private static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)));
private static string[] SchemaNames(string path)

View file

@ -46,12 +46,9 @@ public sealed class LoadoutTests : IDisposable
public void Dispose() => Directory.Delete(_root, true);
// The game's shape: a read-only catalog with the run and player stores.
// The game's shape in every build, the editor included: a read-only catalog with the run and player stores.
private CultCache Open() => AetheriaStores.Open(Catalog, Run, Player);
// The editor's shape: the catalog writable, as the capture command sees it.
private CultCache OpenAuthoring() => AetheriaStores.Open(Catalog, Run, Player, catalogWritable: true);
// Gear on the hull's hardpoint cell, cargo on an interior cell, and two empty weapon groups (fewer than the game's six).
private static Loadout HandBuilt(CultCache cache, string name = "Skiff build") => new Loadout
{
@ -85,7 +82,7 @@ public sealed class LoadoutTests : IDisposable
public void CapturedPresetRoundTripsThroughTheCatalog()
{
Loadout hand;
using (var cache = OpenAuthoring())
using (var cache = Open())
{
var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { });
hand = Armed(cache);
@ -93,7 +90,7 @@ public sealed class LoadoutTests : IDisposable
var ship = Build(items, hand, failures);
Assert.Empty(failures);
Assert.NotNull(ship);
Assert.True(Loadouts.Commit(cache, Loadouts.Capture(items, ship, hand.Name), replace: false));
Assert.True(Loadouts.Commit(Catalog, Loadouts.Capture(items, ship, hand.Name), replace: false));
}
using (var cache = Open())
@ -123,31 +120,30 @@ public sealed class LoadoutTests : IDisposable
[Fact]
public void SameNameCaptureReplacesOnlyWhenAsked()
{
using (var cache = OpenAuthoring())
using (var cache = Open())
{
Assert.True(Loadouts.Commit(cache, HandBuilt(cache), replace: false));
Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false));
var before = Hash(Catalog);
var error = Assert.Throws<InvalidOperationException>(() => Loadouts.Commit(cache, Armed(cache, "Skiff build"), replace: false));
var error = Assert.Throws<InvalidOperationException>(() => Loadouts.Commit(Catalog, Armed(cache, "Skiff build"), replace: false));
Assert.Contains("Skiff build", error.Message);
Assert.Equal(before, Hash(Catalog));
Assert.Equal(2, Assert.Single(cache.GetAll<Loadout>()).WeaponGroups.Length);
Assert.True(Loadouts.Commit(cache, Armed(cache, "Skiff build"), replace: true));
Assert.Equal(6, Assert.Single(cache.GetAll<Loadout>()).WeaponGroups.Length);
Assert.True(Loadouts.Commit(Catalog, Armed(cache, "Skiff build"), replace: true));
}
using (var cache = Open())
Assert.Equal(6, Assert.Single(cache.GetAll<Loadout>()).WeaponGroups.Length);
}
// The editor's catalog is writable while play mutates live catalog instances; a preset commit must land only itself.
// Play mutates live catalog instances; a preset commit must land only itself.
[Fact]
public void PresetCommitWritesNoOtherInMemoryCatalogState()
{
using (var cache = OpenAuthoring())
using (var cache = Open())
{
cache.GetByName<Faction>("Maker").ShortName = "XXX";
Assert.True(Loadouts.Commit(cache, HandBuilt(cache), replace: false));
Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false));
cache.FlushAsync().Wait();
}
using (var cache = Open())
@ -157,6 +153,61 @@ public sealed class LoadoutTests : IDisposable
}
}
// The process cache holds the catalog read-only while capture writes through its own cache: the capture lands, and
// the process cache keeps its instances, gains nothing and stays clean until it reloads.
[Fact]
public void CaptureWhileTheCatalogIsHeldReadOnlyLeavesTheProcessCacheUnchanged()
{
using (var cache = Open())
{
var maker = cache.GetByName<Faction>("Maker");
Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false));
Assert.Empty(cache.GetAll<Loadout>());
Assert.Same(maker, cache.GetByName<Faction>("Maker"));
Assert.False(cache.IsDirty);
Assert.Throws<InvalidOperationException>(() => cache.Upsert(new Faction { Name = "Usurper" }));
}
using (var cache = Open())
Assert.Equal("Skiff build", Assert.Single(cache.GetAll<Loadout>()).Name);
}
// Capture never creates a catalog: a missing file refuses, and no file or directory appears.
[Fact]
public void CaptureWithAMissingCatalogRefusesAndCreatesNothing()
{
var absent = Path.Combine(_root, "absent", "Aetheria.cc");
using var cache = Open();
var error = Assert.Throws<InvalidOperationException>(() => Loadouts.Commit(absent, HandBuilt(cache), replace: false));
Assert.Contains("does not exist", error.Message);
Assert.False(Directory.Exists(Path.GetDirectoryName(absent)));
}
// A preset authored in Studio has a minted key; capturing its name refuses, replace or not, and names that key.
[Fact]
public void SameNameUnderAnotherKeyRefuses()
{
using (var writer = AetheriaStores.Open(Catalog, catalogWritable: true))
{
writer.Upsert(HandBuilt(writer));
writer.FlushAsync().Wait();
}
var before = Hash(Catalog);
using (var cache = Open())
{
var studioKey = cache.RefOf(Assert.Single(cache.GetAll<Loadout>())).Key;
Assert.NotEqual(Loadouts.KeyOf("Skiff build"), studioKey);
foreach (var replace in new[] { false, true })
{
var error = Assert.Throws<InvalidOperationException>(() => Loadouts.Commit(Catalog, Armed(cache, "Skiff build"), replace));
Assert.Contains(studioKey.Value, error.Message);
}
}
Assert.Equal(before, Hash(Catalog));
}
[Fact]
public void LoadoutHoldsNoRunOrGalaxyRefs()
{
@ -182,8 +233,8 @@ public sealed class LoadoutTests : IDisposable
pending.Enqueue(field.FieldType);
}
using (var cache = OpenAuthoring())
Loadouts.Commit(cache, HandBuilt(cache), replace: false);
using (var cache = Open())
Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false));
Assert.Contains("aetheria.loadout", SchemaNames(Catalog));
Assert.True(!File.Exists(Run) || SchemaNames(Run).Length == 0);
Assert.True(!File.Exists(Player) || SchemaNames(Player).Length == 0);