From 88a36fff4e2a87ad124642ea6878ae7645764c5a Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 05:25:00 +0200 Subject: [PATCH 01/20] Economy null guards: a recipe with a null list is an L-recipe fault, not a throw (red) Co-Authored-By: Claude Sonnet 5.5 --- .../EconomyNullGuardTests.cs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/EconomyNullGuardTests.cs diff --git a/tests/Aetheria.Shared.Tests/EconomyNullGuardTests.cs b/tests/Aetheria.Shared.Tests/EconomyNullGuardTests.cs new file mode 100644 index 000000000..61b0cea12 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/EconomyNullGuardTests.cs @@ -0,0 +1,53 @@ +using System; +using System.Linq; +using Xunit; + +// A recipe read from disk can hold null lists. The checks refuse it as an L-recipe fault naming the record; they never throw +// (finding null-fills-throws). +public sealed class EconomyNullGuardTests +{ + private static void Null(EconomyFixture f, string list) => + f.EditRecipe("commodity:plate", r => + { + if (list == "Fills") r.Fills = null; + if (list == "Inputs") r.Inputs = null; + if (list == "Upkeep") r.Upkeep = null; + }); + + [Theory] + [InlineData("Fills")] + [InlineData("Inputs")] + [InlineData("Upkeep")] + public void ANullListIsAnLRecipeFaultNotAThrow(string list) + { + using var f = EconomyFixture.Healthy(); + Null(f, list); + var report = EconomyChecks.Check(f.Cache); + Assert.Contains(report.Faults, x => x.Invariant == EconomyChecks.LRecipe && x.Record == "recipe:commodity:plate" && x.Message.Contains(list)); + } + + [Theory] + [InlineData("Fills")] + [InlineData("Inputs")] + [InlineData("Upkeep")] + public void EveryRuleSurvivesANullList(string list) + { + using var f = EconomyFixture.Healthy(); + Null(f, list); + EconomyChecks.Recipe(f.Cache); + EconomyChecks.Lore(f.Cache); + EconomyChecks.SourceSink(f.Cache); + EconomyChecks.Productive(f.Cache); + EconomyChecks.ConsumerDemand(f.Cache); + Assert.NotEmpty(EconomyChecks.Deep(f.Cache)); + } + + [Fact] + public void ANullEntryInAListIsAnLRecipeFault() + { + using var f = EconomyFixture.Healthy(); + f.EditRecipe("commodity:plate", r => r.Inputs.Add(null)); + var report = EconomyChecks.Check(f.Cache); + Assert.Contains(report.Faults, x => x.Invariant == EconomyChecks.LRecipe && x.Record == "recipe:commodity:plate" && x.Message.Contains("Inputs")); + } +} From dccac5047d2ab097babb828fbba7ddab0bedb89b Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 05:25:24 +0200 Subject: [PATCH 02/20] EconomyChecks: a recipe with a null list or entry is an L-recipe fault; every other rule reads it as empty Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/EconomyChecks.cs | 26 ++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/EconomyChecks.cs b/Assets/Scripts/ServerShared/EconomyChecks.cs index 13f7f5d78..af2c1eb5c 100644 --- a/Assets/Scripts/ServerShared/EconomyChecks.cs +++ b/Assets/Scripts/ServerShared/EconomyChecks.cs @@ -59,9 +59,26 @@ public static class EconomyChecks return new EconomyReport(faults, Deep(catalog)); } - private static IEnumerable<(string Key, RecipeData Recipe)> AllRecipes(CultCache catalog) => + private static IEnumerable<(string Key, RecipeData Recipe)> StoredRecipes(CultCache catalog) => catalog.GetAll().Select(recipe => (catalog.RefOf(recipe).Key.Value, recipe)).OrderBy(pair => pair.Item1, StringComparer.Ordinal); + // Every rule but Recipe reads recipes through here, so a list a record holds as null (or with a null entry) reads as the list + // without it; Recipe alone names the malformation. The stored document is never changed. + private static IEnumerable<(string Key, RecipeData Recipe)> AllRecipes(CultCache catalog) => + StoredRecipes(catalog).Select(pair => (pair.Key, Sound(pair.Recipe))); + + private static RecipeData Sound(RecipeData recipe) => + recipe.Inputs != null && recipe.Upkeep != null && recipe.Fills != null && !recipe.Inputs.Contains(null) && !recipe.Upkeep.Contains(null) + ? recipe + : new RecipeData + { + Output = recipe.Output, OutputCount = recipe.OutputCount, Cycle = recipe.Cycle, Workforce = recipe.Workforce, + StationKind = recipe.StationKind, LoreHook = recipe.LoreHook, + Inputs = (recipe.Inputs ?? new List()).Where(c => c != null).ToList(), + Upkeep = (recipe.Upkeep ?? new List()).Where(c => c != null).ToList(), + Fills = recipe.Fills ?? new List() + }; + private static IEnumerable Commodities(CultCache catalog) => catalog.GetAll().Where(Recipes.IsCommodity).OrderBy(item => catalog.RefOf(item).Key.Value, StringComparer.Ordinal); @@ -83,8 +100,13 @@ public static class EconomyChecks Add(key, $"Offered design '{design.Name}' has no recipe."); } var kindsPlaced = new HashSet(catalog.GetAll().SelectMany(f => StationKinds.KindsOf(catalog, f)).Select(k => k.Key.Value), StringComparer.Ordinal); - foreach (var (key, recipe) in AllRecipes(catalog)) + foreach (var (key, stored) in StoredRecipes(catalog)) { + var recipe = Sound(stored); + foreach (var (list, name) in new[] { (stored.Inputs, "Inputs"), (stored.Upkeep, "Upkeep") }) + if (list == null) Add(key, $"{name} is null; a recipe holds a list, empty when it takes nothing."); + else if (list.Contains(null)) Add(key, $"{name} holds a null entry."); + if (stored.Fills == null) Add(key, "Fills is null; a recipe holds a list, empty when it fills no role."); var output = catalog.Get(recipe.Output); if (output == null) { Add(key, "Output names no item this catalog holds."); continue; } if (!catalog.RefOf(recipe).Key.Equals(RecipeData.KeyOf(recipe.Output.Key))) From 94a14b8dec8f8e9ced96544a37edb07cd04275ad Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 05:26:47 +0200 Subject: [PATCH 03/20] Economy command: contract and tests, skeleton throws (red) Co-Authored-By: Claude Sonnet 5.5 --- .../EconomyCommandTests.cs | 215 ++++++++++++++++++ tools/AetherDb/EconomyCommand.cs | 24 ++ 2 files changed, 239 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/EconomyCommandTests.cs create mode 100644 tools/AetherDb/EconomyCommand.cs diff --git a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs new file mode 100644 index 000000000..916a4804a --- /dev/null +++ b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameCult.Caching; +using Xunit; + +public sealed class EconomyCommandTests +{ + private sealed class Lambda : EconomyBatch + { + private readonly Action _author; + public Lambda(Action author) => _author = author; + public override void Author(EconomyAuthor author) => _author(author); + } + + private static void Flush(EconomyFixture f) => f.Cache.FlushAsync().GetAwaiter().GetResult(); + + private static CultCache Read(EconomyFixture f) => AetheriaStores.Open(f.CatalogPath, registry: TestCatalog.Registry()); + + private static int Author(EconomyFixture f, bool apply, out string output, params Action[] batches) + { + Flush(f); + var writer = new StringWriter(); + var code = EconomyCommand.Author(f.CatalogPath, batches.Select(b => (EconomyBatch) new Lambda(b)).ToList(), apply, writer, TestCatalog.Registry()); + output = writer.ToString(); + return code; + } + + // A commodity and its recipe, run by the fixture's mill unless a kind is given. + private static void Good(EconomyFixture f, EconomyAuthor a, string id, string lore = "a good", string[] fills = null, (string Id, int Count)[] inputs = null, + CultRecordRef? kind = null) + { + var key = new CultRecordKey(EconomyFixture.Key(id)); + if (inputs == null) a.Put(new SimpleCommodityData { Name = id }, key); + else a.Put(new CompoundCommodityData { Name = id }, key); + a.Put(new RecipeData + { + Output = EconomyFixture.Item(key.Value), StationKind = kind ?? f.Mill, LoreHook = lore, + Inputs = (inputs ?? Array.Empty<(string, int)>()).Select(i => new CommodityCount { Commodity = EconomyFixture.Item(EconomyFixture.Key(i.Id)), Count = i.Count }).ToList(), + Fills = (fills ?? Array.Empty()).ToList() + }, RecipeData.KeyOf(key)); + } + + [Fact] + public void ABatchWithAShapeFaultIsRefusedAndTheCatalogStaysByteIdentical() + { + using var f = EconomyFixture.Healthy(); + Flush(f); + var bytes = File.ReadAllBytes(f.CatalogPath); + var code = Author(f, true, out var output, a => Good(f, a, "tar", lore: " ")); + Assert.Equal(1, code); + Assert.Contains("L-lore", output); + Assert.Contains("refused", output); + Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); + } + + [Fact] + public void TheGateRefusesALoopThatCannotSustainItself() + { + using var f = EconomyFixture.Healthy(); + Flush(f); + var bytes = File.ReadAllBytes(f.CatalogPath); + var code = Author(f, true, out var output, a => + { + Good(f, a, "loopa", inputs: new[] { ("loopb", 1) }, fills: new[] { "Plate" }); + Good(f, a, "loopb", inputs: new[] { ("loopa", 1) }); + }); + Assert.Equal(1, code); + Assert.Contains("L-productive", output); + Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); + } + + [Fact] + public void ADryRunWritesNothingAndPrintsWhatItWouldDo() + { + using var f = EconomyFixture.Healthy(); + Flush(f); + var bytes = File.ReadAllBytes(f.CatalogPath); + var code = Author(f, false, out var output, a => Good(f, a, "coal"), a => a.Edit("ore", ore => ore.Description = "grey")); + Assert.Equal(0, code); + Assert.Contains("added commodity:coal", output); + Assert.Contains("added recipe:commodity:coal", output); + Assert.Contains("changed commodity:ore", output); + Assert.Contains("Dry run", output); + Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); + } + + [Fact] + public void ACleanBatchAppliesAndTheCatalogThenReportsNoFault() + { + using var f = EconomyFixture.Healthy(); + var code = Author(f, true, out var output, a => + { + Good(f, a, "coal"); + Good(f, a, "steel", inputs: new[] { ("coal", 1), ("plate", 1) }, fills: new[] { "Plate" }); + }); + Assert.Equal(0, code); + Assert.Contains("0 faults", output); + using var after = Read(f); + Assert.NotNull(after.Get(RecipeData.KeyOf(new CultRecordKey("commodity:steel")))); + Assert.Empty(EconomyChecks.Check(after).Faults); + } + + [Fact] + public void ApplyConvergesAndTouchesOnlyWhatItNames() + { + using var f = EconomyFixture.Healthy(); + Action coal = a => Good(f, a, "coal", fills: new[] { "Plate" }); + Assert.Equal(0, Author(f, true, out _, coal)); + var bytes = File.ReadAllBytes(f.CatalogPath); + Assert.Equal(0, Author(f, true, out var again, coal)); + Assert.Contains("0 added, 0 changed, 2 unchanged", again); + Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); + + Assert.Equal(0, Author(f, true, out var revised, a => Good(f, a, "coal", lore: "black rock", fills: new[] { "Plate" }))); + Assert.Contains("0 added, 1 changed, 1 unchanged", revised); + using var after = Read(f); + Assert.Equal("black rock", after.Get(RecipeData.KeyOf(new CultRecordKey("commodity:coal"))).LoreHook); + Assert.Equal("a good", after.Get(RecipeData.KeyOf(new CultRecordKey("commodity:ore"))).LoreHook); + } + + [Fact] + public void ACompletenessFaultIsReportedAndDoesNotBlockApply() + { + using var f = EconomyFixture.Healthy(); + var code = Author(f, true, out var output, a => Good(f, a, "dust")); + Assert.Equal(0, code); + Assert.Contains("L-source-sink", output); + using var after = Read(f); + Assert.NotNull(after.Get(RecipeData.KeyOf(new CultRecordKey("commodity:dust")))); + } + + [Fact] + public void ACommodityWrittenWithoutARecipeIsCompletenessNotARefusal() + { + using var f = EconomyFixture.Healthy(); + var code = Author(f, true, out var output, a => a.Put(new SimpleCommodityData { Name = "bare" }, new CultRecordKey("commodity:bare"))); + Assert.Equal(0, code); + Assert.Contains("L-recipe", output); + } + + [Fact] + public void AuthorsAKindWithStandIns() + { + using var f = EconomyFixture.Healthy(); + var forge = StationKindData.KeyOf("forge"); + var code = Author(f, true, out _, a => + { + a.Put(new StationKindData { Name = "Forge", StandIns = new List { new StationKindStandIn { Faction = f.Maker, Kind = f.Mill } } }, forge); + Good(f, a, "slab", kind: new CultRecordRef(forge), fills: new[] { "Plate" }); + }); + Assert.Equal(0, code); + using var after = Read(f); + Assert.Single(after.Get(forge).StandIns); + Assert.DoesNotContain(EconomyChecks.Check(after).Faults, x => x.Invariant == EconomyChecks.LOneKindList); + } + + [Fact] + public void AKindThatStandsInForItselfIsRefusedAndNothingIsWritten() + { + using var f = EconomyFixture.Healthy(); + Flush(f); + var bytes = File.ReadAllBytes(f.CatalogPath); + var forge = StationKindData.KeyOf("forge"); + var code = Author(f, true, out var output, a => a.Put(new StationKindData + { + Name = "Forge", StandIns = new List { new StationKindStandIn { Faction = f.Maker, Kind = new CultRecordRef(forge) } } + }, forge)); + Assert.Equal(1, code); + Assert.Contains("refused", output); + Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); + } + + [Fact] + public void AnEditNamingNoItemIsARefusalNotAThrow() + { + using var f = EconomyFixture.Healthy(); + Flush(f); + var bytes = File.ReadAllBytes(f.CatalogPath); + var code = Author(f, true, out var output, a => a.Edit("unobtainium", i => i.Price = 3)); + Assert.Equal(1, code); + Assert.Contains("unobtainium", output); + Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); + } + + [Fact] + public void AnEditChangesOnlyTheNamedItem() + { + using var f = EconomyFixture.Healthy(); + var code = Author(f, true, out _, a => a.Edit("ore", ore => { ore.Description = "grey"; ore.Price = 7; ore.Mass = 2; })); + Assert.Equal(0, code); + using var after = Read(f); + var ore = after.Get(new CultRecordKey("commodity:ore")); + Assert.Equal(("grey", 7, 2f), (ore.Description, ore.Price, ore.Mass)); + Assert.Null(after.Get(new CultRecordKey("commodity:plate")).Description); + } + + [Fact] + public void ReportPrintsFaultsAndDepthAndFailsOnAnyFault() + { + using var f = EconomyFixture.Healthy(); + var healthy = new StringWriter(); + Assert.Equal(0, EconomyCommand.Report(f.Cache, healthy)); + Assert.Contains("commodity:plate", healthy.ToString()); + Assert.Contains("depth 1", healthy.ToString()); + f.EditRecipe("commodity:plate", r => r.LoreHook = ""); + var broken = new StringWriter(); + Assert.Equal(1, EconomyCommand.Report(f.Cache, broken)); + Assert.Contains("L-lore", broken.ToString()); + } + + [Fact] + public void AnUnknownSubcommandFails() => Assert.Equal(1, EconomyCommand.Run(new[] { "bogus" })); +} diff --git a/tools/AetherDb/EconomyCommand.cs b/tools/AetherDb/EconomyCommand.cs new file mode 100644 index 000000000..ae62d1416 --- /dev/null +++ b/tools/AetherDb/EconomyCommand.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GameCult.Caching; + +// One batch of economy content: commodities, recipes, station kinds with stand-ins, outside-market rows, edits to existing items. +public abstract class EconomyBatch +{ + public abstract void Author(EconomyAuthor author); +} + +public sealed class EconomyAuthor +{ + public void Put(T document, CultRecordKey key) where T : class => throw new NotImplementedException(); + public void Edit(string name, Action edit) where T : ItemData => throw new NotImplementedException(); +} + +public static class EconomyCommand +{ + public static int Run(string[] args) => throw new NotImplementedException(); + public static int Report(CultCache catalog, TextWriter output) => throw new NotImplementedException(); + public static int Author(string catalogPath, IReadOnlyList batches, bool apply, TextWriter output, CultDocumentRegistry registry = null) => + throw new NotImplementedException(); +} From b4510d573760129c5045630b7a2f8436023db407 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 05:27:50 +0200 Subject: [PATCH 04/20] EconomyChecks: the key check reads the record key, not the cache's reference to a sound copy Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/EconomyChecks.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/EconomyChecks.cs b/Assets/Scripts/ServerShared/EconomyChecks.cs index af2c1eb5c..eb9564845 100644 --- a/Assets/Scripts/ServerShared/EconomyChecks.cs +++ b/Assets/Scripts/ServerShared/EconomyChecks.cs @@ -109,7 +109,7 @@ public static class EconomyChecks if (stored.Fills == null) Add(key, "Fills is null; a recipe holds a list, empty when it fills no role."); var output = catalog.Get(recipe.Output); if (output == null) { Add(key, "Output names no item this catalog holds."); continue; } - if (!catalog.RefOf(recipe).Key.Equals(RecipeData.KeyOf(recipe.Output.Key))) + if (key != RecipeData.KeyOf(recipe.Output.Key).Value) Add(key, "The recipe's key is not recipe:; one recipe per output is its key."); if (recipe.OutputCount <= 0) Add(key, "OutputCount is not above zero."); if (!float.IsFinite(recipe.Cycle) || recipe.Cycle <= 0) Add(key, "Cycle is not a finite number above zero."); From 7367f21c8a921880c3b3cf06b44fc353311a9b87 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 05:28:42 +0200 Subject: [PATCH 05/20] AetherDb economy: report and author, dry-run gated by the catalog checks Co-Authored-By: Claude Sonnet 5.5 --- .../EconomyCommandTests.cs | 2 +- tools/AetherDb/EconomyCommand.cs | 194 +++++++++++++++++- tools/AetherDb/Program.cs | 3 +- 3 files changed, 190 insertions(+), 9 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs index 916a4804a..9629555fd 100644 --- a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs @@ -93,7 +93,7 @@ public sealed class EconomyCommandTests var code = Author(f, true, out var output, a => { Good(f, a, "coal"); - Good(f, a, "steel", inputs: new[] { ("coal", 1), ("plate", 1) }, fills: new[] { "Plate" }); + Good(f, a, "steel", inputs: new[] { ("coal", 2) }, fills: new[] { "Plate" }); }); Assert.Equal(0, code); Assert.Contains("0 faults", output); diff --git a/tools/AetherDb/EconomyCommand.cs b/tools/AetherDb/EconomyCommand.cs index ae62d1416..0005bec98 100644 --- a/tools/AetherDb/EconomyCommand.cs +++ b/tools/AetherDb/EconomyCommand.cs @@ -1,24 +1,204 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; -// One batch of economy content: commodities, recipes, station kinds with stand-ins, outside-market rows, edits to existing items. +// One batch of economy content: commodities, recipes, station kinds with stand-ins, outside-market rows and edits to existing +// items, written through EconomyAuthor. A batch is a class under tools/AetherDb/Economy/, named on the command line by its class +// name. It writes what it names and nothing else, so a revised batch converges and a rerun writes nothing. public abstract class EconomyBatch { public abstract void Author(EconomyAuthor author); } +// What a batch writes through. Every write is compared with the stored record and counted as added, changed or unchanged; an +// unchanged record is not written. public sealed class EconomyAuthor { - public void Put(T document, CultRecordKey key) where T : class => throw new NotImplementedException(); - public void Edit(string name, Action edit) where T : ItemData => throw new NotImplementedException(); + private static readonly MessagePackSerializerOptions Wire = CultDocumentMessagePackSerialization.OptionsFor(typeof(ItemData).Assembly); + private readonly CultCache _catalog; + private readonly List<(string Status, string Key)> _results = new List<(string, string)>(); + + public EconomyAuthor(CultCache catalog) => _catalog = catalog; + + public IReadOnlyList<(string Status, string Key)> Results => _results; + + // The keys of the records this author added or changed. + public IEnumerable Written => _results.Where(r => r.Status != "unchanged").Select(r => r.Key); + + // The catalog as the batch has left it so far, for a batch that builds on its own earlier writes. + public CultCache Catalog => _catalog; + + // Writes a record under a key: a new commodity, a recipe, a station kind, an outside market. + public void Put(T document, CultRecordKey key) where T : class + { + var held = _catalog.Get(key); + Record(key, held == null ? null : Bytes(typeof(T), held), typeof(T), document); + } + + // Edits the one item of that name and type. A name that matches no item or several is an error the command reports. + public void Edit(string name, Action edit) where T : ItemData + { + var named = _catalog.GetAll().Where(item => item.Name == name).ToArray(); + if (named.Length != 1) + throw new InvalidOperationException(named.Length == 0 ? $"The catalog holds no {typeof(T).Name} named '{name}'." : $"{named.Length} {typeof(T).Name} records are named '{name}'."); + var key = _catalog.RefOf(named[0]).Key; + var before = Bytes(named[0].GetType(), named[0]); + edit(named[0]); + Record(key, before, named[0].GetType(), named[0]); + } + + private void Record(CultRecordKey key, byte[] held, Type type, object document) + { + var status = held == null ? "added" : held.AsSpan().SequenceEqual(Bytes(type, document)) ? "unchanged" : "changed"; + _results.Add((status, key.Value)); + if (status != "unchanged") _catalog.UpsertAsync(type, document, key).GetAwaiter().GetResult(); + } + + private static byte[] Bytes(Type type, object document) => MessagePackSerializer.Serialize(type, document, Wire); } +// economy report | author ... [apply]: the one writer of economy content and a reader of the catalog's economy report. +// EconomyChecks owns every rule; this command only calls it. public static class EconomyCommand { - public static int Run(string[] args) => throw new NotImplementedException(); - public static int Report(CultCache catalog, TextWriter output) => throw new NotImplementedException(); - public static int Author(string catalogPath, IReadOnlyList batches, bool apply, TextWriter output, CultDocumentRegistry registry = null) => - throw new NotImplementedException(); + // The invariants whose faults on a record a batch wrote refuse it. The rest (a missing sink, no consumer demand, a commodity + // with no recipe yet) are completeness: reported, and settled by the batch that closes the chain. + private static readonly string[] Shape = + { EconomyChecks.LRecipe, EconomyChecks.LLore, EconomyChecks.LProductive, EconomyChecks.LOneKindList }; + + private const string Usage = "economy report | author ... [apply]"; + + public static int Run(string[] args) + { + if (args.Length > 0 && args[0] == "report") + { + using var db = AetherDb.Open(); + return Report(db.Cache, Console.Out); + } + if (args.Length > 1 && args[0] == "author") + { + var apply = args.Contains("apply"); + var names = args.Skip(1).Where(a => a != "apply").ToArray(); + var known = typeof(EconomyBatch).Assembly.GetTypes().Where(t => !t.IsAbstract && t.IsSubclassOf(typeof(EconomyBatch))) + .OrderBy(t => t.Name, StringComparer.Ordinal).ToArray(); + var unknown = names.Where(n => known.All(t => !string.Equals(t.Name, n, StringComparison.OrdinalIgnoreCase))).ToArray(); + if (unknown.Length > 0 || names.Length == 0) + { + Console.WriteLine($"No batch named {string.Join(", ", unknown)}. Batches: {string.Join(", ", known.Select(t => t.Name))}"); + return 1; + } + var batches = names.Select(n => (EconomyBatch) Activator.CreateInstance(known.Single(t => string.Equals(t.Name, n, StringComparison.OrdinalIgnoreCase)))).ToList(); + return Author(Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), batches, apply, Console.Out); + } + Console.WriteLine(Usage); + return 1; + } + + // Faults grouped by invariant, then one line per good with its depth, then one line per recipe. Fails on any fault. + public static int Report(CultCache catalog, TextWriter output) + { + var report = EconomyChecks.Check(catalog); + PrintFaults(report, output); + PrintDepth(report, output); + output.WriteLine(); + foreach (var recipe in catalog.GetAll().OrderBy(r => catalog.RefOf(r).Key.Value, StringComparer.Ordinal)) + { + var made = catalog.Get(recipe.Output); + var takes = Recipes.IsRoleRecipe(catalog, recipe) + ? string.Join(", ", Recipes.RolesOf(made).Select(r => $"{r.Count} x {r.Name}")) + : string.Join(", ", (recipe.Inputs ?? new List()).Where(c => c != null).Select(c => $"{c.Count} x {c.Commodity.Key.Value}")); + output.WriteLine($"{recipe.Output.Key.Value} <- {(takes.Length == 0 ? "nothing" : takes)} | {catalog.Get(recipe.StationKind)?.Name ?? "no kind"}, cycle {recipe.Cycle}, workforce {recipe.Workforce} | {recipe.LoreHook}"); + } + return report.Faults.Count == 0 ? 0 : 1; + } + + // Runs the batches on a copy of the catalog, prints what each record would become and the check report over the result, and + // refuses when a shape rule faults a recipe a batch wrote. Only a passing apply opens the real file for writing, so a + // refusal leaves it byte-identical. + public static int Author(string catalogPath, IReadOnlyList batches, bool apply, TextWriter output, CultDocumentRegistry registry = null) + { + var scratch = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "aetherdb-economy-" + Guid.NewGuid().ToString("N"))).FullName; + try + { + var trial = Path.Combine(scratch, "Aetheria.cc"); + File.Copy(catalogPath, trial); + var results = new List<(string Status, string Key)>(); + EconomyReport report; + using (var cache = AetheriaStores.Open(trial, catalogWritable: true, registry: registry, kindlessStation: warning => output.WriteLine("warning: " + warning))) + { + var author = new EconomyAuthor(cache); + try + { + foreach (var batch in batches) batch.Author(author); + StationKinds.Validate(cache); + } + catch (Exception e) + { + output.WriteLine($"The batch is refused: {e.Message}"); + return 1; + } + results.AddRange(author.Results); + foreach (var (status, key) in results) output.WriteLine($"{status} {key}"); + output.WriteLine($"{results.Count(r => r.Status == "added")} added, {results.Count(r => r.Status == "changed")} changed, {results.Count(r => r.Status == "unchanged")} unchanged"); + report = EconomyChecks.Check(cache); + PrintFaults(report, output); + PrintDepth(report, output); + var written = new HashSet(author.Written, StringComparer.Ordinal); + var refused = report.Faults.Where(fault => Shape.Contains(fault.Invariant) && written.Contains(RecipeKeyOf(fault.Record))).ToArray(); + if (refused.Length > 0) + { + output.WriteLine($"\nThe batch is refused: {refused.Length} shape faults on recipes it wrote."); + foreach (var fault in refused) output.WriteLine($" {fault}"); + return 1; + } + } + if (!apply) + { + output.WriteLine("\nDry run. Pass \"apply\" to write Aetheria.cc."); + return 0; + } + if (results.All(r => r.Status == "unchanged")) + { + output.WriteLine("\nNothing to write."); + return 0; + } + using (var cache = AetheriaStores.Open(catalogPath, catalogWritable: true, registry: registry)) + { + var author = new EconomyAuthor(cache); + foreach (var batch in batches) batch.Author(author); + cache.FlushAsync().GetAwaiter().GetResult(); + } + output.WriteLine($"\nWrote {results.Count(r => r.Status != "unchanged")} records to Aetheria.cc"); + return 0; + } + finally + { + try { Directory.Delete(scratch, true); } catch (IOException) { } + } + } + + // A fault names a recipe key, or the key of the good a recipe makes. + private static string RecipeKeyOf(string record) => + record.StartsWith("recipe:", StringComparison.Ordinal) ? record : RecipeData.KeyOf(new CultRecordKey(record)).Value; + + private static void PrintFaults(EconomyReport report, TextWriter output) + { + output.WriteLine($"{report.Faults.Count} faults"); + foreach (var group in report.Faults.GroupBy(f => f.Invariant).OrderBy(g => g.Key, StringComparer.Ordinal)) + { + output.WriteLine($"{group.Key} ({group.Count()})"); + foreach (var fault in group) output.WriteLine($" {fault.Record}: {fault.Message}"); + } + } + + private static void PrintDepth(EconomyReport report, TextWriter output) + { + output.WriteLine("\nDepth"); + foreach (var pair in report.Depth.OrderBy(p => p.Key, StringComparer.Ordinal)) + output.WriteLine($" {pair.Key} depth {(pair.Value.HasValue ? pair.Value.Value.ToString() : "none")}"); + } } diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 572d6f06f..1dd65bda1 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -76,9 +76,10 @@ public static class Program if (apply) db.Cache.FlushAsync().GetAwaiter().GetResult(); return code; } + case "economy": return EconomyCommand.Run(args.Skip(1).ToArray()); case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], loadout-commit [,,,[@]]... [--group ,...]... [--replace] [--boss-of ], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply], station-kinds [hull \"\" ] [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], loadout-commit [,,,[@]]... [--group ,...]... [--replace] [--boss-of ], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply], station-kinds [hull \"\" ] [apply], economy report|author ... [apply]"); return 1; } } From b31595add2c262054898088b3e6a14a6ec3c79a2 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 05:36:28 +0200 Subject: [PATCH 06/20] Economy command tests: an edit naming two items is refused (kills the ambiguity mutant) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/EconomyCommandTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs index 9629555fd..98d748002 100644 --- a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs @@ -184,6 +184,20 @@ public sealed class EconomyCommandTests Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); } + [Fact] + public void AnEditNamingTwoItemsIsARefusalAndNothingIsWritten() + { + using var f = EconomyFixture.Healthy(); + f.Cache.Upsert(typeof(SimpleCommodityData), new SimpleCommodityData { Name = "twin" }, new CultRecordKey("commodity:twin-a")); + f.Cache.Upsert(typeof(SimpleCommodityData), new SimpleCommodityData { Name = "twin" }, new CultRecordKey("commodity:twin-b")); + Flush(f); + var bytes = File.ReadAllBytes(f.CatalogPath); + var code = Author(f, true, out var output, a => a.Edit("twin", i => i.Price = 3)); + Assert.Equal(1, code); + Assert.Contains("2 SimpleCommodityData records are named 'twin'", output); + Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); + } + [Fact] public void AnEditChangesOnlyTheNamedItem() { From 0394e33ed80c423b0d913cf0d3081264c4da386e Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 05:40:51 +0200 Subject: [PATCH 07/20] Economy command tests: pin the printed report, the written set and the trial cleanup (Stryker survivors) Co-Authored-By: Claude Sonnet 5.5 --- .../EconomyCommandTests.cs | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs index 98d748002..0cf492bc6 100644 --- a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs @@ -52,6 +52,11 @@ public sealed class EconomyCommandTests Assert.Equal(1, code); Assert.Contains("L-lore", output); Assert.Contains("refused", output); + Assert.Contains(" L-lore recipe:commodity:tar: ", output); + Assert.Contains(" recipe:commodity:tar: A commodity's recipe needs a lore hook.", output); + Assert.Contains("1 shape faults", output); + Assert.Contains("commodity:ore depth 0", output); + Assert.True(output.IndexOf("commodity:ore depth", StringComparison.Ordinal) < output.IndexOf("commodity:plate depth", StringComparison.Ordinal)); Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); } @@ -68,6 +73,8 @@ public sealed class EconomyCommandTests }); Assert.Equal(1, code); Assert.Contains("L-productive", output); + Assert.Contains("L-source-sink", output); + Assert.True(output.IndexOf("L-productive (", StringComparison.Ordinal) < output.IndexOf("L-source-sink (", StringComparison.Ordinal)); Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); } @@ -97,6 +104,7 @@ public sealed class EconomyCommandTests }); Assert.Equal(0, code); Assert.Contains("0 faults", output); + Assert.Contains("Wrote 4 records to Aetheria.cc", output); using var after = Read(f); Assert.NotNull(after.Get(RecipeData.KeyOf(new CultRecordKey("commodity:steel")))); Assert.Empty(EconomyChecks.Check(after).Faults); @@ -111,6 +119,7 @@ public sealed class EconomyCommandTests var bytes = File.ReadAllBytes(f.CatalogPath); Assert.Equal(0, Author(f, true, out var again, coal)); Assert.Contains("0 added, 0 changed, 2 unchanged", again); + Assert.Contains("Nothing to write.", again); Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); Assert.Equal(0, Author(f, true, out var revised, a => Good(f, a, "coal", lore: "black rock", fills: new[] { "Plate" }))); @@ -120,6 +129,29 @@ public sealed class EconomyCommandTests Assert.Equal("a good", after.Get(RecipeData.KeyOf(new CultRecordKey("commodity:ore"))).LoreHook); } + [Fact] + public void AFaultOnARecordTheBatchDidNotChangeDoesNotBlock() + { + using var f = EconomyFixture.Healthy(); + f.Good("tar", lore: " "); + var code = Author(f, true, out var output, a => Good(f, a, "tar", lore: " "), a => Good(f, a, "coal")); + Assert.Equal(0, code); + Assert.Contains("L-lore", output); + Assert.Contains("2 added, 0 changed, 2 unchanged", output); + using var after = Read(f); + Assert.NotNull(after.Get(RecipeData.KeyOf(new CultRecordKey("commodity:coal")))); + } + + [Fact] + public void TheTrialCopyIsRemoved() + { + using var f = EconomyFixture.Healthy(); + var before = Directory.GetDirectories(Path.GetTempPath(), "aetherdb-economy-*").Length; + Author(f, false, out _, a => Good(f, a, "coal")); + Author(f, true, out _, a => Good(f, a, "tar", lore: "")); + Assert.Equal(before, Directory.GetDirectories(Path.GetTempPath(), "aetherdb-economy-*").Length); + } + [Fact] public void ACompletenessFaultIsReportedAndDoesNotBlockApply() { @@ -180,7 +212,7 @@ public sealed class EconomyCommandTests var bytes = File.ReadAllBytes(f.CatalogPath); var code = Author(f, true, out var output, a => a.Edit("unobtainium", i => i.Price = 3)); Assert.Equal(1, code); - Assert.Contains("unobtainium", output); + Assert.Contains("The catalog holds no SimpleCommodityData named 'unobtainium'.", output); Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); } @@ -216,8 +248,14 @@ public sealed class EconomyCommandTests using var f = EconomyFixture.Healthy(); var healthy = new StringWriter(); Assert.Equal(0, EconomyCommand.Report(f.Cache, healthy)); - Assert.Contains("commodity:plate", healthy.ToString()); - Assert.Contains("depth 1", healthy.ToString()); + var text = healthy.ToString(); + Assert.Contains("0 faults", text); + Assert.Contains("commodity:plate depth 1", text); + Assert.Contains("commodity:ore <- nothing | Mill, cycle 1, workforce 0 | a good", text); + Assert.Contains("commodity:plate <- 2 x commodity:ore | Mill, cycle 1, workforce 0 | a good", text); + Assert.Contains("gear:laser <- 2 x Plate | Mill", text); + Assert.True(text.IndexOf("commodity:ore <-", StringComparison.Ordinal) < text.IndexOf("commodity:plate <-", StringComparison.Ordinal)); + Assert.True(text.IndexOf("commodity:plate <-", StringComparison.Ordinal) < text.IndexOf("gear:laser <-", StringComparison.Ordinal)); f.EditRecipe("commodity:plate", r => r.LoreHook = ""); var broken = new StringWriter(); Assert.Equal(1, EconomyCommand.Report(f.Cache, broken)); From 98aef507f17cb70ec7356a630f7a79243dac3684 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 06:01:59 +0200 Subject: [PATCH 08/20] Economy command tests: a later batch closing a cycle, in-place edit, one run, real-catalog write, throwing batch, null market rows and Fills (red) Co-Authored-By: Claude Sonnet 5.5 --- .../EconomyCommandTests.cs | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs index 0cf492bc6..c130b17cb 100644 --- a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs @@ -264,4 +264,108 @@ public sealed class EconomyCommandTests [Fact] public void AnUnknownSubcommandFails() => Assert.Equal(1, EconomyCommand.Run(new[] { "bogus" })); + + private static CultRecordKey K(string id) => new CultRecordKey(EconomyFixture.Key(id)); + + private static string FaultsOf(CultCache catalog, string invariant) => + string.Join(";", EconomyChecks.Check(catalog).Faults.Where(x => x.Invariant == invariant).Select(x => x.Record)); + + [Fact] + public void ACycleClosedByALaterBatchIsRefusedEvenThoughTheFaultNamesAnEarlierBatchsRecord() + { + using var f = EconomyFixture.Healthy(); + Assert.Equal(0, Author(f, true, out _, a => + { + a.Put(new CompoundCommodityData { Name = "loopb" }, K("loopb")); + Good(f, a, "loopa", inputs: new[] { ("loopb", 1) }); + })); + Flush(f); + var bytes = File.ReadAllBytes(f.CatalogPath); + var code = Author(f, true, out var output, a => Good(f, a, "loopb", inputs: new[] { ("loopa", 2) })); + Assert.Equal(1, code); + Assert.Contains("refused", output); + Assert.Contains("L-productive", output); + Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); + } + + [Fact] + public void AnInPlaceEditThroughTheCatalogIsReportedChangedAndCheckedBeforeItIsWritten() + { + using var f = EconomyFixture.Healthy(); + Flush(f); + var bytes = File.ReadAllBytes(f.CatalogPath); + var plate = RecipeData.KeyOf(K("plate")); + var code = Author(f, true, out var output, a => + { + var held = a.Catalog.Get(plate); + held.LoreHook = " "; + a.Put(held, plate); + }); + Assert.Equal(1, code); + Assert.Contains("changed recipe:commodity:plate", output); + Assert.Contains("L-lore", output); + Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); + } + + [Fact] + public void ABatchRunsOnceAndWhatIsWrittenIsWhatWasChecked() + { + using var f = EconomyFixture.Healthy(); + var runs = 0; + var code = Author(f, true, out _, a => + { + runs++; + Good(f, a, "tin", lore: runs == 1 ? "a good" : " "); + }); + Assert.Equal(0, code); + Assert.Equal(1, runs); + using var after = Read(f); + Assert.Equal("a good", after.Get(RecipeData.KeyOf(K("tin"))).LoreHook); + Assert.DoesNotContain(EconomyChecks.Check(after).Faults, x => x.Invariant == EconomyChecks.LLore); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ABatchThatWritesTheRealCatalogIsRefusedByName(bool apply) + { + using var f = EconomyFixture.Healthy(); + var code = Author(f, apply, out var output, a => File.AppendAllText(f.CatalogPath, "x")); + Assert.Equal(1, code); + Assert.Contains("Aetheria.cc changed during the trial", output); + Assert.DoesNotContain("Dry run", output); + } + + [Fact] + public void ABatchThatThrowsIsARefusalNamingItWithNoStackTrace() + { + using var f = EconomyFixture.Healthy(); + Flush(f); + var bytes = File.ReadAllBytes(f.CatalogPath); + var code = Author(f, true, out var output, a => Good(f, a, "zinc"), a => throw new IOException("boom")); + Assert.Equal(1, code); + Assert.Contains("The batch Lambda is refused: boom", output); + Assert.DoesNotContain(" at ", output); + Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ANullOutsideMarketRowsOrRowIsANamedFaultNotAThrow(bool rowsNull) + { + using var f = EconomyFixture.Healthy(); + f.Cache.Upsert(typeof(OutsideMarketData), new OutsideMarketData { Name = "gate", Rows = rowsNull ? null : new List { null } }, OutsideMarketData.KeyOf("gate")); + var report = EconomyChecks.Check(f.Cache); + Assert.Contains(report.Faults, x => x.Invariant == EconomyChecks.LRecipe && x.Record == "outside-market:gate"); + Assert.Equal(1, EconomyCommand.Report(f.Cache, new StringWriter())); + } + + [Fact] + public void ANullEntryInFillsIsAnLRecipeFault() + { + using var f = EconomyFixture.Healthy(); + f.EditRecipe("commodity:plate", r => r.Fills.Add(null)); + Assert.Contains(EconomyChecks.Check(f.Cache).Faults, x => x.Invariant == EconomyChecks.LRecipe && x.Record == "recipe:commodity:plate" && x.Message.Contains("Fills")); + } } From 3092ce81cdca81d2e82de5042b376f954fbd01d7 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 06:02:06 +0200 Subject: [PATCH 09/20] Economy checks: null outside-market Rows or row and a null Fills entry are L-recipe faults; a missing recipe is marked pending Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/EconomyChecks.cs | 23 +++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/EconomyChecks.cs b/Assets/Scripts/ServerShared/EconomyChecks.cs index eb9564845..ce2b49317 100644 --- a/Assets/Scripts/ServerShared/EconomyChecks.cs +++ b/Assets/Scripts/ServerShared/EconomyChecks.cs @@ -19,8 +19,12 @@ public sealed class EconomyFault public readonly string Record; public readonly string Message; - public EconomyFault(string invariant, string record, string message) + // A fault only because the chain has not written the record yet (a commodity with no recipe). Reported, never a refusal. + public readonly bool Pending; + + public EconomyFault(string invariant, string record, string message, bool pending = false) { + Pending = pending; Invariant = invariant; Record = record; Message = message; @@ -68,7 +72,7 @@ public static class EconomyChecks StoredRecipes(catalog).Select(pair => (pair.Key, Sound(pair.Recipe))); private static RecipeData Sound(RecipeData recipe) => - recipe.Inputs != null && recipe.Upkeep != null && recipe.Fills != null && !recipe.Inputs.Contains(null) && !recipe.Upkeep.Contains(null) + recipe.Inputs != null && recipe.Upkeep != null && recipe.Fills != null && !recipe.Inputs.Contains(null) && !recipe.Upkeep.Contains(null) && !recipe.Fills.Contains(null) ? recipe : new RecipeData { @@ -76,7 +80,7 @@ public static class EconomyChecks StationKind = recipe.StationKind, LoreHook = recipe.LoreHook, Inputs = (recipe.Inputs ?? new List()).Where(c => c != null).ToList(), Upkeep = (recipe.Upkeep ?? new List()).Where(c => c != null).ToList(), - Fills = recipe.Fills ?? new List() + Fills = (recipe.Fills ?? new List()).Where(f => f != null).ToList() }; private static IEnumerable Commodities(CultCache catalog) => @@ -89,7 +93,7 @@ public static class EconomyChecks void Add(string record, string message) => faults.Add(new EconomyFault(LRecipe, record, message)); foreach (var item in Commodities(catalog)) if (Recipes.RecipeOf(catalog, item) == null) - Add(catalog.RefOf(item).Key.Value, $"Commodity '{item.Name}' has no recipe."); + faults.Add(new EconomyFault(LRecipe, catalog.RefOf(item).Key.Value, $"Commodity '{item.Name}' has no recipe.", pending: true)); var seen = new HashSet(StringComparer.Ordinal); foreach (var product in catalog.GetAll().Where(p => LoadoutGenerator.IsOffered(catalog, p))) { @@ -97,7 +101,13 @@ public static class EconomyChecks if (design == null || (design is HullData hull && (hull.HullType == HullType.Station || hull.HullType == HullType.Turret))) continue; var key = catalog.RefOf(design).Key.Value; if (seen.Add(key) && Recipes.RecipeOf(catalog, design) == null) - Add(key, $"Offered design '{design.Name}' has no recipe."); + faults.Add(new EconomyFault(LRecipe, key, $"Offered design '{design.Name}' has no recipe.", pending: true)); + } + foreach (var market in catalog.GetAll()) + { + var marketKey = catalog.RefOf(market).Key.Value; + if (market.Rows == null) Add(marketKey, "Rows is null; an outside market holds a list, empty when it trades nothing."); + else if (market.Rows.Contains(null)) Add(marketKey, "Rows holds a null entry."); } var kindsPlaced = new HashSet(catalog.GetAll().SelectMany(f => StationKinds.KindsOf(catalog, f)).Select(k => k.Key.Value), StringComparer.Ordinal); foreach (var (key, stored) in StoredRecipes(catalog)) @@ -107,6 +117,7 @@ public static class EconomyChecks if (list == null) Add(key, $"{name} is null; a recipe holds a list, empty when it takes nothing."); else if (list.Contains(null)) Add(key, $"{name} holds a null entry."); if (stored.Fills == null) Add(key, "Fills is null; a recipe holds a list, empty when it fills no role."); + else if (stored.Fills.Contains(null)) Add(key, "Fills holds a null entry."); var output = catalog.Get(recipe.Output); if (output == null) { Add(key, "Output names no item this catalog holds."); continue; } if (key != RecipeData.KeyOf(recipe.Output.Key).Value) @@ -147,7 +158,7 @@ public static class EconomyChecks private static (HashSet Sells, HashSet Buys) Gate(CultCache catalog) { - var rows = catalog.GetAll().SelectMany(market => market.Rows).ToList(); + var rows = catalog.GetAll().SelectMany(market => market.Rows ?? new List()).Where(r => r != null).ToList(); return (new HashSet(rows.Where(r => r.Sells).Select(r => r.Commodity.Key.Value), StringComparer.Ordinal), new HashSet(rows.Where(r => r.Buys).Select(r => r.Commodity.Key.Value), StringComparer.Ordinal)); } From dc69f5dc5b0f4fb3594a50feea7adf65ff8637ad Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 06:03:29 +0200 Subject: [PATCH 10/20] Economy command: run the batches once on the trial, check that copy, replace Aetheria.cc with it atomically; refuse introduced shape faults by rule and record Co-Authored-By: Claude Sonnet 5.5 --- tools/AetherDb/EconomyCommand.cs | 157 ++++++++++++++++++------------- 1 file changed, 94 insertions(+), 63 deletions(-) diff --git a/tools/AetherDb/EconomyCommand.cs b/tools/AetherDb/EconomyCommand.cs index 0005bec98..e5657a619 100644 --- a/tools/AetherDb/EconomyCommand.cs +++ b/tools/AetherDb/EconomyCommand.cs @@ -14,30 +14,23 @@ public abstract class EconomyBatch public abstract void Author(EconomyAuthor author); } -// What a batch writes through. Every write is compared with the stored record and counted as added, changed or unchanged; an -// unchanged record is not written. +// What a batch writes through. The command compares the trial catalog with the original on disk, so a write here is only recorded +// by key, in the order the batch made it. public sealed class EconomyAuthor { - private static readonly MessagePackSerializerOptions Wire = CultDocumentMessagePackSerialization.OptionsFor(typeof(ItemData).Assembly); private readonly CultCache _catalog; - private readonly List<(string Status, string Key)> _results = new List<(string, string)>(); + private readonly List _touched = new List(); public EconomyAuthor(CultCache catalog) => _catalog = catalog; - public IReadOnlyList<(string Status, string Key)> Results => _results; - - // The keys of the records this author added or changed. - public IEnumerable Written => _results.Where(r => r.Status != "unchanged").Select(r => r.Key); + // The keys this author wrote, in order, once each. + public IReadOnlyList Touched => _touched; // The catalog as the batch has left it so far, for a batch that builds on its own earlier writes. public CultCache Catalog => _catalog; // Writes a record under a key: a new commodity, a recipe, a station kind, an outside market. - public void Put(T document, CultRecordKey key) where T : class - { - var held = _catalog.Get(key); - Record(key, held == null ? null : Bytes(typeof(T), held), typeof(T), document); - } + public void Put(T document, CultRecordKey key) where T : class => Write(typeof(T), document, key); // Edits the one item of that name and type. A name that matches no item or several is an error the command reports. public void Edit(string name, Action edit) where T : ItemData @@ -45,20 +38,15 @@ public sealed class EconomyAuthor var named = _catalog.GetAll().Where(item => item.Name == name).ToArray(); if (named.Length != 1) throw new InvalidOperationException(named.Length == 0 ? $"The catalog holds no {typeof(T).Name} named '{name}'." : $"{named.Length} {typeof(T).Name} records are named '{name}'."); - var key = _catalog.RefOf(named[0]).Key; - var before = Bytes(named[0].GetType(), named[0]); edit(named[0]); - Record(key, before, named[0].GetType(), named[0]); + Write(named[0].GetType(), named[0], _catalog.RefOf(named[0]).Key); } - private void Record(CultRecordKey key, byte[] held, Type type, object document) + private void Write(Type type, object document, CultRecordKey key) { - var status = held == null ? "added" : held.AsSpan().SequenceEqual(Bytes(type, document)) ? "unchanged" : "changed"; - _results.Add((status, key.Value)); - if (status != "unchanged") _catalog.UpsertAsync(type, document, key).GetAwaiter().GetResult(); + if (!_touched.Contains(key.Value)) _touched.Add(key.Value); + _catalog.UpsertAsync(type, document, key).GetAwaiter().GetResult(); } - - private static byte[] Bytes(Type type, object document) => MessagePackSerializer.Serialize(type, document, Wire); } // economy report | author ... [apply]: the one writer of economy content and a reader of the catalog's economy report. @@ -70,7 +58,9 @@ public static class EconomyCommand private static readonly string[] Shape = { EconomyChecks.LRecipe, EconomyChecks.LLore, EconomyChecks.LProductive, EconomyChecks.LOneKindList }; - private const string Usage = "economy report | author ... [apply]"; + // A batch is compiled code under tools/AetherDb/Economy/ and runs with the operator's authority: reviewed code, never an input. + private const string Usage = "economy report | author ... [apply] +A batch is reviewed code: it runs on a trial copy and must write only through EconomyAuthor."; public static int Run(string[] args) { @@ -116,63 +106,98 @@ public static class EconomyCommand return report.Faults.Count == 0 ? 0 : 1; } - // Runs the batches on a copy of the catalog, prints what each record would become and the check report over the result, and - // refuses when a shape rule faults a recipe a batch wrote. Only a passing apply opens the real file for writing, so a - // refusal leaves it byte-identical. + // Runs the batches once, on a copy of the catalog, and checks that copy. What apply writes is that checked copy, swapped in + // atomically, so a refusal leaves Aetheria.cc byte-identical. A batch is compiled code and is reviewed as code: it is trusted not + // to touch the real file, and the command refuses by name if the file changed under it. Added, changed and unchanged are read by + // comparing the original and the copy on disk. A shape fault the batches introduce (present after, absent before, by rule and + // record) refuses; faults the catalog already held print and do not. public static int Author(string catalogPath, IReadOnlyList batches, bool apply, TextWriter output, CultDocumentRegistry registry = null) { var scratch = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "aetherdb-economy-" + Guid.NewGuid().ToString("N"))).FullName; try { var trial = Path.Combine(scratch, "Aetheria.cc"); + var fingerprint = Fingerprint(catalogPath); File.Copy(catalogPath, trial); - var results = new List<(string Status, string Key)>(); - EconomyReport report; + HashSet<(string, string)> held; + Dictionary original; + using (var cache = AetheriaStores.Open(catalogPath, registry: registry)) + { + held = new HashSet<(string, string)>(EconomyChecks.Check(cache).Faults.Select(f => (f.Invariant, f.Record))); + original = Records(cache); + } + var touched = new List(); + int written; using (var cache = AetheriaStores.Open(trial, catalogWritable: true, registry: registry, kindlessStation: warning => output.WriteLine("warning: " + warning))) { var author = new EconomyAuthor(cache); + var running = ""; try { - foreach (var batch in batches) batch.Author(author); + foreach (var batch in batches) + { + running = batch.GetType().Name; + batch.Author(author); + } + running = ""; StationKinds.Validate(cache); } catch (Exception e) { - output.WriteLine($"The batch is refused: {e.Message}"); + output.WriteLine(running.Length == 0 ? $"The batches are refused: {e.Message}" : $"The batch {running} is refused: {e.Message}"); return 1; } - results.AddRange(author.Results); - foreach (var (status, key) in results) output.WriteLine($"{status} {key}"); - output.WriteLine($"{results.Count(r => r.Status == "added")} added, {results.Count(r => r.Status == "changed")} changed, {results.Count(r => r.Status == "unchanged")} unchanged"); - report = EconomyChecks.Check(cache); - PrintFaults(report, output); - PrintDepth(report, output); - var written = new HashSet(author.Written, StringComparer.Ordinal); - var refused = report.Faults.Where(fault => Shape.Contains(fault.Invariant) && written.Contains(RecipeKeyOf(fault.Record))).ToArray(); - if (refused.Length > 0) - { - output.WriteLine($"\nThe batch is refused: {refused.Length} shape faults on recipes it wrote."); - foreach (var fault in refused) output.WriteLine($" {fault}"); - return 1; - } - } - if (!apply) - { - output.WriteLine("\nDry run. Pass \"apply\" to write Aetheria.cc."); - return 0; - } - if (results.All(r => r.Status == "unchanged")) - { - output.WriteLine("\nNothing to write."); - return 0; - } - using (var cache = AetheriaStores.Open(catalogPath, catalogWritable: true, registry: registry)) - { - var author = new EconomyAuthor(cache); - foreach (var batch in batches) batch.Author(author); + touched.AddRange(author.Touched); cache.FlushAsync().GetAwaiter().GetResult(); } - output.WriteLine($"\nWrote {results.Count(r => r.Status != "unchanged")} records to Aetheria.cc"); + if (Fingerprint(catalogPath) != fingerprint) + { + output.WriteLine("The batches are refused: Aetheria.cc changed during the trial. Batch code must write only through EconomyAuthor."); + return 1; + } + using (var cache = AetheriaStores.Open(trial, registry: registry)) + { + var trialRecords = Records(cache); + var status = new Dictionary(StringComparer.Ordinal); + foreach (var (id, bytes) in trialRecords) + if (!original.TryGetValue(id, out var was)) status[KeyOf(id)] = "added"; + else if (!was.AsSpan().SequenceEqual(bytes)) status[KeyOf(id)] = "changed"; + var listed = touched.Concat(status.Keys.OrderBy(k => k, StringComparer.Ordinal).Where(k => !touched.Contains(k))).ToList(); + foreach (var key in listed) output.WriteLine($"{(status.TryGetValue(key, out var s) ? s : "unchanged")} {key}"); + output.WriteLine($"{status.Values.Count(v => v == "added")} added, {status.Values.Count(v => v == "changed")} changed, {listed.Count - status.Count} unchanged"); + var report = EconomyChecks.Check(cache); + PrintFaults(report, output); + PrintDepth(report, output); + var introduced = report.Faults.Where(fault => Shape.Contains(fault.Invariant) && !fault.Pending && !held.Contains((fault.Invariant, fault.Record))).ToArray(); + if (introduced.Length > 0) + { + output.WriteLine($"\nThe batch is refused: {introduced.Length} shape faults the batches introduce."); + foreach (var fault in introduced) output.WriteLine($" {fault}"); + return 1; + } + written = status.Count; + if (status.Count == 0 || !apply) + { + output.WriteLine(status.Count == 0 ? "\nNothing to write." : "\nDry run. Pass \"apply\" to write Aetheria.cc."); + return 0; + } + } + var staged = catalogPath + ".economy-" + Guid.NewGuid().ToString("N"); + File.Copy(trial, staged); + try + { + if (Fingerprint(catalogPath) != fingerprint) + { + output.WriteLine("The batches are refused: Aetheria.cc changed before the checked copy could replace it."); + return 1; + } + File.Replace(staged, catalogPath, null); + } + finally + { + if (File.Exists(staged)) File.Delete(staged); + } + output.WriteLine($"\nWrote {written} records to Aetheria.cc"); return 0; } finally @@ -181,9 +206,15 @@ public static class EconomyCommand } } - // A fault names a recipe key, or the key of the good a recipe makes. - private static string RecipeKeyOf(string record) => - record.StartsWith("recipe:", StringComparison.Ordinal) ? record : RecipeData.KeyOf(new CultRecordKey(record)).Value; + private static readonly MessagePackSerializerOptions Wire = CultDocumentMessagePackSerialization.OptionsFor(typeof(ItemData).Assembly); + + private static string Fingerprint(string path) => Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(path))); + + // Every record in the catalog as its wire bytes, under "schema|key". + private static Dictionary Records(CultCache catalog) => + catalog.AllStoredDocuments.ToDictionary(d => d.Descriptor.SchemaName + "|" + d.Key.Value, d => MessagePackSerializer.Serialize(d.Document.GetType(), d.Document, Wire), StringComparer.Ordinal); + + private static string KeyOf(string id) => id.Substring(id.IndexOf('|') + 1); private static void PrintFaults(EconomyReport report, TextWriter output) { From 7f3b06222dc9f58be78ffa6e9b77d719a2e20556 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 06:03:44 +0200 Subject: [PATCH 11/20] Economy command: run the batches once on the trial, check that copy, replace Aetheria.cc with it atomically; refuse introduced shape faults by rule and record Co-Authored-By: Claude Sonnet 5.5 --- tools/AetherDb/EconomyCommand.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tools/AetherDb/EconomyCommand.cs b/tools/AetherDb/EconomyCommand.cs index e5657a619..df568ca9e 100644 --- a/tools/AetherDb/EconomyCommand.cs +++ b/tools/AetherDb/EconomyCommand.cs @@ -53,14 +53,13 @@ public sealed class EconomyAuthor // EconomyChecks owns every rule; this command only calls it. public static class EconomyCommand { - // The invariants whose faults on a record a batch wrote refuse it. The rest (a missing sink, no consumer demand, a commodity + // The invariants whose faults the batches introduce refuse them. The rest (a missing sink, no consumer demand, a commodity // with no recipe yet) are completeness: reported, and settled by the batch that closes the chain. private static readonly string[] Shape = { EconomyChecks.LRecipe, EconomyChecks.LLore, EconomyChecks.LProductive, EconomyChecks.LOneKindList }; // A batch is compiled code under tools/AetherDb/Economy/ and runs with the operator's authority: reviewed code, never an input. - private const string Usage = "economy report | author ... [apply] -A batch is reviewed code: it runs on a trial copy and must write only through EconomyAuthor."; + private const string Usage = "economy report | author ... [apply]\nA batch is reviewed code: it runs on a trial copy and must write only through EconomyAuthor."; public static int Run(string[] args) { From 7c9a970c812e07e22f6f5545a1334d5e59daf1cb Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 06:09:35 +0200 Subject: [PATCH 12/20] Economy command: drop the untested second fingerprint check before the swap Co-Authored-By: Claude Sonnet 5.5 --- tools/AetherDb/EconomyCommand.cs | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/tools/AetherDb/EconomyCommand.cs b/tools/AetherDb/EconomyCommand.cs index df568ca9e..240f6a118 100644 --- a/tools/AetherDb/EconomyCommand.cs +++ b/tools/AetherDb/EconomyCommand.cs @@ -183,19 +183,8 @@ public static class EconomyCommand } var staged = catalogPath + ".economy-" + Guid.NewGuid().ToString("N"); File.Copy(trial, staged); - try - { - if (Fingerprint(catalogPath) != fingerprint) - { - output.WriteLine("The batches are refused: Aetheria.cc changed before the checked copy could replace it."); - return 1; - } - File.Replace(staged, catalogPath, null); - } - finally - { - if (File.Exists(staged)) File.Delete(staged); - } + try { File.Replace(staged, catalogPath, null); } + finally { if (File.Exists(staged)) File.Delete(staged); } output.WriteLine($"\nWrote {written} records to Aetheria.cc"); return 0; } From a90ec3d45087901befd59db8014a7c05ce01594c Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 06:27:54 +0200 Subject: [PATCH 13/20] Economy command tests: fault identity by rule/record/message, cycle members and radius, removals listed and counted, pending only for the recipe-less, swap failure and file mode (red) Co-Authored-By: Claude Sonnet 5.5 --- .../EconomyCommandTests.cs | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs index c130b17cb..5cd6441bf 100644 --- a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs @@ -368,4 +368,155 @@ public sealed class EconomyCommandTests f.EditRecipe("commodity:plate", r => r.Fills.Add(null)); Assert.Contains(EconomyChecks.Check(f.Cache).Faults, x => x.Invariant == EconomyChecks.LRecipe && x.Record == "recipe:commodity:plate" && x.Message.Contains("Fills")); } + + [Fact] + public void FixingOneFaultOnARecordDoesNotHideANewOneThere() + { + using var f = EconomyFixture.Healthy(); + f.EditRecipe("commodity:plate", r => r.Upkeep.Add(null)); + Flush(f); + var bytes = File.ReadAllBytes(f.CatalogPath); + var key = RecipeData.KeyOf(new CultRecordKey(EconomyFixture.Key("plate"))); + var code = Author(f, true, out var output, a => { var r = a.Catalog.Get(key); r.Upkeep.RemoveAll(x => x == null); r.Cycle = 0; a.Put(r, key); }); + Assert.Equal(1, code); + Assert.Contains("Cycle is not a finite number above zero", output); + Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); + } + + private static EconomyFixture HeldLoop() + { + var f = EconomyFixture.Healthy(); + f.Good("loopa", inputs: new[] { ("loopb", 1) }); + f.Good("loopb", inputs: new[] { ("loopa", 2) }); + return f; + } + + [Theory] + [InlineData("worsen")] + [InlineData("move")] + [InlineData("grow")] + [InlineData("disjoint")] + public void AHeldUnproductiveCatalogRefusesABatchThatWorsensMovesOrGrowsTheCycle(string how) + { + using var f = HeldLoop(); + Flush(f); + var bytes = File.ReadAllBytes(f.CatalogPath); + Action batch = how switch + { + "worsen" => a => Good(f, a, "loopb", inputs: new[] { ("loopa", 50) }), + "move" => a => { Good(f, a, "loopb"); Good(f, a, "loopz", inputs: new[] { ("loopa", 9) }); Good(f, a, "loopa", inputs: new[] { ("loopz", 1) }); }, + "grow" => a => { Good(f, a, "loopy", inputs: new[] { ("loopa", 7) }); Good(f, a, "loopa", inputs: new[] { ("loopb", 1), ("loopy", 7) }); }, + _ => a => { Good(f, a, "loopc", inputs: new[] { ("loopd", 5) }); Good(f, a, "loopd", inputs: new[] { ("loopc", 5) }); } + }; + var code = Author(f, true, out var output, batch); + Assert.Equal(1, code); + Assert.Contains("L-productive", output); + Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); + } + + [Fact] + public void AHeldUnproductiveCatalogStillAppliesABatchThatLeavesTheCycleAlone() + { + using var f = HeldLoop(); + var code = Author(f, true, out var output, a => Good(f, a, "coal")); + Assert.Equal(0, code); + Assert.Contains("Wrote 2 records", output); + } + + private static void Seed(EconomyFixture f, string id) + { + var key = new CultRecordKey(EconomyFixture.Key(id)); + f.Cache.Upsert(typeof(SimpleCommodityData), new SimpleCommodityData { Name = id }, key); + f.Cache.Upsert(typeof(RecipeData), new RecipeData { Output = EconomyFixture.Item(key.Value), StationKind = f.Mill, LoreHook = "a good", Inputs = new List() }, RecipeData.KeyOf(key)); + } + + [Fact] + public void RemovingACommodityAndItsRecipeIsListedAndApplies() + { + using var f = EconomyFixture.Healthy(); + Seed(f, "tar"); + var key = new CultRecordKey(EconomyFixture.Key("tar")); + var code = Author(f, true, out var output, a => { a.Catalog.Remove(RecipeData.KeyOf(key)); a.Catalog.Remove(key); }); + Assert.Equal(0, code); + Assert.Contains("removed commodity:tar", output); + Assert.Contains("removed recipe:commodity:tar", output); + Assert.Contains("2 removed", output); + Assert.Contains("Wrote 2 records", output); + Assert.DoesNotContain("Nothing to write", output); + using var after = Read(f); + Assert.Null(after.Get(RecipeData.KeyOf(key))); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void RemovingARecipeAloneIsARefusedLRecipeFault(bool alongsideAnAddition) + { + using var f = EconomyFixture.Healthy(); + Seed(f, "tar"); + Flush(f); + var bytes = File.ReadAllBytes(f.CatalogPath); + var key = new CultRecordKey(EconomyFixture.Key("tar")); + var code = Author(f, true, out var output, a => { a.Catalog.Remove(RecipeData.KeyOf(key)); if (alongsideAnAddition) Good(f, a, "coal"); }); + Assert.Equal(1, code); + Assert.Contains("L-recipe commodity:tar", output); + Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); + } + + [Fact] + public void ANewCommodityWithNoRecipeYetIsStillPending() + { + using var f = EconomyFixture.Healthy(); + var code = Author(f, true, out var output, a => a.Put(new SimpleCommodityData { Name = "coal" }, new CultRecordKey(EconomyFixture.Key("coal")))); + Assert.Equal(0, code); + Assert.Contains("Wrote 1 records", output); + } + + [Fact] + public void AReadOnlyCatalogIsARefusalOnEveryPlatformAndStaysIntact() + { + using var f = EconomyFixture.Healthy(); + Flush(f); + var bytes = File.ReadAllBytes(f.CatalogPath); + File.SetAttributes(f.CatalogPath, FileAttributes.ReadOnly); + try + { + var code = Author(f, true, out var output, a => Good(f, a, "coal")); + Assert.Equal(1, code); + Assert.Contains("read-only", output); + Assert.DoesNotContain(" at ", output); + Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); + } + finally { File.SetAttributes(f.CatalogPath, FileAttributes.Normal); } + } + + [Fact] + public void ACatalogHeldOpenWithoutDeleteSharingIsANamedRefusalAndStaysIntact() + { + if (!OperatingSystem.IsWindows()) return; + using var f = EconomyFixture.Healthy(); + Flush(f); + var bytes = File.ReadAllBytes(f.CatalogPath); + using (new FileStream(f.CatalogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + var code = Author(f, true, out var output, a => Good(f, a, "coal")); + Assert.Equal(1, code); + Assert.Contains("close Unity", output); + Assert.DoesNotContain(" at ", output); + } + Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); + Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(f.CatalogPath), "*.economy-*")); + } + + [Fact] + public void TheSwapKeepsTheCatalogsFileMode() + { + if (OperatingSystem.IsWindows()) return; + using var f = EconomyFixture.Healthy(); + Flush(f); + var mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead; + File.SetUnixFileMode(f.CatalogPath, mode); + Assert.Equal(0, Author(f, true, out _, a => Good(f, a, "coal"))); + Assert.Equal(mode, File.GetUnixFileMode(f.CatalogPath)); + } } From 352a94fe2129ec22eef55df757c3004e5fb3af78 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 06:28:20 +0200 Subject: [PATCH 14/20] Economy command: faults keyed by rule, record and message; L-productive by cycle members with a rising radius refused; removals listed, counted and applied; pending only for the recipe-less; swap failures named, read-only refused, file mode kept Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/EconomyChecks.cs | 12 ++++- tools/AetherDb/EconomyCommand.cs | 46 +++++++++++++++++--- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/Assets/Scripts/ServerShared/EconomyChecks.cs b/Assets/Scripts/ServerShared/EconomyChecks.cs index ce2b49317..fb7ce3c7c 100644 --- a/Assets/Scripts/ServerShared/EconomyChecks.cs +++ b/Assets/Scripts/ServerShared/EconomyChecks.cs @@ -22,8 +22,15 @@ public sealed class EconomyFault // A fault only because the chain has not written the record yet (a commodity with no recipe). Reported, never a refusal. public readonly bool Pending; - public EconomyFault(string invariant, string record, string message, bool pending = false) + // L-productive only: every good the carrying cycle holds (sorted) and the spectral radius. The fault's identity is the member + // set; a radius that rises is a worse fault of the same identity. + public readonly IReadOnlyList Members; + public readonly double Radius; + + public EconomyFault(string invariant, string record, string message, bool pending = false, IReadOnlyList members = null, double radius = 0) { + Members = members; + Radius = radius; Pending = pending; Invariant = invariant; Record = record; @@ -304,7 +311,8 @@ public static class EconomyChecks string Name(int i) => i == labour ? "labour" : names[i]; var order = CycleOrder(final, n, component); faults.Add(new EconomyFault(LProductive, Name(order[0]), - $"The goods cannot sustain themselves: spectral radius {rho:0.######} is not below 1, carried by {string.Join(" -> ", order.Select(Name))}.")); + $"The goods cannot sustain themselves: spectral radius {rho:0.######} is not below 1, carried by {string.Join(" -> ", order.Select(Name))}.", + members: order.Select(Name).OrderBy(name => name, StringComparer.Ordinal).ToArray(), radius: rho)); } return faults; } diff --git a/tools/AetherDb/EconomyCommand.cs b/tools/AetherDb/EconomyCommand.cs index 240f6a118..9385e181a 100644 --- a/tools/AetherDb/EconomyCommand.cs +++ b/tools/AetherDb/EconomyCommand.cs @@ -118,11 +118,14 @@ public static class EconomyCommand var trial = Path.Combine(scratch, "Aetheria.cc"); var fingerprint = Fingerprint(catalogPath); File.Copy(catalogPath, trial); - HashSet<(string, string)> held; + Dictionary held; + HashSet recipesHeld; Dictionary original; using (var cache = AetheriaStores.Open(catalogPath, registry: registry)) { - held = new HashSet<(string, string)>(EconomyChecks.Check(cache).Faults.Select(f => (f.Invariant, f.Record))); + held = new Dictionary(StringComparer.Ordinal); + foreach (var fault in EconomyChecks.Check(cache).Faults) held[Identity(fault)] = fault; + recipesHeld = new HashSet(cache.GetAll().Select(r => cache.RefOf(r).Key.Value), StringComparer.Ordinal); original = Records(cache); } var touched = new List(); @@ -161,13 +164,15 @@ public static class EconomyCommand foreach (var (id, bytes) in trialRecords) if (!original.TryGetValue(id, out var was)) status[KeyOf(id)] = "added"; else if (!was.AsSpan().SequenceEqual(bytes)) status[KeyOf(id)] = "changed"; + foreach (var id in original.Keys) + if (!trialRecords.ContainsKey(id)) status[KeyOf(id)] = "removed"; var listed = touched.Concat(status.Keys.OrderBy(k => k, StringComparer.Ordinal).Where(k => !touched.Contains(k))).ToList(); foreach (var key in listed) output.WriteLine($"{(status.TryGetValue(key, out var s) ? s : "unchanged")} {key}"); - output.WriteLine($"{status.Values.Count(v => v == "added")} added, {status.Values.Count(v => v == "changed")} changed, {listed.Count - status.Count} unchanged"); + output.WriteLine($"{status.Values.Count(v => v == "added")} added, {status.Values.Count(v => v == "changed")} changed, {status.Values.Count(v => v == "removed")} removed, {listed.Count - status.Count} unchanged"); var report = EconomyChecks.Check(cache); PrintFaults(report, output); PrintDepth(report, output); - var introduced = report.Faults.Where(fault => Shape.Contains(fault.Invariant) && !fault.Pending && !held.Contains((fault.Invariant, fault.Record))).ToArray(); + var introduced = report.Faults.Where(fault => Shape.Contains(fault.Invariant) && Introduced(fault, held, recipesHeld)).ToArray(); if (introduced.Length > 0) { output.WriteLine($"\nThe batch is refused: {introduced.Length} shape faults the batches introduce."); @@ -182,8 +187,24 @@ public static class EconomyCommand } } var staged = catalogPath + ".economy-" + Guid.NewGuid().ToString("N"); - File.Copy(trial, staged); - try { File.Replace(staged, catalogPath, null); } + try + { + if (new FileInfo(catalogPath).IsReadOnly) + { + output.WriteLine(" +The batches are refused: Aetheria.cc is read-only. Clear the read-only flag, or check it out, and run again. The catalog is unchanged."); + return 1; + } + File.Copy(trial, staged); + if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(staged, File.GetUnixFileMode(catalogPath)); + File.Replace(staged, catalogPath, null); + } + catch (Exception e) when (e is UnauthorizedAccessException || e is IOException) + { + output.WriteLine($" +The batches are refused: Aetheria.cc could not be replaced ({e.Message}). Something holds the file: close Unity and any other editor or tool that has it open, then run again. The catalog is unchanged."); + return 1; + } finally { if (File.Exists(staged)) File.Delete(staged); } output.WriteLine($"\nWrote {written} records to Aetheria.cc"); return 0; @@ -194,6 +215,19 @@ public static class EconomyCommand } } + // A fault is the same fault by rule, record and message, so fixing one fault on a record cannot hide a new one there. L-productive + // is one fault per cycle: its identity is the cycle's member set, and the same cycle with a higher radius is a worse fault. + private static string Identity(EconomyFault fault) => + fault.Members != null ? fault.Invariant + "|" + string.Join(",", fault.Members) : fault.Invariant + "|" + fault.Record + "|" + fault.Message; + + private static bool Introduced(EconomyFault fault, Dictionary held, HashSet recipesHeld) + { + // "No recipe" is pending for a commodity that had none; it refuses where the batch took away a recipe the catalog held. + if (fault.Pending) return recipesHeld.Contains(RecipeData.KeyOf(new CultRecordKey(fault.Record)).Value); + if (!held.TryGetValue(Identity(fault), out var was)) return true; + return fault.Members != null && fault.Radius > was.Radius + 1e-9; + } + private static readonly MessagePackSerializerOptions Wire = CultDocumentMessagePackSerialization.OptionsFor(typeof(ItemData).Assembly); private static string Fingerprint(string path) => Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(path))); From e5099c03635bd963153465735dfcde4a9a7ee2e1 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 06:32:52 +0200 Subject: [PATCH 15/20] Economy command: escape the refusal line breaks Co-Authored-By: Claude Sonnet 5.5 --- tools/AetherDb/EconomyCommand.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tools/AetherDb/EconomyCommand.cs b/tools/AetherDb/EconomyCommand.cs index 9385e181a..0d58b39a2 100644 --- a/tools/AetherDb/EconomyCommand.cs +++ b/tools/AetherDb/EconomyCommand.cs @@ -191,8 +191,7 @@ public static class EconomyCommand { if (new FileInfo(catalogPath).IsReadOnly) { - output.WriteLine(" -The batches are refused: Aetheria.cc is read-only. Clear the read-only flag, or check it out, and run again. The catalog is unchanged."); + output.WriteLine("\nThe batches are refused: Aetheria.cc is read-only. Clear the read-only flag, or check it out, and run again. The catalog is unchanged."); return 1; } File.Copy(trial, staged); @@ -201,8 +200,7 @@ The batches are refused: Aetheria.cc is read-only. Clear the read-only flag, or } catch (Exception e) when (e is UnauthorizedAccessException || e is IOException) { - output.WriteLine($" -The batches are refused: Aetheria.cc could not be replaced ({e.Message}). Something holds the file: close Unity and any other editor or tool that has it open, then run again. The catalog is unchanged."); + output.WriteLine($"\nThe batches are refused: Aetheria.cc could not be replaced ({e.Message}). Something holds the file: close Unity and any other editor or tool that has it open, then run again. The catalog is unchanged."); return 1; } finally { if (File.Exists(staged)) File.Delete(staged); } From f403485dc0a88e4eb39aff509846832f137acdf9 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 06:36:54 +0200 Subject: [PATCH 16/20] Economy command tests: the summary line counts removals Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/EconomyCommandTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs index 5cd6441bf..1b97a599f 100644 --- a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs @@ -118,12 +118,12 @@ public sealed class EconomyCommandTests Assert.Equal(0, Author(f, true, out _, coal)); var bytes = File.ReadAllBytes(f.CatalogPath); Assert.Equal(0, Author(f, true, out var again, coal)); - Assert.Contains("0 added, 0 changed, 2 unchanged", again); + Assert.Contains("0 added, 0 changed, 0 removed, 2 unchanged", again); Assert.Contains("Nothing to write.", again); Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); Assert.Equal(0, Author(f, true, out var revised, a => Good(f, a, "coal", lore: "black rock", fills: new[] { "Plate" }))); - Assert.Contains("0 added, 1 changed, 1 unchanged", revised); + Assert.Contains("0 added, 1 changed, 0 removed, 1 unchanged", revised); using var after = Read(f); Assert.Equal("black rock", after.Get(RecipeData.KeyOf(new CultRecordKey("commodity:coal"))).LoreHook); Assert.Equal("a good", after.Get(RecipeData.KeyOf(new CultRecordKey("commodity:ore"))).LoreHook); @@ -137,7 +137,7 @@ public sealed class EconomyCommandTests var code = Author(f, true, out var output, a => Good(f, a, "tar", lore: " "), a => Good(f, a, "coal")); Assert.Equal(0, code); Assert.Contains("L-lore", output); - Assert.Contains("2 added, 0 changed, 2 unchanged", output); + Assert.Contains("2 added, 0 changed, 0 removed, 2 unchanged", output); using var after = Read(f); Assert.NotNull(after.Get(RecipeData.KeyOf(new CultRecordKey("commodity:coal")))); } From 2da2afd4680bc10395b76bd95de107eac64d9c70 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 06:38:50 +0200 Subject: [PATCH 17/20] Economy command: refuse a read-only catalog before the trial; the trial copy is writable Co-Authored-By: Claude Sonnet 5.5 --- tools/AetherDb/EconomyCommand.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tools/AetherDb/EconomyCommand.cs b/tools/AetherDb/EconomyCommand.cs index 0d58b39a2..6c6cc15c2 100644 --- a/tools/AetherDb/EconomyCommand.cs +++ b/tools/AetherDb/EconomyCommand.cs @@ -112,12 +112,18 @@ public static class EconomyCommand // record) refuses; faults the catalog already held print and do not. public static int Author(string catalogPath, IReadOnlyList batches, bool apply, TextWriter output, CultDocumentRegistry registry = null) { + if (apply && new FileInfo(catalogPath).IsReadOnly) + { + output.WriteLine("The batches are refused: Aetheria.cc is read-only. Clear the read-only flag, or check it out, and run again. The catalog is unchanged."); + return 1; + } var scratch = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "aetherdb-economy-" + Guid.NewGuid().ToString("N"))).FullName; try { var trial = Path.Combine(scratch, "Aetheria.cc"); var fingerprint = Fingerprint(catalogPath); File.Copy(catalogPath, trial); + new FileInfo(trial).IsReadOnly = false; Dictionary held; HashSet recipesHeld; Dictionary original; @@ -189,11 +195,6 @@ public static class EconomyCommand var staged = catalogPath + ".economy-" + Guid.NewGuid().ToString("N"); try { - if (new FileInfo(catalogPath).IsReadOnly) - { - output.WriteLine("\nThe batches are refused: Aetheria.cc is read-only. Clear the read-only flag, or check it out, and run again. The catalog is unchanged."); - return 1; - } File.Copy(trial, staged); if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(staged, File.GetUnixFileMode(catalogPath)); File.Replace(staged, catalogPath, null); From fe7c5226cc8e400f777c40f5714991aa6aef67f4 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 06:43:56 +0200 Subject: [PATCH 18/20] Economy command tests: a cycle that moves to a lower radius is still a changed membership Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/EconomyCommandTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs index 1b97a599f..73d616f75 100644 --- a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs @@ -394,6 +394,7 @@ public sealed class EconomyCommandTests [Theory] [InlineData("worsen")] [InlineData("move")] + [InlineData("move-lower")] [InlineData("grow")] [InlineData("disjoint")] public void AHeldUnproductiveCatalogRefusesABatchThatWorsensMovesOrGrowsTheCycle(string how) @@ -405,6 +406,7 @@ public sealed class EconomyCommandTests { "worsen" => a => Good(f, a, "loopb", inputs: new[] { ("loopa", 50) }), "move" => a => { Good(f, a, "loopb"); Good(f, a, "loopz", inputs: new[] { ("loopa", 9) }); Good(f, a, "loopa", inputs: new[] { ("loopz", 1) }); }, + "move-lower" => a => { Good(f, a, "loopb"); Good(f, a, "loopz", inputs: new[] { ("loopa", 1) }); Good(f, a, "loopa", inputs: new[] { ("loopz", 1) }); }, "grow" => a => { Good(f, a, "loopy", inputs: new[] { ("loopa", 7) }); Good(f, a, "loopa", inputs: new[] { ("loopb", 1), ("loopy", 7) }); }, _ => a => { Good(f, a, "loopc", inputs: new[] { ("loopd", 5) }); Good(f, a, "loopd", inputs: new[] { ("loopc", 5) }); } }; From ae3e4a7c587aa9b27974c91db08d0be1a1308025 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 06:48:22 +0200 Subject: [PATCH 19/20] Economy command tests: a held cycle whose radius falls still applies Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/EconomyCommandTests.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs index 73d616f75..4d4efbe98 100644 --- a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs @@ -416,6 +416,16 @@ public sealed class EconomyCommandTests Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); } + [Fact] + public void AHeldCycleWhoseRadiusFallsStillApplies() + { + using var f = HeldLoop(); + var code = Author(f, true, out var output, a => Good(f, a, "loopb", inputs: new[] { ("loopa", 1) })); + Assert.Equal(0, code); + Assert.Contains("L-productive", output); + Assert.Contains("Wrote 1 records", output); + } + [Fact] public void AHeldUnproductiveCatalogStillAppliesABatchThatLeavesTheCycleAlone() { From 6befccce7453c8d9de0939b9953ecdbd6691063b Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 07:15:59 +0200 Subject: [PATCH 20/20] Economy command: L-productive is one fault per unproductive component; a held-open catalog is a named refusal at the first read too Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/EconomyChecks.cs | 19 +++++---- .../EconomyCommandTests.cs | 39 +++++++++++++++++++ tools/AetherDb/EconomyCommand.cs | 19 +++++++-- 3 files changed, 63 insertions(+), 14 deletions(-) diff --git a/Assets/Scripts/ServerShared/EconomyChecks.cs b/Assets/Scripts/ServerShared/EconomyChecks.cs index fb7ce3c7c..3c8121b72 100644 --- a/Assets/Scripts/ServerShared/EconomyChecks.cs +++ b/Assets/Scripts/ServerShared/EconomyChecks.cs @@ -304,11 +304,11 @@ public static class EconomyChecks } var final = Build(pick, 0); - var (rho, component) = Radius(final, n); var faults = new List(); - if (rho >= 1 - Margin) + string Name(int i) => i == labour ? "labour" : names[i]; + foreach (var (rho, component) in Radii(final, n)) { - string Name(int i) => i == labour ? "labour" : names[i]; + if (rho < 1 - Margin) continue; var order = CycleOrder(final, n, component); faults.Add(new EconomyFault(LProductive, Name(order[0]), $"The goods cannot sustain themselves: spectral radius {rho:0.######} is not below 1, carried by {string.Join(" -> ", order.Select(Name))}.", @@ -336,9 +336,9 @@ public static class EconomyChecks return u; } - // The spectral radius of a nonnegative matrix: the largest over its strongly connected components that hold a cycle, each by - // power iteration on A + I (primitive when irreducible). An edge j -> i is a nonzero A[i, j]. Also the component carrying it. - private static (double Rho, List Component) Radius(double[,] a, int n) + // The spectral radius of every strongly connected component of a nonnegative matrix, each by power iteration on A + I + // (primitive when irreducible). An edge j -> i is a nonzero A[i, j]. A component with no cycle has radius 0. + private static List<(double Rho, List Component)> Radii(double[,] a, int n) { var reach = new bool[n, n]; for (var i = 0; i < n; i++) @@ -350,8 +350,7 @@ public static class EconomyChecks for (var j = 0; j < n; j++) if (reach[k, j]) reach[i, j] = true; var done = new bool[n]; - var best = 0.0; - var bestComponent = new List(); + var radii = new List<(double, List)>(); for (var start = 0; start < n; start++) { if (done[start]) continue; @@ -359,9 +358,9 @@ public static class EconomyChecks foreach (var i in component) done[i] = true; var rho = Irreducible(a, component); if (double.IsNaN(rho)) rho = double.PositiveInfinity; - if (rho > best) { best = rho; bestComponent = component; } + radii.Add((rho, component)); } - return (best, bestComponent); + return radii; } private static double Irreducible(double[,] a, List nodes) diff --git a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs index 4d4efbe98..7fc19f641 100644 --- a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs @@ -416,6 +416,28 @@ public sealed class EconomyCommandTests Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ANewUnproductiveCycleBelowTheHeldRadiusIsRefusedAloneOrBesideALoweringOfTheHeldCycle(bool alsoLowerHeld) + { + using var f = EconomyFixture.Healthy(); + f.Good("loopa", inputs: new[] { ("loopb", 2) }); + f.Good("loopb", inputs: new[] { ("loopa", 4) }); + Flush(f); + var bytes = File.ReadAllBytes(f.CatalogPath); + var code = Author(f, true, out var output, a => + { + if (alsoLowerHeld) Good(f, a, "loopb", inputs: new[] { ("loopa", 2) }); + Good(f, a, "loopc", inputs: new[] { ("loopd", 1) }); + Good(f, a, "loopd", inputs: new[] { ("loope", 1) }); + Good(f, a, "loope", inputs: new[] { ("loopc", 2) }); + }); + Assert.Equal(1, code); + Assert.Contains("L-productive", output); + Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); + } + [Fact] public void AHeldCycleWhoseRadiusFallsStillApplies() { @@ -520,6 +542,23 @@ public sealed class EconomyCommandTests Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(f.CatalogPath), "*.economy-*")); } + [Fact] + public void ACatalogHeldOpenForWritingIsANamedRefusalAndStaysIntact() + { + if (!OperatingSystem.IsWindows()) return; + using var f = EconomyFixture.Healthy(); + Flush(f); + var bytes = File.ReadAllBytes(f.CatalogPath); + using (new FileStream(f.CatalogPath, FileMode.Open, FileAccess.ReadWrite, FileShare.Read)) + { + var code = Author(f, true, out var output, a => Good(f, a, "coal")); + Assert.Equal(1, code); + Assert.Contains("close Unity", output); + Assert.DoesNotContain(" at ", output); + } + Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); + } + [Fact] public void TheSwapKeepsTheCatalogsFileMode() { diff --git a/tools/AetherDb/EconomyCommand.cs b/tools/AetherDb/EconomyCommand.cs index 6c6cc15c2..6981cc2e3 100644 --- a/tools/AetherDb/EconomyCommand.cs +++ b/tools/AetherDb/EconomyCommand.cs @@ -121,19 +121,26 @@ public static class EconomyCommand try { var trial = Path.Combine(scratch, "Aetheria.cc"); - var fingerprint = Fingerprint(catalogPath); - File.Copy(catalogPath, trial); - new FileInfo(trial).IsReadOnly = false; + string fingerprint; Dictionary held; HashSet recipesHeld; Dictionary original; - using (var cache = AetheriaStores.Open(catalogPath, registry: registry)) + try { + fingerprint = Fingerprint(catalogPath); + File.Copy(catalogPath, trial); + new FileInfo(trial).IsReadOnly = false; + using var cache = AetheriaStores.Open(catalogPath, registry: registry); held = new Dictionary(StringComparer.Ordinal); foreach (var fault in EconomyChecks.Check(cache).Faults) held[Identity(fault)] = fault; recipesHeld = new HashSet(cache.GetAll().Select(r => cache.RefOf(r).Key.Value), StringComparer.Ordinal); original = Records(cache); } + catch (Exception e) when (e is UnauthorizedAccessException || e is IOException) + { + output.WriteLine(Held("read", e)); + return 1; + } var touched = new List(); int written; using (var cache = AetheriaStores.Open(trial, catalogWritable: true, registry: registry, kindlessStation: warning => output.WriteLine("warning: " + warning))) @@ -214,6 +221,10 @@ public static class EconomyCommand } } + // Something holds Aetheria.cc open: a refusal that names the holder to close, never an exception. + private static string Held(string verb, Exception e) => + $"The batches are refused: Aetheria.cc could not be {verb} ({e.Message}). Something holds the file: close Unity and any other editor or tool that has it open, then run again. The catalog is unchanged."; + // A fault is the same fault by rule, record and message, so fixing one fault on a record cannot hide a new one there. L-productive // is one fault per cycle: its identity is the cycle's member set, and the same cycle with a higher radius is a worse fault. private static string Identity(EconomyFault fault) =>