diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index f12ea2633..a0c537f66 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -42,6 +42,9 @@ public class ActionGameManager : MonoBehaviour private static CultCache _cultCache; + // Mod packages left out of this boot (a bad package, or a name collision), for the main menu to name. + public static ShipModCatalog.Exclusion[] ModExclusions { get; private set; } = Array.Empty(); + private static string CatalogPath => Path.Combine(GameDataDirectory.FullName, "Aetheria.cc"); private static string ModsPath => Path.Combine(GameDataDirectory.FullName, "Mods"); // Disposable: composed at every boot from the shipped catalog and the mod packages, never authored. @@ -57,8 +60,10 @@ public class ActionGameManager : MonoBehaviour // inside this cache; reopening would replace the catalog instances the galaxy and live entities hold. // The catalog is read-only in every build; capturepreset writes through its own cache (Loadouts.Commit) to the // shipped file. With mod packages installed the game reads the derived catalog instead. + var (catalog, excluded) = ShipModCatalog.ResolveCatalog(CatalogPath, DerivedCatalogPath, ModsPath); + ModExclusions = excluded; _cultCache = AetheriaStores.Open( - ShipModCatalog.ResolveCatalog(CatalogPath, DerivedCatalogPath, ModsPath), + catalog, runPath: Path.Combine(GameDataDirectory.FullName, "run.cc"), playerPath: Path.Combine(GameDataDirectory.FullName, "player.cc")); diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index d8b868cf5..2775241fd 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -64,11 +64,14 @@ public static class Loadouts // 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 + // Throws and writes nothing when the loadout names a mod design (hull or slot), 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) { + var modDesigns = new[] { loadout.Hull.Key }.Concat(loadout.Slots.Select(slot => slot.Design.Key)).Where(ShipModCatalog.IsModKey).Select(key => key.Value).Distinct().ToArray(); + if (modDesigns.Length > 0) + throw new InvalidOperationException($"Preset '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a preset."); 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); diff --git a/Assets/Scripts/ServerShared/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs index ed5cdecf1..d5db1c533 100644 --- a/Assets/Scripts/ServerShared/SavedGame.cs +++ b/Assets/Scripts/ServerShared/SavedGame.cs @@ -96,13 +96,22 @@ public static class RunSave // built; the save is never edited to fit. public static void RequireDesigns(CultCache cache, SavedGame game) { - var 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(); + string[] named; + try + { + 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); + } 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(); diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index ffdc560b3..d7263fd42 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -219,8 +219,15 @@ public static class ShipAuthoringStore if (!occupiedHardpointCells.Add((x, y))) throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} overlaps another hardpoint."); } - if (!anchors.Any(anchor => anchor.Id == hardpoint.Transform)) + var mountAnchor = anchors.FirstOrDefault(anchor => anchor.Id == hardpoint.Transform); + if (mountAnchor == null) throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} has no model anchor."); + // A mount's anchor plays the mount, so its role is fixed by the hardpoint type; the structural roles (map-icon, + // hull-collider, shield, tractor, weapon-muzzle) name other nodes and never double as a mount. + var mountRole = hardpoint.Type == HardpointType.Thruster ? "thruster-emitter" : + hardpoint.Type == HardpointType.Radiator ? "radiator-mesh" : "articulation"; + if (mountAnchor.Role != mountRole) + throw new InvalidOperationException($"{ship.Id}: {hardpoint.Type} hardpoint {hardpoint.Transform} needs a {mountRole} anchor of that id."); } foreach (var anchor in anchors.Where(anchor => !string.IsNullOrEmpty(anchor.ParentId))) if (!mounts.Contains(anchor.ParentId)) diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index d3751feb8..7b3a44a1d 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -21,7 +21,31 @@ public static class ShipModCatalog public static CultRecordKey HullKey(string id) => new CultRecordKey("mod-hull:" + id); public static CultRecordKey AuthoringKey(string id) => new CultRecordKey("mod-ship:" + id); - public static int Compose(string shippedCatalog, string outputCatalog, string modsRoot) + // A key the mods own. The shipped catalog and its authored records never reference one: a mod may be uninstalled. + public static bool IsModKey(CultRecordKey key) => + key.IsSet() && (key.Value.StartsWith("mod-hull:", StringComparison.Ordinal) || key.Value.StartsWith("mod-ship:", StringComparison.Ordinal)); + + // A package left out of the composition, named by its directory, with every reason it was left out. + public sealed class Exclusion + { + public string Package; + public string Reason; + public override string ToString() => $"{Package}: {Reason}"; + } + + public sealed class Composition + { + // Ship IDs of the packages composed into the derived catalog, in directory order. + public string[] Included; + // Packages that failed on their own or collided with the catalog or with each other, in directory order. + public Exclusion[] Excluded; + } + + // One bad package never stops the rest. Each package is read and validated on its own; one that fails is excluded and + // named. A collision (with the shipped catalog, or with another package's hull name) excludes every package involved, + // since neither has a claim to the name. The derived catalog holds the shipped catalog plus the included packages. + // Unsafe inputs and outputs (a missing catalog, a derived file that would replace a source) still throw: nothing is composed. + public static Composition Compose(string shippedCatalog, string outputCatalog, string modsRoot) { var source = Path.GetFullPath(shippedCatalog); var output = Path.GetFullPath(outputCatalog); @@ -33,13 +57,21 @@ public static class ShipModCatalog if (output.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("The derived catalog cannot replace a mod source or asset."); - var packages = new List(); + var reasons = new SortedDictionary>(StringComparer.Ordinal); + void Exclude(string package, string reason) + { + if (!reasons.TryGetValue(package, out var list)) reasons[package] = list = new List(); + list.Add(reason); + } + var packages = new List<(string Directory, Package Package)>(); foreach (var directory in Directory.GetDirectories(root).OrderBy(path => path, StringComparer.Ordinal)) { var path = Path.Combine(directory, "ship.cc"); if (!File.Exists(path)) continue; - // ReadPackage pins each ID to its directory name, so IDs within one mods root are already unique. - packages.Add(ReadPackage(path)); + var name = Path.GetFileName(directory); + // A package can fail in any way a hostile or broken file can, so its failure is quarantined whatever it is. + try { packages.Add((name, ReadPackage(path))); } + catch (Exception error) { Exclude(name, error.Message); } } Directory.CreateDirectory(Path.GetDirectoryName(output)); @@ -51,33 +83,48 @@ public static class ShipModCatalog { Directory.CreateDirectory(workspace); File.Copy(source, temporary); + Composition composition; using (var cache = AetheriaStores.Open(temporary, catalogWritable: true)) { - foreach (var package in packages) + // Collisions are judged against the shipped records alone, before any package is written, so the + // verdict does not depend on directory order. CultCache indexes names per concrete type and lets the + // last writer win, and GetByName over a base type throws on a match across subtypes: a mod hull named + // like any shipped item, or like another mod's hull, would make name lookups wrong or throw. + var keys = new HashSet(cache.AllStoredDocuments.Select(record => record.Key)); + var itemNames = new HashSet(cache.GetAll().Select(item => item.Name), StringComparer.Ordinal); + var shipIds = new HashSet(cache.GetAll().Select(existing => existing.Id), StringComparer.Ordinal); + foreach (var (directory, package) in packages) { var (hull, ship) = (package.Hull, package.Visual); - var hullKey = HullKey(ship.Id); - var authoringKey = AuthoringKey(ship.Id); - if (cache.AllStoredDocuments.Any(record => record.Key.Equals(hullKey) || record.Key.Equals(authoringKey))) - throw new InvalidOperationException($"{ship.Id}: a mod key collides with an existing catalog record."); - // CultCache indexes names per concrete type and lets the last writer win, and GetByName over a base - // type throws on a match across subtypes. A mod hull named like any shipped item, or another mod's - // hull (upserted earlier in this loop), would make name lookups wrong or throw. - if (cache.GetAll().Any(item => string.Equals(item.Name, hull.Name, StringComparison.Ordinal))) - throw new InvalidOperationException($"{ship.Id}: hull name '{hull.Name}' collides with an existing catalog item."); - if (cache.GetAll().Any(existing => string.Equals(existing.Id, ship.Id, StringComparison.Ordinal))) - throw new InvalidOperationException($"{ship.Id}: ship ID collides with an existing catalog ship authoring record."); - // Verbatim: the mod store's own two records, under the keys it already holds them at. - ShipAuthoringStore.Write(cache, hull, ship); + if (keys.Contains(HullKey(ship.Id)) || keys.Contains(AuthoringKey(ship.Id))) + Exclude(directory, $"a mod key collides with an existing catalog record."); + if (itemNames.Contains(hull.Name)) + Exclude(directory, $"hull name '{hull.Name}' collides with an existing catalog item."); + if (shipIds.Contains(ship.Id)) + Exclude(directory, $"ship ID collides with an existing catalog ship authoring record."); } + foreach (var claim in packages.GroupBy(package => package.Package.Hull.Name, StringComparer.Ordinal).Where(group => group.Count() > 1)) + foreach (var (directory, package) in claim) + Exclude(directory, $"hull name '{claim.Key}' is also claimed by " + + string.Join(", ", claim.Where(other => other.Directory != directory).Select(other => other.Directory)) + "."); + + var included = packages.Where(package => !reasons.ContainsKey(package.Directory)).Select(package => package.Package).ToArray(); + foreach (var package in included) + // Verbatim: the mod store's own two records, under the keys it already holds them at. + ShipAuthoringStore.Write(cache, package.Hull, package.Visual); // Shipped hulls too: the derived catalog must not hold a hull that names two bodies. foreach (var hull in cache.GetAll()) ShipAuthoringStore.RequireOneBody(hull, hull.Name); cache.FlushAsync().GetAwaiter().GetResult(); + composition = new Composition + { + Included = included.Select(package => package.Visual.Id).ToArray(), + Excluded = reasons.Select(entry => new Exclusion { Package = entry.Key, Reason = string.Join("; ", entry.Value) }).ToArray() + }; } if (File.Exists(output)) File.Replace(temporary, output, null); else File.Move(temporary, output); - return packages.Count; + return composition; } finally { @@ -103,16 +150,16 @@ public static class ShipModCatalog return Bind(hull, ship, directory, Path.Combine(directory, "ship.cc")); } - // The one game-side choice of catalog: the shipped file when the mods root holds no package, otherwise the derived - // file, composed afresh from the shipped one and the packages. Recomposed every call, so the derived file is never - // stale and never an authority. - public static string ResolveCatalog(string shippedCatalog, string derivedCatalog, string modsRoot) + // The one game-side choice of catalog: the shipped file when no package is installed or none survives composition, + // otherwise the derived file, composed afresh from the shipped one and the surviving packages. Recomposed every + // call, so the derived file is never stale and never an authority. Excluded packages come back named, for the menu. + public static (string Catalog, Exclusion[] Excluded) ResolveCatalog(string shippedCatalog, string derivedCatalog, string modsRoot) { if (!Directory.Exists(modsRoot) || !Directory.GetDirectories(modsRoot).Any(directory => File.Exists(Path.Combine(directory, "ship.cc")))) - return shippedCatalog; - Compose(shippedCatalog, derivedCatalog, modsRoot); - return derivedCatalog; + return (shippedCatalog, Array.Empty()); + var composition = Compose(shippedCatalog, derivedCatalog, modsRoot); + return (composition.Included.Length == 0 ? shippedCatalog : derivedCatalog, composition.Excluded); } private static Package Bind(HullData hull, ShipAuthoring ship, string directory, string path) diff --git a/Assets/Scripts/ServerShared/ShipModPlan.cs b/Assets/Scripts/ServerShared/ShipModPlan.cs index e0c84c517..6b696bcaa 100644 --- a/Assets/Scripts/ServerShared/ShipModPlan.cs +++ b/Assets/Scripts/ServerShared/ShipModPlan.cs @@ -25,18 +25,15 @@ public sealed class ShipModPlan ShipAuthoringStore.Validate(hull, visual); var hardpoints = hull.Hardpoints ?? new List(); var byMount = hardpoints.ToDictionary(hardpoint => hardpoint.Transform, StringComparer.Ordinal); - var role = visual.Anchors.ToDictionary(anchor => anchor.Id, anchor => anchor.Role, StringComparer.Ordinal); string Only(string wanted) => visual.Anchors.Single(anchor => anchor.Role == wanted).Id; foreach (var anchor in visual.Anchors.Where(anchor => anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh")) { var wanted = anchor.Role == "thruster-emitter" ? HardpointType.Thruster : HardpointType.Radiator; - if (!byMount.TryGetValue(anchor.Id, out var mount) || mount.Type != wanted) + if (!byMount.ContainsKey(anchor.Id)) throw new InvalidOperationException($"{visual.Id}: {anchor.Role} anchor {anchor.Id} must be a {wanted} hardpoint's mount."); } - string[] Mounts(HardpointType type, string wantedRole) => hardpoints.Where(hardpoint => hardpoint.Type == type).Select(hardpoint => - role[hardpoint.Transform] == wantedRole ? hardpoint.Transform : - throw new InvalidOperationException($"{visual.Id}: {type} hardpoint {hardpoint.Transform} needs a {wantedRole} anchor of that id.")).ToArray(); + string[] Mounts(HardpointType type) => hardpoints.Where(hardpoint => hardpoint.Type == type).Select(hardpoint => hardpoint.Transform).ToArray(); var muzzles = visual.Anchors.Where(anchor => anchor.Role == "weapon-muzzle") .OrderBy(anchor => anchor.Order).ThenBy(anchor => anchor.Id, StringComparer.Ordinal).ToArray(); @@ -57,8 +54,8 @@ public sealed class ShipModPlan Shield = Only("shield"), Tractor = Only("tractor"), Equipment = hardpoints.Select(hardpoint => hardpoint.Transform).ToArray(), - Thrusters = Mounts(HardpointType.Thruster, "thruster-emitter"), - Radiators = Mounts(HardpointType.Radiator, "radiator-mesh"), + Thrusters = Mounts(HardpointType.Thruster), + Radiators = Mounts(HardpointType.Radiator), Weapons = weapons }; } diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index aedc0f5dc..9d17af138 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -89,6 +89,8 @@ public class MainMenu : MonoBehaviour _fadeFromRight = fromRight; } + private static bool _exclusionsShown; + private void ShowMain() { _nextMenu.panel.Clear(); @@ -97,6 +99,11 @@ public class MainMenu : MonoBehaviour { // A run exists only as the run store's SavedGame global. var cache = ActionGameManager.CultCache; + if (ActionGameManager.ModExclusions.Length > 0 && !_exclusionsShown) + { + _exclusionsShown = true; + Refuse("Some mods were not loaded", string.Join("\n", ActionGameManager.ModExclusions.Select(exclusion => exclusion.ToString()))); + } var saved = cache.GetGlobal(); if (saved == null && cache.AllStoredDocuments.Any(stored => RunSave.IsRunRecord(stored.Descriptor.DocumentType))) Debug.Log("run store has no SavedGame; Continue disabled"); diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index 48cd75cbd..24450dfaf 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -122,6 +122,29 @@ public sealed class LoadoutTests : IDisposable Assert.True(!File.Exists(Player) || !SchemaNames(Player).Contains("aetheria.loadout")); } + // The shipped catalog never references a mod: a mod may be uninstalled, and a preset naming one would dangle. The + // refusal is at the only writer, so no caller (the editor command included) can get one into the file. + [Theory] + [InlineData("hull", "mod-hull:mod.skiff")] + [InlineData("slot", "mod-hull:mod.skiff")] + [InlineData("slot", "mod-ship:mod.skiff")] + public void APresetNamingAModDesignIsRefusedAndNothingIsWritten(string where, string key) + { + var mod = new CultRecordRef(new CultRecordKey(key)); + using var cache = Open(); + var loadout = HandBuilt(cache); + if (where == "hull") loadout.Hull = new CultRecordRef(new CultRecordKey(key)); + else loadout.Slots[1].Design = mod; + var before = Hash(Catalog); + + var error = Assert.Throws(() => Loadouts.Commit(Catalog, loadout, replace: false)); + + Assert.Contains("names mod designs", error.Message); + Assert.Contains(key, error.Message); + Assert.Equal(before, Hash(Catalog)); + Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false)); + } + // Recapturing a name refuses without replace and leaves the file untouched; with replace it replaces, never duplicates. [Fact] public void SameNameCaptureReplacesOnlyWhenAsked() diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index c69239670..3cc495c20 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -341,6 +341,117 @@ public sealed class RunSaveTests : IDisposable Assert.Equal(before, File.ReadAllBytes(Run)); } + // --- RequireDesigns: what it walks, what it lists --------------------------------------------------------------- + + // Commits a run whose zones the caller builds, then returns RequireDesigns' refusal message, or null when it passes. + // `present` is a gear item ("Lamp", not a hull) the catalog holds; unit(design, lot) mints an item naming `design` + // whose lot names `lot`. + private string DesignsRefused(Func, Func, CultRecordRef, EquippableItem>, SavedZone[]> build) + { + CultRecordRef present; + using (var setup = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + var lamp = setup.GetAll().SingleOrDefault(gear => gear.Name == "Lamp") ?? + setup.Upsert(new GearData { Name = "Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape() }); + present = new CultRecordRef(setup.RefOf(lamp).Key); + setup.FlushAsync().Wait(); + } + using var cache = Open(); + var ledger = new ProvenanceLedger(); + EquippableItem Unit(CultRecordRef design, CultRecordRef lot) + { + var number = ledger.NextLot++; + ledger.Lots[number] = new Lot { Design = lot, Origin = new Attributed() }; + return new EquippableItem { Data = design, Lot = number }; + } + ledger.NextLot = 1; + RunSave.Commit(cache, Game(cache), build(present, Unit), ledger); + try { RunSave.RequireDesigns(cache, cache.GetGlobal()); return null; } + catch (InvalidOperationException refusal) { return refusal.Message; } + } + + private static CultRecordRef Key(string key) => new CultRecordRef(new CultRecordKey(key)); + + private static SavedZone ZoneOf(params EntityPack[] packs) => new SavedZone + { + Name = "Zone", AdjacentZones = Array.Empty(), Factions = Array.Empty(), Owner = -1, + Contents = new ZonePack { Entities = packs.ToList() } + }; + + // Whatever the slot, a design missing from the catalog is found, and a design the catalog holds (a gear item, not + // only a hull) is not: the gate walks every unit an entity carries, recursively, and resolves any item design. + [Theory] + [InlineData("hull")] + [InlineData("equipment")] + [InlineData("cargo bay")] + [InlineData("docking bay")] + [InlineData("cargo contents")] + [InlineData("docking contents")] + [InlineData("child hull")] + [InlineData("child equipment")] + public void RequireDesignsFindsAMissingDesignInEverySlotOfAnEntity(string slot) + { + string Refusal(bool missing) => DesignsRefused((present, unit) => + { + EquippableItem Slot(string where) => unit(missing && where == slot ? Key("hull:gone") : present, present); + var child = BarePack(Slot("child hull")); + child.Equipment = new[] { (new int2(0, 0), Slot("child equipment")) }; + var pack = BarePack(Slot("hull"), new EntityPack[] { child }); + pack.Equipment = new[] { (new int2(0, 0), Slot("equipment")) }; + pack.CargoBays = new[] { (new int2(0, 0), Slot("cargo bay")) }; + pack.DockingBays = new[] { (new int2(0, 0), Slot("docking bay")) }; + pack.CargoContents = new[] { new (int2, ItemInstance)[] { (new int2(0, 0), Slot("cargo contents")) } }; + pack.DockingBayContents = new[] { new (int2, ItemInstance)[] { (new int2(0, 0), Slot("docking contents")) } }; + return new[] { ZoneOf(pack) }; + }); + + Assert.Null(Refusal(missing: false)); + Assert.Contains("missing other designs: hull:gone. Reinstall", Refusal(missing: true)); + } + + // Every zone and every lot is read, not the first: each names its own missing design. + [Fact] + public void RequireDesignsReadsEveryZoneAndEveryLot() + { + var refusal = DesignsRefused((present, unit) => new[] + { + ZoneOf(BarePack(unit(Key("hull:zone-a"), present))), + ZoneOf(BarePack(unit(present, present))), + ZoneOf(BarePack(unit(Key("hull:zone-b"), present)), BarePack(unit(present, Key("hull:lot-a")))), + ZoneOf(BarePack(unit(present, present)), BarePack(unit(present, Key("hull:lot-b")))) + }); + + Assert.Contains("missing other designs: hull:lot-a, hull:lot-b, hull:zone-a, hull:zone-b. Reinstall", refusal); + } + + // Each missing design is listed once however many units name it, ordinally, mod ships apart from the rest. + [Fact] + public void RequireDesignsListsEachMissingDesignOnceInStableOrder() + { + var refusal = DesignsRefused((present, unit) => new[] + { + ZoneOf(BarePack(unit(Key("hull:b"), Key("mod-hull:z"))), BarePack(unit(Key("mod-hull:z"), Key("hull:b")))), + ZoneOf(BarePack(unit(Key("hull:B"), Key("mod-hull:m"))), BarePack(unit(Key("hull:a"), Key("hull:b")))) + }); + + Assert.Equal("This run names designs the catalog no longer holds; missing mod ships: m, z; " + + "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() + { + var refusal = DesignsRefused((present, unit) => + { + var pack = BarePack(unit(present, present)); + pack.Equipment = new[] { (new int2(0, 0), (EquippableItem) null) }; + return new[] { ZoneOf(pack) }; + }); + + Assert.Contains("save is malformed", refusal); + } + [Fact] public void ARunNamingAMissingShippedDesignIsRefusedToo() { diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index 9e6ec129f..2d6680933 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -230,6 +230,51 @@ public sealed class ShipAuthoringTests Assert.Contains("records must be stored under", Assert.Throws(() => ShipAuthoringStore.Load(wrongVisualKey)).Message); } + // A mount's anchor plays the mount, so a structural anchor (the hull collider, the shield, a muzzle...) is never one. + // Interim ruling until an `equipment` role exists: a mount that is not a thruster or radiator is an articulation anchor. + [Theory] + [InlineData("map-icon", "map")] + [InlineData("hull-collider", "collider")] + [InlineData("shield", "shield")] + [InlineData("tractor", "tractor")] + [InlineData("weapon-muzzle", "muzzle")] + [InlineData("thruster-emitter", "emitter")] + [InlineData("radiator-mesh", "mesh")] + public void AStructuralAnchorCannotBeAnEquipmentMount(string role, string anchorId) + { + var ship = Fixture(); + // The extra anchors exist so the role-count and parent checks pass; the Tool hardpoint then claims the anchor. + ship.Visual.Anchors.Add(new ShipAnchor { Id = "muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "thruster.port" }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "emitter", Role = "thruster-emitter", ModelNodeId = "emitter" }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "mesh", Role = "radiator-mesh", ModelNodeId = "mesh" }); + ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Tool, Position = new int2(0, 0), Shape = new Shape(), Transform = anchorId }); + + Assert.Contains($"Tool hardpoint {anchorId} needs a articulation anchor of that id", Assert.Throws(ship.Validate).Message); + ship = Fixture(); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "tool", Role = "articulation", ModelNodeId = "tool" }); + ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Tool, Position = new int2(0, 0), Shape = new Shape(), Transform = "tool" }); + ship.Validate(); + } + + [Fact] + public void EveryHardpointTypeHasOneAnchorRoleForItsMount() + { + foreach (HardpointType type in Enum.GetValues(typeof(HardpointType))) + { + var wanted = type == HardpointType.Thruster ? "thruster-emitter" : type == HardpointType.Radiator ? "radiator-mesh" : "articulation"; + foreach (var role in new[] { "articulation", "thruster-emitter", "radiator-mesh" }) + { + var ship = Fixture(); + ship.Hull.Hardpoints.Clear(); + ship.Visual.Anchors.RemoveAll(anchor => anchor.Id == "thruster.port"); + ship.Hull.Hardpoints.Add(new HardpointData { Type = type, Position = new int2(0, 0), Shape = new Shape(), Transform = "mount" }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "mount", Role = role, ModelNodeId = "mount" }); + if (role == wanted) ship.Validate(); + else Assert.Contains($"{type} hardpoint mount needs a {wanted} anchor of that id", Assert.Throws(ship.Validate).Message); + } + } + } + internal static ShipParts Fixture() { var shape = new Shape(2, 2); diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 195af1c98..825f63553 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -37,7 +37,7 @@ public sealed class ShipModCatalogTests : IDisposable Directory.CreateDirectory(Path.Combine(Mods, "notes")); var shippedBefore = Hash(Shipped); - Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods)); + Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods).Included.Length); Assert.Equal(shippedBefore, Hash(Shipped)); using var cache = AetheriaStores.Open(Derived); @@ -114,7 +114,7 @@ public sealed class ShipModCatalogTests : IDisposable var json = JObject.Parse(Encoding.UTF8.GetString(glb, 20, (int)BitConverter.ToUInt32(glb, 12))); Assert.Equal(0, (int)json["scene"]); Assert.Equal(Enumerable.Range(0, ShipFixture.Nodes.Length), json["scenes"][0]["nodes"].Select(node => (int)node)); - Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods)); + Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods).Included.Length); var export = Environment.GetEnvironmentVariable("AETHERIA_SHIP_FIXTURE_DIR"); if (string.IsNullOrEmpty(export)) return; @@ -127,42 +127,81 @@ public sealed class ShipModCatalogTests : IDisposable public void ComposeCountsEveryPackageAndReplacesAnExistingDerivedCatalog() { WritePackage("mod.skiff"); - Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods)); + Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods).Included.Length); WritePackage("mod.barge", hullName: "Barge"); - Assert.Equal(2, ShipModCatalog.Compose(Shipped, Derived, Mods)); + Assert.Equal(2, ShipModCatalog.Compose(Shipped, Derived, Mods).Included.Length); using var cache = AetheriaStores.Open(Derived); Assert.Equal(new[] { "Barge", "Skiff", "Wasp" }, cache.GetAll().Select(hull => hull.Name).OrderBy(name => name)); AssertOnlyTheDerivedFile(); } + // One bad package never stops the game: it is left out and named, and the rest are composed. [Fact] - public void AFailedComposeLeavesThePreviousDerivedFileAndNoTemporaryFile() + public void ABadPackageIsExcludedAndNamedWhileTheGoodOnesCompose() { - WritePackage("mod.skiff"); - ShipModCatalog.Compose(Shipped, Derived, Mods); - var before = File.ReadAllBytes(Derived); + WritePackage("mod.good", hullName: "Good"); + WritePackage("mod.nomodel", hullName: "Nomodel", nodes: FixtureNodes.Where(node => node != "shield").ToArray()); + WritePackage("mod.nofile", hullName: "Nofile"); + File.Delete(Path.Combine(Mods, "mod.nofile", "skiff.glb")); + Directory.CreateDirectory(Path.Combine(Mods, "mod.garbage")); + File.WriteAllText(Path.Combine(Mods, "mod.garbage", "ship.cc"), "not a ship file"); - WritePackage("mod.barge", hullName: "Wasp"); - Assert.Contains("collides with an existing catalog item", Assert.Throws(() => - ShipModCatalog.Compose(Shipped, Derived, Mods)).Message); - Assert.Equal(before, File.ReadAllBytes(Derived)); + var composition = ShipModCatalog.Compose(Shipped, Derived, Mods); + + Assert.Equal(new[] { "mod.good" }, composition.Included); + Assert.Equal(new[] { "mod.garbage", "mod.nofile", "mod.nomodel" }, composition.Excluded.Select(exclusion => exclusion.Package)); + Assert.Contains("model has no node with aetheria.id=shield", composition.Excluded.Single(e => e.Package == "mod.nomodel").Reason); + Assert.Contains("must name an existing GLB", composition.Excluded.Single(e => e.Package == "mod.nofile").Reason); + Assert.Contains("mod.nofile: ", composition.Excluded.Single(e => e.Package == "mod.nofile").ToString()); + using (var cache = AetheriaStores.Open(Derived)) + Assert.Equal(new[] { "Good", "Wasp" }, cache.GetAll().Select(hull => hull.Name).OrderBy(name => name)); AssertOnlyTheDerivedFile(); + } - Directory.Delete(Path.Combine(Mods, "mod.barge"), true); - WritePackage("mod.barge", hullName: "Barge", nodes: FixtureNodes.Where(node => node != "shield").ToArray()); - Assert.Contains("model has no node with aetheria.id=shield", Assert.Throws(() => - ShipModCatalog.Compose(Shipped, Derived, Mods)).Message); - Assert.Equal(before, File.ReadAllBytes(Derived)); + // A name collision leaves out every package that claims the name; the verdict does not depend on directory order. + [Fact] + public void ACollisionExcludesEveryPackageInvolvedAndNoneOfTheRest() + { + WritePackage("mod.a", hullName: "Skiff"); + WritePackage("mod.b", hullName: "Skiff"); + WritePackage("mod.c", hullName: "Skiff"); + WritePackage("mod.d", hullName: "Wasp"); + WritePackage("mod.e", hullName: "Fine"); + + var composition = ShipModCatalog.Compose(Shipped, Derived, Mods); + + Assert.Equal(new[] { "mod.e" }, composition.Included); + Assert.Equal(new[] { "mod.a", "mod.b", "mod.c", "mod.d" }, composition.Excluded.Select(exclusion => exclusion.Package)); + Assert.Equal("hull name 'Skiff' is also claimed by mod.b, mod.c.", composition.Excluded[0].Reason); + Assert.Equal("hull name 'Skiff' is also claimed by mod.a, mod.c.", composition.Excluded[1].Reason); + Assert.Equal("hull name 'Wasp' collides with an existing catalog item.", composition.Excluded[3].Reason); + using var cache = AetheriaStores.Open(Derived); + Assert.Equal(new[] { "Fine", "Wasp" }, cache.GetAll().Select(hull => hull.Name).OrderBy(name => name)); + } + + // A package that fails for two reasons names both. + [Fact] + public void APackageThatCollidesTwiceNamesBothReasons() + { + WritePackage("mod.a", hullName: "Wasp"); + WritePackage("mod.b", hullName: "Wasp"); + + var composition = ShipModCatalog.Compose(Shipped, Derived, Mods); + + Assert.Empty(composition.Included); + Assert.Equal("hull name 'Wasp' collides with an existing catalog item.; hull name 'Wasp' is also claimed by mod.b.", + composition.Excluded[0].Reason); } [Theory] [InlineData("mod-hull:mod.skiff", false)] [InlineData("mod-ship:mod.skiff", true)] - public void ComposeRefusesAModKeyThatAlreadyExistsInTheCatalog(string key, bool authoring) + public void APackageWhoseKeyAlreadyExistsInTheCatalogIsExcluded(string key, bool authoring) { WritePackage("mod.skiff"); + WritePackage("mod.other", hullName: "Other"); using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true)) { if (authoring) @@ -176,15 +215,17 @@ public sealed class ShipModCatalogTests : IDisposable cache.FlushAsync().Wait(); } - Assert.Contains("a mod key collides", Assert.Throws(() => - ShipModCatalog.Compose(Shipped, Derived, Mods)).Message); - Assert.False(File.Exists(Derived)); + var composition = ShipModCatalog.Compose(Shipped, Derived, Mods); + + Assert.Equal(new[] { "mod.other" }, composition.Included); + Assert.Contains("a mod key collides", Assert.Single(composition.Excluded).Reason); + Assert.Equal("mod.skiff", composition.Excluded[0].Package); } [Theory] [InlineData(typeof(HullData))] [InlineData(typeof(GearData))] - public void ComposeRefusesAHullNamedLikeAShippedItem(Type shippedType) + public void APackageWithAHullNamedLikeAShippedItemIsExcluded(Type shippedType) { using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true)) { @@ -196,24 +237,14 @@ public sealed class ShipModCatalogTests : IDisposable } WritePackage("mod.skiff"); - Assert.Contains("hull name 'Skiff' collides with an existing catalog item", Assert.Throws(() => - ShipModCatalog.Compose(Shipped, Derived, Mods)).Message); - Assert.False(File.Exists(Derived)); + var composition = ShipModCatalog.Compose(Shipped, Derived, Mods); + + Assert.Empty(composition.Included); + Assert.Equal("hull name 'Skiff' collides with an existing catalog item.", Assert.Single(composition.Excluded).Reason); } [Fact] - public void ComposeRefusesTwoModsThatShareAHullName() - { - WritePackage("mod.a", hullName: "Skiff"); - WritePackage("mod.b", hullName: "Skiff"); - - Assert.Contains("mod.b: hull name 'Skiff' collides", Assert.Throws(() => - ShipModCatalog.Compose(Shipped, Derived, Mods)).Message); - Assert.False(File.Exists(Derived)); - } - - [Fact] - public void ComposeRefusesAShipIdThatAShippedAuthoringRecordAlreadyUses() + public void APackageWhoseShipIdAShippedAuthoringRecordAlreadyUsesIsExcluded() { using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true)) { @@ -222,12 +253,29 @@ public sealed class ShipModCatalogTests : IDisposable } WritePackage("mod.skiff", hullName: "Different"); - Assert.Contains("ship ID collides with an existing catalog ship authoring record", Assert.Throws(() => - ShipModCatalog.Compose(Shipped, Derived, Mods)).Message); - Assert.False(File.Exists(Derived)); + var composition = ShipModCatalog.Compose(Shipped, Derived, Mods); + + Assert.Empty(composition.Included); + Assert.Equal("ship ID collides with an existing catalog ship authoring record.", Assert.Single(composition.Excluded).Reason); } + // The game's boot: a bad package is named and the rest boot; with none left, the shipped catalog boots. [Fact] + public void TheBootCatalogSurvivesBadPackages() + { + WritePackage("mod.good", hullName: "Good"); + WritePackage("mod.bad", hullName: "Bad", nodes: FixtureNodes.Where(node => node != "shield").ToArray()); + + var (catalog, excluded) = ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods); + Assert.Equal(Derived, catalog); + Assert.Equal("mod.bad", Assert.Single(excluded).Package); + + Directory.Delete(Path.Combine(Mods, "mod.good"), true); + (catalog, excluded) = ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods); + Assert.Equal(Shipped, catalog); + Assert.Equal("mod.bad", Assert.Single(excluded).Package); + } + public void ComposeRefusesUnsafeInputsAndOutputs() { WritePackage("mod.skiff"); @@ -366,21 +414,21 @@ public sealed class ShipModCatalogTests : IDisposable [Fact] public void TheBootCatalogIsTheShippedOneUntilAModIsInstalled() { - Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Path.Combine(_directory.Path, "absent"))); + Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Path.Combine(_directory.Path, "absent")).Catalog); Directory.CreateDirectory(Path.Combine(Mods, "notes")); - Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods)); + Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods).Catalog); Assert.False(File.Exists(Derived)); WritePackage("mod.skiff"); - Assert.Equal(Derived, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods)); + Assert.Equal(Derived, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods).Catalog); using (var cache = AetheriaStores.Open(Derived)) Assert.Contains("Skiff", cache.GetAll().Select(hull => hull.Name)); // Recomposed on every call: a mod removed since the last boot is gone from the file the game opens next. Directory.Delete(Path.Combine(Mods, "mod.skiff"), true); - Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods)); + Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods).Catalog); WritePackage("mod.barge", hullName: "Barge"); - Assert.Equal(Derived, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods)); + Assert.Equal(Derived, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods).Catalog); using (var cache = AetheriaStores.Open(Derived)) Assert.Equal(new[] { "Barge", "Wasp" }, cache.GetAll().Select(hull => hull.Name).OrderBy(name => name)); } diff --git a/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs b/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs index b062b8de1..c98ff3a10 100644 --- a/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs @@ -59,13 +59,26 @@ public sealed class ShipModPlanTests // A thruster-emitter anchor on a weapon mount, and a radiator-mesh anchor that is no mount at all. var ship = Rig(); ship.Visual.Anchors.Single(anchor => anchor.Id == "gun").Role = "thruster-emitter"; - Assert.Contains("thruster-emitter anchor gun must be a Thruster hardpoint's mount", Refusal(ship)); + Assert.Contains("Energy hardpoint gun needs a articulation anchor of that id", Refusal(ship)); ship = Rig(); ship.Visual.Anchors.Add(new ShipAnchor { Id = "loose", Role = "radiator-mesh", ModelNodeId = "loose" }); Assert.Contains("radiator-mesh anchor loose must be a Radiator hardpoint's mount", Refusal(ship)); } + // Equal Order falls back to the id, compared ordinally, whatever order the anchors were authored in. + [Fact] + public void MuzzlesOfEqualOrderAreTiedByIdOrdinally() + { + var ship = Rig(); + ship.Visual.Anchors.RemoveAll(anchor => anchor.Role == "weapon-muzzle"); + foreach (var id in new[] { "gun.c", "gun.a", "gun.B", "gun.b" }) + ship.Visual.Anchors.Add(new ShipAnchor { Id = id, Role = "weapon-muzzle", ModelNodeId = id, ParentId = "gun", Order = 2 }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.z", Role = "weapon-muzzle", ModelNodeId = "gun.z", ParentId = "gun", Order = 1 }); + + Assert.Equal(new[] { "gun.z", "gun.B", "gun.a", "gun.b", "gun.c" }, Assert.Single(Plan(ship).Weapons).Muzzles); + } + [Fact] public void MuzzlesBelongToWeaponMountsAndEveryWeaponHasOne() { diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index e6b380ed3..52a8f75fd 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -18,9 +18,10 @@ public static class ShipAuthoringCommands { if (args[0] == "compose" && args.Length == 4) { - var count = ShipModCatalog.Compose(args[1], args[2], args[3]); - Console.WriteLine($"Composed {count} mod ships into {Path.GetFullPath(args[2])}"); - return 0; + var composition = ShipModCatalog.Compose(args[1], args[2], args[3]); + Console.WriteLine($"Composed {composition.Included.Length} mod ships into {Path.GetFullPath(args[2])}"); + foreach (var exclusion in composition.Excluded) Console.Error.WriteLine($"Excluded {exclusion}"); + return composition.Excluded.Length == 0 ? 0 : 1; } if (args[0] == "inspect" && args.Length == 2) {