Loadouts.Commit takes bossOf: the Loadout and Faction.BossLoadout land in one batch

With bossOf set, the same conditional batch Expects the faction unchanged and Upserts it
with BossLoadout = KeyOf(Name), so both land or neither does. The batch lives in an
internal overload over a caller's cache; tests pin that a faction changed on disk after
the read refuses both, and that a missing boss faction writes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-21 20:36:14 +02:00
commit 80283d18a9
2 changed files with 92 additions and 9 deletions

View file

@ -96,19 +96,28 @@ public static class Loadouts
_ => ""
};
// 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 loadout only once it reloads.
// The catalog is a stack of packages, the shipped content included (ruling catalog-is-a-package-stack), so a loadout may
// name any design the stack holds; one whose package is later removed fails closed where it is built, with its reason logged.
// Throws and writes nothing 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 loadout changed on disk since this open is not clobbered: returns false then.
public static bool Commit(string catalogPath, Loadout loadout, bool replace)
// The only writer of Loadout records, and of the Faction.BossLoadout that names one. 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 loadout
// only once it reloads. The catalog is a stack of packages, the shipped content included (ruling
// catalog-is-a-package-stack), so a loadout may name any design the stack holds; one whose package is later removed fails
// closed where it is built, with its reason logged. With bossOf set, the same batch makes the loadout that faction's boss
// (Faction.BossLoadout), so the loadout and the reference land together or not at all.
// Throws and writes nothing when the file is missing (capture never creates a catalog), when bossOf names no faction in
// it, 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) and on the faction, so a record changed on disk since this open
// is not clobbered: returns false then, and neither is written.
public static bool Commit(string catalogPath, Loadout loadout, bool replace, CultRecordRef<Faction> bossOf = default)
{
if (!File.Exists(catalogPath))
throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a loadout commit never creates one.");
using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true);
return Commit(cache, loadout, replace, bossOf);
}
// The one batch Commit(path, ...) makes, over a writable catalog cache the caller opened.
internal static bool Commit(CultCache cache, Loadout loadout, bool replace, CultRecordRef<Faction> bossOf)
{
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)
@ -116,10 +125,17 @@ public static class Loadouts
var existing = cache.Get<Loadout>(key);
if (existing != null && !replace)
throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists; replace it explicitly.");
var boss = bossOf.IsSet()
? cache.Get(bossOf) ?? throw new InvalidOperationException($"Loadout '{loadout.Name}': the boss faction is not in the catalog.")
: null;
return cache.Commit(batch =>
{
batch.Expect(key, existing);
batch.Upsert(typeof(Loadout), loadout, key);
if (boss == null) return;
batch.Expect(bossOf.Key, boss);
boss.BossLoadout = new CultRecordRef<Loadout>(key);
batch.Upsert(boss.GetType(), boss, bossOf.Key);
});
}

View file

@ -143,6 +143,73 @@ public sealed class LoadoutTests : IDisposable
Assert.Equal(key, (where == "hull" ? written.Hull.Key : written.Slots[1].Design.Key).Value);
}
// With bossOf, one commit writes the loadout and makes it that faction's boss: Faction.BossLoadout names its key.
[Fact]
public void ABossCommitWritesTheLoadoutAndTheFactionsBossReference()
{
using (var cache = Open())
Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false, cache.RefOf(cache.GetByName<Faction>("Maker"))));
using (var cache = Open())
{
var loadout = Assert.Single(cache.GetAll<Loadout>());
Assert.Equal(Loadouts.KeyOf("Skiff build"), cache.RefOf(loadout).Key);
Assert.Same(loadout, Loadouts.BossOf(cache, cache.GetByName<Faction>("Maker"), out var refusal));
Assert.Equal(BossRefusal.None, refusal);
}
}
// The loadout and the boss reference are one batch: when the faction changed on disk after the commit's cache read it,
// the commit returns false and neither lands, though the loadout alone would have; the other writer's change survives.
[Fact]
public void ABossCommitOverAFactionChangedOnDiskWritesNeitherTheLoadoutNorTheReference()
{
Loadout loadout;
CultRecordRef<Faction> maker;
using (var read = Open())
{
loadout = HandBuilt(read);
maker = read.RefOf(read.GetByName<Faction>("Maker"));
}
using (var stale = AetheriaStores.Open(Catalog, catalogWritable: true))
{
Assert.NotNull(stale.Get(maker));
using (var other = AetheriaStores.Open(Catalog, catalogWritable: true))
{
var fresh = other.Get(maker);
Assert.True(other.Commit(batch =>
{
batch.Expect(maker.Key, fresh);
fresh.ShortName = "NEW";
batch.Upsert(typeof(Faction), fresh, maker.Key);
}));
}
Assert.False(Loadouts.Commit(stale, loadout, replace: false, maker));
}
using var after = Open();
Assert.Empty(after.GetAll<Loadout>());
var faction = after.Get(maker);
Assert.Equal("NEW", faction.ShortName);
Assert.False(faction.BossLoadout.IsSet());
}
// A boss faction the catalog does not hold is refused before anything is written.
[Fact]
public void ABossCommitNamingNoFactionIsRefusedAndNothingIsWritten()
{
var before = Hash(Catalog);
using (var cache = Open())
{
var error = Assert.Throws<InvalidOperationException>(() =>
Loadouts.Commit(Catalog, HandBuilt(cache), replace: false, new CultRecordRef<Faction>(new CultRecordKey("faction:absent"))));
Assert.Contains("boss faction is not in the catalog", error.Message);
}
Assert.Equal(before, Hash(Catalog));
}
// Recapturing a name refuses without replace and leaves the file untouched; with replace it replaces, never duplicates.
[Fact]
public void SameNameCaptureReplacesOnlyWhenAsked()