Merge eureka/aetheria-living-world-economy-authoring-command: AetherDb economy author and report; the batch gate refuses every shape fault a batch introduces

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-23 07:23:04 +02:00
commit bd7cf4b5d4
5 changed files with 953 additions and 19 deletions

View file

@ -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<string> Members;
public readonly double Radius;
public EconomyFault(string invariant, string record, string message, bool pending = false, IReadOnlyList<string> 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<RecipeData>().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<CommodityCount>()).Where(c => c != null).ToList(),
Upkeep = (recipe.Upkeep ?? new List<CommodityCount>()).Where(c => c != null).ToList(),
Fills = (recipe.Fills ?? new List<string>()).Where(f => f != null).ToList()
};
private static IEnumerable<ItemData> Commodities(CultCache catalog) =>
catalog.GetAll<ItemData>().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<string>(StringComparer.Ordinal);
foreach (var product in catalog.GetAll<FactionProductData>().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<OutsideMarketData>())
{
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<string>(catalog.GetAll<Faction>().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:<output key>; 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<string> Sells, HashSet<string> Buys) Gate(CultCache catalog)
{
var rows = catalog.GetAll<OutsideMarketData>().SelectMany(market => market.Rows).ToList();
var rows = catalog.GetAll<OutsideMarketData>().SelectMany(market => market.Rows ?? new List<OutsideMarketRow>()).Where(r => r != null).ToList();
return (new HashSet<string>(rows.Where(r => r.Sells).Select(r => r.Commodity.Key.Value), StringComparer.Ordinal),
new HashSet<string>(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<EconomyFault>();
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<int> 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<int> 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<int>();
var radii = new List<(double, List<int>)>();
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<int> nodes)

View file

@ -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<EconomyAuthor> _author;
public Lambda(Action<EconomyAuthor> 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<EconomyAuthor>[] 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<StationKindData>? 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<string>()).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<SimpleCommodityData>("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>(RecipeData.KeyOf(new CultRecordKey("commodity:steel"))));
Assert.Empty(EconomyChecks.Check(after).Faults);
}
[Fact]
public void ApplyConvergesAndTouchesOnlyWhatItNames()
{
using var f = EconomyFixture.Healthy();
Action<EconomyAuthor> 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>(RecipeData.KeyOf(new CultRecordKey("commodity:coal"))).LoreHook);
Assert.Equal("a good", after.Get<RecipeData>(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>(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>(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<StationKindStandIn> { new StationKindStandIn { Faction = f.Maker, Kind = f.Mill } } }, forge);
Good(f, a, "slab", kind: new CultRecordRef<StationKindData>(forge), fills: new[] { "Plate" });
});
Assert.Equal(0, code);
using var after = Read(f);
Assert.Single(after.Get<StationKindData>(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<StationKindStandIn> { new StationKindStandIn { Faction = f.Maker, Kind = new CultRecordRef<StationKindData>(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<SimpleCommodityData>("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<SimpleCommodityData>("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<SimpleCommodityData>("ore", ore => { ore.Description = "grey"; ore.Price = 7; ore.Mass = 2; }));
Assert.Equal(0, code);
using var after = Read(f);
var ore = after.Get<SimpleCommodityData>(new CultRecordKey("commodity:ore"));
Assert.Equal(("grey", 7, 2f), (ore.Description, ore.Price, ore.Mass));
Assert.Null(after.Get<CompoundCommodityData>(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<RecipeData>(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>(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<OutsideMarketRow> { 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<RecipeData>(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<EconomyAuthor> 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<CommodityCount>() }, 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>(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));
}
}

View file

@ -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"));
}
}

View file

@ -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<string> _touched = new List<string>();
public EconomyAuthor(CultCache catalog) => _catalog = catalog;
// The keys this author wrote, in order, once each.
public IReadOnlyList<string> 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>(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<T>(string name, Action<T> edit) where T : ItemData
{
var named = _catalog.GetAll<T>().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 <batch>... [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 <batch>... [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<RecipeData>().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<CommodityCount>()).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<EconomyBatch> 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<string, EconomyFault> held;
HashSet<string> recipesHeld;
Dictionary<string, byte[]> 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<string, EconomyFault>(StringComparer.Ordinal);
foreach (var fault in EconomyChecks.Check(cache).Faults) held[Identity(fault)] = fault;
recipesHeld = new HashSet<string>(cache.GetAll<RecipeData>().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<string>();
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<string, string>(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<string, EconomyFault> held, HashSet<string> 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<string, byte[]> 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")}");
}
}

View file

@ -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 <name> <hull product> [<x>,<y>,<rotation>,<design>[@<product>]]... [--group <i>,<j>...]... [--replace] [--boss-of <faction>], save, settings, settings-dump, dangling [clear <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective <fraction>] [--end <multiplier>], 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 \"<name>\" <kind>] [apply]");
Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], loadout-commit <name> <hull product> [<x>,<y>,<rotation>,<design>[@<product>]]... [--group <i>,<j>...]... [--replace] [--boss-of <faction>], save, settings, settings-dump, dangling [clear <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective <fraction>] [--end <multiplier>], 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 \"<name>\" <kind>] [apply], economy report|author <batch>... [apply]");
return 1;
}
}