diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index 15e832a58..b8b73a0ab 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -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 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 bossOf) + { var key = KeyOf(loadout.Name); var namesake = cache.GetAll().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(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(key); + batch.Upsert(boss.GetType(), boss, bossOf.Key); }); } diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index c073bf37e..6f55baa76 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -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("Maker")))); + + using (var cache = Open()) + { + var loadout = Assert.Single(cache.GetAll()); + Assert.Equal(Loadouts.KeyOf("Skiff build"), cache.RefOf(loadout).Key); + Assert.Same(loadout, Loadouts.BossOf(cache, cache.GetByName("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 maker; + using (var read = Open()) + { + loadout = HandBuilt(read); + maker = read.RefOf(read.GetByName("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()); + 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(() => + Loadouts.Commit(Catalog, HandBuilt(cache), replace: false, new CultRecordRef(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()