diff --git a/Assets/Scripts/ServerShared/EconomyChecks.cs b/Assets/Scripts/ServerShared/EconomyChecks.cs index 13f7f5d78..3c8121b72 100644 --- a/Assets/Scripts/ServerShared/EconomyChecks.cs +++ b/Assets/Scripts/ServerShared/EconomyChecks.cs @@ -19,8 +19,19 @@ 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; + + // 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; Message = message; @@ -59,9 +70,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.Fills.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()).Where(f => f != null).ToList() + }; + private static IEnumerable Commodities(CultCache catalog) => catalog.GetAll().Where(Recipes.IsCommodity).OrderBy(item => catalog.RefOf(item).Key.Value, StringComparer.Ordinal); @@ -72,7 +100,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))) { @@ -80,14 +108,26 @@ 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, 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."); + 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 (!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."); @@ -125,7 +165,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)); } @@ -264,14 +304,15 @@ 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))}.")); + $"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; } @@ -295,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++) @@ -309,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; @@ -318,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 new file mode 100644 index 000000000..7fc19f641 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs @@ -0,0 +1,573 @@ +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.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)); + } + + [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.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)); + } + + [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", 2) }, fills: new[] { "Plate" }); + }); + 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); + } + + [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, 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, 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); + } + + [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, 0 removed, 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() + { + 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("The catalog holds no SimpleCommodityData named 'unobtainium'.", output); + 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() + { + 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)); + 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)); + Assert.Contains("L-lore", broken.ToString()); + } + + [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")); + } + + [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("move-lower")] + [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) }); }, + "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) }); } + }; + 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)); + } + + [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() + { + 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() + { + 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 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() + { + 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)); + } +} 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")); + } +} diff --git a/tools/AetherDb/EconomyCommand.cs b/tools/AetherDb/EconomyCommand.cs new file mode 100644 index 000000000..6981cc2e3 --- /dev/null +++ b/tools/AetherDb/EconomyCommand.cs @@ -0,0 +1,267 @@ +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 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. 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 readonly CultCache _catalog; + private readonly List _touched = new List(); + + public EconomyAuthor(CultCache catalog) => _catalog = catalog; + + // 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 => 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 + { + 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}'."); + edit(named[0]); + Write(named[0].GetType(), named[0], _catalog.RefOf(named[0]).Key); + } + + private void Write(Type type, object document, CultRecordKey key) + { + if (!_touched.Contains(key.Value)) _touched.Add(key.Value); + _catalog.UpsertAsync(type, document, key).GetAwaiter().GetResult(); + } +} + +// 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 +{ + // 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]\nA batch is reviewed code: it runs on a trial copy and must write only through EconomyAuthor."; + + 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 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) + { + 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"); + string fingerprint; + Dictionary held; + HashSet recipesHeld; + Dictionary original; + 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))) + { + var author = new EconomyAuthor(cache); + var running = ""; + try + { + foreach (var batch in batches) + { + running = batch.GetType().Name; + batch.Author(author); + } + running = ""; + StationKinds.Validate(cache); + } + catch (Exception e) + { + output.WriteLine(running.Length == 0 ? $"The batches are refused: {e.Message}" : $"The batch {running} is refused: {e.Message}"); + return 1; + } + touched.AddRange(author.Touched); + cache.FlushAsync().GetAwaiter().GetResult(); + } + 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"; + 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, {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) && Introduced(fault, held, recipesHeld)).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"); + try + { + 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($"\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); } + output.WriteLine($"\nWrote {written} records to Aetheria.cc"); + return 0; + } + finally + { + try { Directory.Delete(scratch, true); } catch (IOException) { } + } + } + + // 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) => + 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))); + + // 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) + { + 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 6f289b537..665ce766b 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -76,10 +76,11 @@ public static class Program if (apply) db.Cache.FlushAsync().GetAwaiter().GetResult(); return code; } + case "economy": return EconomyCommand.Run(args.Skip(1).ToArray()); case "auto-guns": return AutoGunsCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out); 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], auto-guns [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], auto-guns [apply], assembly-from-maker [apply], station-kinds [hull \"\" ] [apply], economy report|author ... [apply]"); return 1; } }