diff --git a/Assets/Scripts/ServerShared/EntitySerializer.cs b/Assets/Scripts/ServerShared/EntitySerializer.cs index 4b727eee4..6fb749b29 100644 --- a/Assets/Scripts/ServerShared/EntitySerializer.cs +++ b/Assets/Scripts/ServerShared/EntitySerializer.cs @@ -128,6 +128,7 @@ public static class EntitySerializer // docking bay units, and their stored contents. The provenance ledger's GC root walk. public static IEnumerable Items(EntityPack pack) { + if (pack == null) throw new InvalidOperationException("This run's save is malformed: an entity is null. Start a new game."); yield return pack.Hull; foreach (var (_, item) in pack.Equipment) yield return item; foreach (var (_, item) in pack.CargoBays) yield return item; diff --git a/Assets/Scripts/ServerShared/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs index d5db1c533..71bd625af 100644 --- a/Assets/Scripts/ServerShared/SavedGame.cs +++ b/Assets/Scripts/ServerShared/SavedGame.cs @@ -96,22 +96,21 @@ public static class RunSave // built; the save is never edited to fit. public static void RequireDesigns(CultCache cache, SavedGame game) { - string[] named; - try + var designs = new List(); + foreach (var zoneRef in game.Zones ?? Array.Empty>()) { - named = (game.Zones ?? Array.Empty>()) - .SelectMany(zone => cache.Get(zone)?.Contents?.Entities ?? new List()) - .SelectMany(EntitySerializer.Items).Select(item => item.Data.Key) - .Concat(Lots(cache).Lots.Values.Select(lot => lot.Design.Key)) - .Where(key => key.IsSet()).Distinct() - .Where(key => cache.Get(key) == null) - .Select(key => key.Value).OrderBy(key => key, StringComparer.Ordinal).ToArray(); - } - catch (NullReferenceException error) - { - // A save with a null entity, item or lot where one is required is malformed, and refused like a missing design. - throw new InvalidOperationException("This run's save is malformed: an entity, item or lot is missing where one is required. Start a new game.", error); + var where = zoneRef.Key.Value; + var zone = cache.Get(zoneRef) ?? throw MalformedSave($"zone {where} has no record"); + // A zone never visited has no contents: legitimately null. + foreach (var pack in zone.Contents?.Entities ?? new List()) + foreach (var item in EntitySerializer.Items(pack)) + designs.Add((item ?? throw MalformedSave($"an item in {where} is null")).Data.Key); } + foreach (var (number, lot) in Lots(cache).Lots ?? throw MalformedSave("the lot ledger has no lots")) + designs.Add((lot ?? throw MalformedSave($"lot {number} is null")).Design.Key); + var named = designs.Where(key => key.IsSet()).Distinct() + .Where(key => cache.Get(key) == null) + .Select(key => key.Value).OrderBy(key => key, StringComparer.Ordinal).ToArray(); if (named.Length == 0) return; const string modHull = "mod-hull:"; var mods = named.Where(key => key.StartsWith(modHull, StringComparison.Ordinal)).Select(key => key.Substring(modHull.Length)).ToArray(); @@ -122,6 +121,9 @@ public static class RunSave ". Reinstall them, or start a new game."); } + private static InvalidOperationException MalformedSave(string where) => + new InvalidOperationException($"This run's save is malformed: {where}. Start a new game."); + // The stored ledger, or a fresh empty one when the run has minted nothing yet. public static ProvenanceLedger Lots(CultCache cache) => cache.GetGlobal() ?? new ProvenanceLedger(); diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index 7b3a44a1d..ee80add89 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -158,7 +158,10 @@ public static class ShipModCatalog if (!Directory.Exists(modsRoot) || !Directory.GetDirectories(modsRoot).Any(directory => File.Exists(Path.Combine(directory, "ship.cc")))) return (shippedCatalog, Array.Empty()); - var composition = Compose(shippedCatalog, derivedCatalog, modsRoot); + Composition composition; + // A failure of the composition itself (an unwritable derived path, say) is still a mod problem, never a reason not to boot. + try { composition = Compose(shippedCatalog, derivedCatalog, modsRoot); } + catch (Exception error) { return (shippedCatalog, new[] { new Exclusion { Package = "(all mods)", Reason = error.Message } }); } return (composition.Included.Length == 0 ? shippedCatalog : derivedCatalog, composition.Excluded); } diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 13f5b50d8..9384b93ed 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -439,18 +439,46 @@ public sealed class RunSaveTests : IDisposable "missing other designs: hull:B, hull:a, hull:b. Reinstall them, or start a new game.", refusal); } - // A save with a null where the walk needs an item is malformed: refused with a message, never a NullReferenceException. - [Fact] - public void RequireDesignsRefusesAMalformedSaveInsteadOfThrowingNull() + // A save can hold a null where the gate needs a value: refused with a message that says where, never a + // NullReferenceException. The records are written straight to the run store, since a save never writes them itself. + [Theory] + [InlineData("item", "an item in savedzone-0 is null")] + [InlineData("entity", "an entity is null")] + [InlineData("zone", "zone savedzone-1 has no record")] + [InlineData("lot", "lot 2 is null")] + [InlineData("lots", "the lot ledger has no lots")] + public void RequireDesignsRefusesANullWhereTheWalkNeedsAValue(string kind, string where) { - var refusal = DesignsRefused((present, unit) => + using var cache = Open(); + var pack = BarePack(new EquippableItem { Data = Key("hull:none"), Lot = 1 }); + if (kind == "item") pack.Equipment = new[] { (new int2(0, 0), (EquippableItem) null) }; + if (kind == "entity") pack.Children = new EntityPack[] { null }; + var ledger = new ProvenanceLedger { NextLot = 3, Lots = { [1] = new Lot { Origin = new Attributed() } } }; + if (kind == "lot") ledger.Lots[2] = null; + if (kind == "lots") ledger.Lots = null; + var game = Game(cache); + game.Zones = new[] { new CultRecordRef(new CultRecordKey("savedzone-0")), new CultRecordRef(new CultRecordKey("savedzone-1")) }; + cache.Commit(batch => { - var pack = BarePack(unit(present, present)); - pack.Equipment = new[] { (new int2(0, 0), (EquippableItem) null) }; - return new[] { ZoneOf(pack) }; + batch.Upsert(typeof(SavedZone), ZoneOf(pack), new CultRecordKey("savedzone-0")); + if (kind != "zone") batch.Upsert(typeof(SavedZone), ZoneOf(), new CultRecordKey("savedzone-1")); + batch.Upsert(game); + batch.Upsert(ledger); }); - Assert.Contains("save is malformed", refusal); + var refusal = Assert.Throws(() => RunSave.RequireDesigns(cache, cache.GetGlobal())); + Assert.Contains("save is malformed: " + where, refusal.Message); + } + + // The same run with nothing null is not malformed: null contents (a zone never visited) and unset designs are legitimate. + [Fact] + public void RequireDesignsAcceptsAZoneNeverVisitedAndAnUnsetDesign() + { + Assert.Null(DesignsRefused((present, unit) => new[] + { + new SavedZone { Name = "Never", AdjacentZones = Array.Empty(), Factions = Array.Empty(), Owner = -1 }, + ZoneOf(BarePack(unit(default, default))) + })); } [Fact] diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 825f63553..7db843d30 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -433,6 +433,26 @@ public sealed class ShipModCatalogTests : IDisposable Assert.Equal(new[] { "Barge", "Wasp" }, cache.GetAll().Select(hull => hull.Name).OrderBy(name => name)); } + // A failure of the composition itself is a mod problem too: the game boots on the shipped catalog and names it. + [Fact] + public void TheBootCatalogSurvivesAComposeThatFails() + { + WritePackage("mod.skiff"); + var blocker = Path.Combine(_directory.Path, "blocker"); + File.WriteAllText(blocker, "a file where the derived catalog's directory should be"); + + var (catalog, excluded) = ShipModCatalog.ResolveCatalog(Shipped, Path.Combine(blocker, "Aetheria.modded.cc"), Mods); + + Assert.Equal(Shipped, catalog); + var exclusion = Assert.Single(excluded); + Assert.Equal("(all mods)", exclusion.Package); + Assert.False(string.IsNullOrWhiteSpace(exclusion.Reason)); + // An invalid output (the shipped catalog itself) is refused the same way, with its reason. + (catalog, excluded) = ShipModCatalog.ResolveCatalog(Shipped, Shipped, Mods); + Assert.Equal(Shipped, catalog); + Assert.Contains("cannot replace the shipped catalog", Assert.Single(excluded).Reason); + } + // Gameplay reads a mod ship's records from the derived catalog; the package directory supplies only the GLB. [Fact] public void PackageOfTakesItsRecordsFromTheCatalogAndOnlyTheGlbFromTheMod()