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:
commit
bd7cf4b5d4
5 changed files with 953 additions and 19 deletions
|
|
@ -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)
|
||||
|
|
|
|||
573
tests/Aetheria.Shared.Tests/EconomyCommandTests.cs
Normal file
573
tests/Aetheria.Shared.Tests/EconomyCommandTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
53
tests/Aetheria.Shared.Tests/EconomyNullGuardTests.cs
Normal file
53
tests/Aetheria.Shared.Tests/EconomyNullGuardTests.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
267
tools/AetherDb/EconomyCommand.cs
Normal file
267
tools/AetherDb/EconomyCommand.cs
Normal 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")}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue