diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 36ed44af2..eebc22126 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -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 "" [replace] writes the piloted ship as a catalog preset. + // Editor only: capturepreset "" [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 \"\" [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 \"\" [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) { diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index ec3ea8f4e..8403d4854 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -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().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(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 isAvailable, List failures) { diff --git a/Assets/Scripts/UI/ConsoleController.cs b/Assets/Scripts/UI/ConsoleController.cs index 640b3d6f7..d7185d63e 100644 --- a/Assets/Scripts/UI/ConsoleController.cs +++ b/Assets/Scripts/UI/ConsoleController.cs @@ -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; } } diff --git a/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs b/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs index 900ee3a21..12a961e69 100644 --- a/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs +++ b/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs @@ -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()).Key))); + + var error = Assert.Throws(() => 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) diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index a0115f680..608ba4431 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -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(() => Loadouts.Commit(cache, Armed(cache, "Skiff build"), replace: false)); + var error = Assert.Throws(() => 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()).WeaponGroups.Length); - Assert.True(Loadouts.Commit(cache, Armed(cache, "Skiff build"), replace: true)); - Assert.Equal(6, Assert.Single(cache.GetAll()).WeaponGroups.Length); + Assert.True(Loadouts.Commit(Catalog, Armed(cache, "Skiff build"), replace: true)); } using (var cache = Open()) Assert.Equal(6, Assert.Single(cache.GetAll()).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("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("Maker"); + Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false)); + Assert.Empty(cache.GetAll()); + Assert.Same(maker, cache.GetByName("Maker")); + Assert.False(cache.IsDirty); + Assert.Throws(() => cache.Upsert(new Faction { Name = "Usurper" })); + } + + using (var cache = Open()) + Assert.Equal("Skiff build", Assert.Single(cache.GetAll()).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(() => 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())).Key; + Assert.NotEqual(Loadouts.KeyOf("Skiff build"), studioKey); + foreach (var replace in new[] { false, true }) + { + var error = Assert.Throws(() => 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);