Merge eureka/aetheria-living-world-economy-checks: consumer demand from shared uses, a weapon's draw is a sink, upkeep walked, economy command tail

Soul s1 (aetheria-living-world:verdict:cut-economy-checks.s1) holds; s2 holds the fixes except one Low (dry-run test cannot see apply), filed as a follow-up. Suite 2848/0 at dc4838bd9; shipped report 144 faults, consumer demand nitric-acid only. Unity compile not run (no Unity-side change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-27 10:29:05 +01:00
commit 1ed3579025
6 changed files with 312 additions and 55 deletions

View file

@ -170,12 +170,21 @@ public static class EconomyChecks
new HashSet<string>(rows.Where(r => r.Buys).Select(r => r.Commodity.Key.Value), StringComparer.Ordinal));
}
// The commodities an offered design's weapon draws as ammunition. One reader, shared by the sink and the consumer-demand rules.
private static HashSet<string> Drawn(CultCache catalog) =>
new HashSet<string>(catalog.GetAll<FactionProductData>().Where(p => LoadoutGenerator.IsOffered(catalog, p))
.Select(p => catalog.Get(p.Design) as EquippableItemData).Where(design => design?.Behaviors != null)
.SelectMany(design => design.Behaviors.OfType<WeaponData>()).Where(weapon => weapon.AmmoType.IsSet())
.Select(weapon => weapon.AmmoType.Key.Value), StringComparer.Ordinal);
// Every commodity has a source (a grounded chain, or a gate that sells it) and a sink (a recipe uses it, a role some design
// names, or a gate that buys it). A consumer good's sink is judged once needs tables exist.
// names, a weapon some offered design carries draws it, or a gate that buys it). A consumer good's sink is judged once needs
// tables exist.
public static List<EconomyFault> SourceSink(CultCache catalog)
{
var faults = new List<EconomyFault>();
var (sells, buys) = Gate(catalog);
var drawn = Drawn(catalog);
var grounded = Depths(catalog, sells);
var recipes = AllRecipes(catalog).ToList();
var used = new HashSet<string>(recipes.SelectMany(p => p.Recipe.Inputs.Concat(p.Recipe.Upkeep)).Select(c => c.Commodity.Key.Value), StringComparer.Ordinal);
@ -189,8 +198,8 @@ public static class EconomyChecks
if (!grounded.TryGetValue(outputKey, out var depth) || depth == null)
faults.Add(new EconomyFault(LSource, key, $"'{output.Name}' has no source: no chain of recipes reaches raw goods and no gate sells it."));
if (Recipes.IsConsumerGood(catalog, recipe)) continue;
if (!used.Contains(outputKey) && !recipe.Fills.Any(rolesNamed.Contains) && !buys.Contains(outputKey))
faults.Add(new EconomyFault(LSource, key, $"'{output.Name}' has no sink: no recipe uses it, no design names a role it fills, no gate buys it."));
if (!used.Contains(outputKey) && !recipe.Fills.Any(rolesNamed.Contains) && !drawn.Contains(outputKey) && !buys.Contains(outputKey))
faults.Add(new EconomyFault(LSource, key, $"'{output.Name}' has no sink: no recipe uses it, no design names a role it fills, no weapon draws it, no gate buys it."));
}
return faults;
}
@ -399,36 +408,45 @@ public static class EconomyChecks
return order;
}
// Every processed commodity two or three steps below a gear or hull (its role fillers are one step, the gear-specific final
// assemblies, exempt) fills a role some consumer good names, so the shared layer is shared. Raw goods and the gate do not count.
// A processed good owes a consumer use when it is shared (rulings consumer-demand-shared-only, draw-is-one-use). Its uses are
// the gear or hull roles, and the rounds some weapon draws, that the goods downstream of it (itself included) fill or are; a
// good used as upkeep is shared too. Shared means two or more such uses, or upkeep. A shared good must lead, through the
// chain (an input edge or an upkeep edge), into a consumer good or a role one names. A good whose every use is one gear role is exempt like its final assembly.
// Derived from the recipe graph on every check; depth and distance play no part. Raw goods and the gate count for nothing.
public static List<EconomyFault> ConsumerDemand(CultCache catalog)
{
var recipes = AllRecipes(catalog).Where(p => catalog.Get(p.Recipe.Output) != null).ToDictionary(p => p.Recipe.Output.Key.Value, p => p.Recipe, StringComparer.Ordinal);
var drawn = Drawn(catalog);
bool IsCommodityRecipe(RecipeData r) => Recipes.IsCommodity(catalog.Get(r.Output));
var consumerRoles = new HashSet<string>(recipes.Values.Where(r => Recipes.IsConsumerGood(catalog, r))
.SelectMany(r => Recipes.RolesOf(catalog.Get(r.Output))).Select(role => role.Name), StringComparer.Ordinal);
HashSet<string> Below(IEnumerable<string> keys)
var gearRoles = new HashSet<string>(recipes.Values.Where(r => !Recipes.IsCommodity(catalog.Get(r.Output)))
.SelectMany(r => Recipes.RolesOf(catalog.Get(r.Output))).Select(role => role.Name), StringComparer.Ordinal);
var upkeep = new HashSet<string>(recipes.Values.SelectMany(r => r.Upkeep).Select(c => c.Commodity.Key.Value), StringComparer.Ordinal);
var takenBy = recipes.Values.Where(IsCommodityRecipe)
.SelectMany(r => r.Inputs.Concat(r.Upkeep).Select(input => (Input: input.Commodity.Key.Value, Output: r.Output.Key.Value)))
.ToLookup(edge => edge.Input, edge => edge.Output, StringComparer.Ordinal);
var faults = new List<EconomyFault>();
foreach (var key in recipes.Keys.OrderBy(k => k, StringComparer.Ordinal))
{
var next = new HashSet<string>(StringComparer.Ordinal);
foreach (var key in keys)
{
if (!recipes.TryGetValue(key, out var recipe)) continue;
foreach (var input in recipe.Inputs) next.Add(input.Commodity.Key.Value);
foreach (var role in Recipes.RolesOf(catalog.Get(recipe.Output)))
foreach (var filler in Recipes.Fillers(catalog, role.Name)) next.Add(filler.Value);
}
return next;
var recipe = recipes[key];
if (!IsCommodityRecipe(recipe) || Recipes.IsRaw(catalog, recipe) || Recipes.IsConsumerGood(catalog, recipe)) continue;
var reached = new HashSet<string>(StringComparer.Ordinal) { key };
var open = new Stack<string>(reached);
while (open.Count > 0)
foreach (var next in takenBy[open.Pop()])
if (reached.Add(next)) open.Push(next);
var members = reached.Select(k => recipes[k]).ToList();
if (members.Any(r => Recipes.IsConsumerGood(catalog, r) || r.Fills.Any(consumerRoles.Contains))) continue;
var uses = members.SelectMany(r => r.Fills).Where(gearRoles.Contains).Distinct(StringComparer.Ordinal).OrderBy(role => role, StringComparer.Ordinal)
.Concat(reached.Where(drawn.Contains).OrderBy(k => k, StringComparer.Ordinal).Select(k => catalog.Get(recipes[k].Output).Name)).ToList();
var asUpkeep = reached.Any(upkeep.Contains);
if (asUpkeep) uses.Add("upkeep");
if (uses.Count < 2 && !asUpkeep) continue;
var list = uses.Count == 1 ? uses[0] : string.Join(", ", uses.Take(uses.Count - 1)) + " and " + uses[uses.Count - 1];
faults.Add(new EconomyFault(LDemand, RecipeData.KeyOf(new CultRecordKey(key)).Value,
$"'{catalog.Get(recipe.Output).Name}' leads into {list} but no use leads into a consumer good."));
}
var deep = new HashSet<string>(StringComparer.Ordinal);
foreach (var key in recipes.Where(p => !Recipes.IsCommodity(catalog.Get(p.Value.Output)) && Recipes.IsRoleRecipe(catalog, p.Value)).Select(p => p.Key))
{
var distance2 = Below(Below(new[] { key }));
deep.UnionWith(distance2);
deep.UnionWith(Below(distance2));
}
return deep.OrderBy(key => key, StringComparer.Ordinal)
.Where(key => recipes.TryGetValue(key, out var recipe) && Recipes.IsCommodity(catalog.Get(recipe.Output)) && !Recipes.IsRaw(catalog, recipe) && !recipe.Fills.Any(consumerRoles.Contains))
.Select(key => new EconomyFault(LDemand, RecipeData.KeyOf(new CultRecordKey(key)).Value,
$"'{catalog.Get(recipes[key].Output).Name}' is a processed good below a gear or hull that fills no role a consumer good names."))
.ToList();
return faults;
}
}

View file

@ -24,7 +24,9 @@ public sealed class EconomyChecksFurtherTests
f.Good("ingot", inputs: new[] { ("slag", 1) });
f.Good("steel", inputs: new[] { ("ingot", 1) });
f.Good("barrel", inputs: new[] { ("steel", 1) }, fills: new[] { "Barrel" });
f.Good("plate", inputs: new[] { ("steel", 1) }, fills: new[] { "Plate" });
f.Gear("cannon", new[] { ("Barrel", 1) });
f.Hull("tug", HullType.Ship, new[] { ("Plate", 1) });
f.Gear("bare", new[] { ("Barrel", 1) }, recipe: false);
f.Good("tar", lore: " ");
f.Good("waste");
@ -93,7 +95,9 @@ public sealed class EconomyChecksFurtherTests
f.Good("ore");
f.Good("steel", inputs: new[] { ("ore", 1), ("ghost", 1) });
f.Good("barrel", inputs: new[] { ("steel", 1), ("ghost", 1) }, fills: new[] { "Barrel" });
f.Good("plate", inputs: new[] { ("steel", 1) }, fills: new[] { "Plate" });
f.Gear("cannon", new[] { ("Barrel", 1) });
f.Hull("tug", HullType.Ship, new[] { ("Plate", 1) });
var faults = EconomyChecks.ConsumerDemand(f.Cache);
Assert.Equal(new[] { "recipe:commodity:steel" }, faults.Select(x => x.Record));
Assert.Contains("steel", faults[0].Message);

View file

@ -304,47 +304,201 @@ public sealed class EconomyChecksTests
Assert.Empty(report.Faults);
}
private static EconomyFixture Cannon()
private static string[] DemandRecords(EconomyFixture f) => EconomyChecks.ConsumerDemand(f.Cache).Select(x => x.Record).ToArray();
// A gear whose weapon draws the given commodity as ammunition (nothing when draws is null).
private static void Rifle(EconomyFixture f, string id, string draws, bool offered = true)
{
var f = new EconomyFixture();
f.Good("ore");
f.Good("slag", inputs: new[] { ("ore", 1) });
f.Good("ingot", inputs: new[] { ("slag", 1) });
f.Good("steel", inputs: new[] { ("ingot", 1) });
f.Good("barrel", inputs: new[] { ("steel", 1), ("ore", 1) }, fills: new[] { "Barrel" });
f.Gear("cannon", new[] { ("Barrel", 1) });
return f;
var key = f.Gear(id, new[] { ("Frame", 1) }, offered);
f.EditItem<GearData>(key, g => g.Behaviors.Add(new InstantWeaponData
{
AmmoType = draws == null ? default : new CultRecordRef<SimpleCommodityData>(new CultRecordKey(EconomyFixture.Key(draws)))
}));
}
[Fact]
public void ConsumerDemandCoversTheSharedLayer()
{
using var f = Cannon();
// barrel is distance 1 (exempt); steel is 2 and ingot 3 (faults); slag is 4 and ore is raw (neither is).
var faults = EconomyChecks.ConsumerDemand(f.Cache);
Assert.Equal(new[] { "recipe:commodity:ingot", "recipe:commodity:steel" }, faults.Select(x => x.Record));
Assert.All(faults, x => Assert.Equal(EconomyChecks.LDemand, x.Invariant));
f.Good("pot", roles: new[] { ("Casing", 1) });
f.EditRecipe("commodity:steel", r => r.Fills.Add("Casing"));
Assert.Equal(new[] { "recipe:commodity:ingot" }, EconomyChecks.ConsumerDemand(f.Cache).Select(x => x.Record));
f.EditRecipe("commodity:ingot", r => r.Fills.Add("Casing"));
Assert.Empty(EconomyChecks.ConsumerDemand(f.Cache));
f.Gate(("commodity:slag", true, false), ("commodity:steel", true, false));
f.EditRecipe("commodity:steel", r => r.Fills.Clear());
Assert.Equal(new[] { "recipe:commodity:steel" }, EconomyChecks.ConsumerDemand(f.Cache).Select(x => x.Record));
}
[Fact]
public void ConsumerDemandReadsHullsAsWellAsGear()
public void ConsumerDemandExemptsAGoodWhoseUsesLeadIntoOneGearRole()
{
using var f = new EconomyFixture();
f.Good("ore");
f.Good("ingot", inputs: new[] { ("ore", 1) });
f.Good("steel", inputs: new[] { ("ingot", 1) });
f.Good("barrel", inputs: new[] { ("steel", 1) }, fills: new[] { "Barrel" });
f.Gear("cannon", new[] { ("Barrel", 1) });
f.Gear("mortar", new[] { ("Barrel", 2) });
Assert.Empty(EconomyChecks.ConsumerDemand(f.Cache));
}
[Fact]
public void ConsumerDemandCoversAGoodThatLeadsIntoTwoGearOrHullRoles()
{
using var f = new EconomyFixture();
f.Good("ore");
f.Good("slag", inputs: new[] { ("ore", 1) });
f.Good("ingot", inputs: new[] { ("slag", 1) });
f.Good("barrel", inputs: new[] { ("ingot", 1) }, fills: new[] { "Barrel" });
f.Good("plate", inputs: new[] { ("ingot", 1) }, fills: new[] { "Plate" });
f.Gear("cannon", new[] { ("Barrel", 1) });
f.Hull("tug", HullType.Ship, new[] { ("Plate", 1) });
Assert.Equal(new[] { "recipe:commodity:ingot" }, EconomyChecks.ConsumerDemand(f.Cache).Select(x => x.Record));
var faults = EconomyChecks.ConsumerDemand(f.Cache);
Assert.Equal(new[] { "recipe:commodity:ingot", "recipe:commodity:slag" }, faults.Select(x => x.Record));
Assert.All(faults, x => Assert.Equal(EconomyChecks.LDemand, x.Invariant));
Assert.Contains("Barrel and Plate", faults[0].Message);
// A consumer good taking a role that a good downstream of ingot fills clears the chain behind it.
f.Good("pot", roles: new[] { ("Casing", 1) });
f.EditRecipe("commodity:barrel", r => r.Fills.Add("Casing"));
Assert.Empty(EconomyChecks.ConsumerDemand(f.Cache));
}
[Fact]
public void ConsumerDemandIgnoresTheStepWindow()
{
using var near = new EconomyFixture();
near.Good("ore");
near.Good("ingot", inputs: new[] { ("ore", 1) }, fills: new[] { "Barrel", "Plate" });
near.Gear("cannon", new[] { ("Barrel", 1) });
near.Hull("tug", HullType.Ship, new[] { ("Plate", 1) });
Assert.Equal(new[] { "recipe:commodity:ingot" }, DemandRecords(near));
using var far = new EconomyFixture();
far.Good("ore");
far.Good("c1", inputs: new[] { ("ore", 1) });
for (var i = 2; i <= 5; i++) far.Good("c" + i, inputs: new[] { ("c" + (i - 1), 1) });
far.Good("c6", inputs: new[] { ("c5", 1) }, fills: new[] { "Barrel", "Plate" });
far.Gear("cannon", new[] { ("Barrel", 1) });
far.Hull("tug", HullType.Ship, new[] { ("Plate", 1) });
Assert.Equal(new[] { "c1", "c2", "c3", "c4", "c5", "c6" }.Select(id => "recipe:commodity:" + id), DemandRecords(far));
}
[Fact]
public void ConsumerDemandCoversAGoodThatLeadsIntoUpkeep()
{
using var f = new EconomyFixture();
f.Good("ore");
f.Good("coke", inputs: new[] { ("ore", 1) });
f.Good("fuel", inputs: new[] { ("coke", 1) });
f.Good("plate", inputs: new[] { ("ore", 1) }, fills: new[] { "Plate" }, upkeep: new[] { ("fuel", 1) });
f.Hull("tug", HullType.Ship, new[] { ("Plate", 1) });
// Fuel is upkeep itself; coke is shared because a good it leads into is.
var faults = EconomyChecks.ConsumerDemand(f.Cache);
Assert.Equal(new[] { "recipe:commodity:coke", "recipe:commodity:fuel" }, faults.Select(x => x.Record));
Assert.Contains("'fuel' leads into Plate and upkeep but no use leads into a consumer good.", faults[1].Message);
f.Good("pot", roles: new[] { ("Casing", 1) });
f.EditRecipe("commodity:fuel", r => r.Fills.Add("Casing"));
Assert.Empty(EconomyChecks.ConsumerDemand(f.Cache));
}
[Fact]
public void ConsumerDemandFollowsUpkeepIntoAConsumerGoodAndIntoAFillerOfItsRole()
{
// Control: coolant is upkeep for a gasket that fills a plain gear role, so it is shared and leads into no consumer good.
using var plain = new EconomyFixture();
plain.Good("ore");
plain.Good("coolant", inputs: new[] { ("ore", 1) });
plain.Good("gasket", inputs: new[] { ("ore", 1) }, fills: new[] { "Plate" }, upkeep: new[] { ("coolant", 1) });
plain.Hull("tug", HullType.Ship, new[] { ("Plate", 1) });
Assert.Equal(new[] { "recipe:commodity:coolant" }, DemandRecords(plain));
// The refrigerator is a consumer good (a commodity made from roles) that takes coolant as upkeep: coolant leads into it.
using var consumer = new EconomyFixture();
consumer.Good("ore");
consumer.Good("coolant", inputs: new[] { ("ore", 1) });
consumer.Good("fridge", roles: new[] { ("Casing", 1) }, upkeep: new[] { ("coolant", 1) });
Assert.Empty(DemandRecords(consumer));
// Upkeep taken by a filler of a consumer role leads into that role the same way.
using var filler = new EconomyFixture();
filler.Good("ore");
filler.Good("coolant", inputs: new[] { ("ore", 1) });
filler.Good("gasket", inputs: new[] { ("ore", 1) }, fills: new[] { "Casing" }, upkeep: new[] { ("coolant", 1) });
filler.Good("fridge", roles: new[] { ("Casing", 1) });
Assert.Empty(DemandRecords(filler));
}
[Fact]
public void ConsumerDemandCountsADrawnRoundAsOneUse()
{
using var f = new EconomyFixture();
f.Good("ore");
f.Good("casing", inputs: new[] { ("ore", 1) });
f.Good("round", inputs: new[] { ("casing", 1) });
Rifle(f, "rifle", "round");
Assert.Empty(EconomyChecks.ConsumerDemand(f.Cache));
// Acid also feeds a warhead role: two uses, so it is shared; the casing still feeds only the round.
f.Good("acid", inputs: new[] { ("ore", 1) });
f.Good("warhead", inputs: new[] { ("acid", 1) }, fills: new[] { "Warhead" });
f.Gear("mine", new[] { ("Warhead", 1) });
f.EditRecipe("commodity:round", r => r.Inputs.Add(new CommodityCount { Commodity = EconomyFixture.Item(EconomyFixture.Key("acid")), Count = 1 }));
var fault = Assert.Single(EconomyChecks.ConsumerDemand(f.Cache));
Assert.Equal("recipe:commodity:acid", fault.Record);
Assert.Contains("Warhead and round", fault.Message);
// Without the weapon nothing is drawn, and acid has one use again.
f.EditItem<GearData>("gear:rifle", g => g.Behaviors.Clear());
Assert.Empty(EconomyChecks.ConsumerDemand(f.Cache));
}
[Fact]
public void ConsumerDemandNamesEveryUseThatMadeTheGoodShared()
{
using var f = new EconomyFixture();
f.Good("ore");
f.Good("acid", inputs: new[] { ("ore", 1) });
f.Good("warhead", inputs: new[] { ("acid", 1) }, fills: new[] { "Warhead" });
f.Good("roundb", inputs: new[] { ("acid", 1) });
f.Good("rounda", inputs: new[] { ("acid", 1) });
f.Gear("mine", new[] { ("Warhead", 1) });
Rifle(f, "riflea", "rounda");
Rifle(f, "rifleb", "roundb");
var fault = Assert.Single(EconomyChecks.ConsumerDemand(f.Cache));
Assert.Equal("'acid' leads into Warhead, rounda and roundb but no use leads into a consumer good.", fault.Message);
}
[Fact]
public void ConsumerDemandTreatsTheGateAsNoUse()
{
using var f = new EconomyFixture();
f.Good("ore");
f.Good("ingot", inputs: new[] { ("ore", 1) }, fills: new[] { "Barrel", "Plate" });
f.Good("slug", inputs: new[] { ("ore", 1) }, fills: new[] { "Slug" });
f.Gear("cannon", new[] { ("Barrel", 1) });
f.Gear("sling", new[] { ("Slug", 1) });
f.Hull("tug", HullType.Ship, new[] { ("Plate", 1) });
f.Gate(("commodity:ingot", true, true), ("commodity:slug", true, true));
Assert.Equal(new[] { "recipe:commodity:ingot" }, DemandRecords(f));
}
[Fact]
public void SourceSinkCountsAWeaponsDrawAsASink()
{
static EconomyFault NoSink(EconomyFixture f) =>
EconomyChecks.SourceSink(f.Cache).FirstOrDefault(x => x.Record == "recipe:commodity:round" && x.Message.Contains("no sink"));
static EconomyFixture Catalog(string draws, bool offered)
{
var f = new EconomyFixture();
f.Good("ore");
f.Good("round", inputs: new[] { ("ore", 1) });
Rifle(f, "rifle", draws, offered);
return f;
}
using (var drawn = Catalog("round", true)) Assert.Null(NoSink(drawn));
using (var unoffered = Catalog("round", false)) Assert.Contains("no weapon draws it", NoSink(unoffered).Message);
using (var unset = Catalog(null, true)) Assert.NotNull(NoSink(unset));
}
[Fact]
public void ProductiveMembersAreSortedWhileTheMessageKeepsCycleOrder()
{
using var f = new EconomyFixture();
f.Good("loopa", inputs: new[] { ("loopc", 1) });
f.Good("loopc", inputs: new[] { ("loopb", 1) });
f.Good("loopb", inputs: new[] { ("loopa", 2) });
var fault = Assert.Single(EconomyChecks.Productive(f.Cache));
Assert.Equal(new[] { "commodity:loopa", "commodity:loopb", "commodity:loopc" }, fault.Members);
Assert.Contains("commodity:loopa -> commodity:loopc -> commodity:loopb", fault.Message);
}
[Fact]

View file

@ -263,7 +263,65 @@ public sealed class EconomyCommandTests
}
[Fact]
public void AnUnknownSubcommandFails() => Assert.Equal(1, EconomyCommand.Run(new[] { "bogus" }));
public void AnUnknownSubcommandFails()
{
var output = new StringWriter();
Assert.Equal(1, EconomyCommand.Run(new[] { "bogus" }, output));
Assert.Contains("economy report | author", output.ToString());
}
[Fact]
public void ReportOnTheShippedCatalogGoesToTheGivenWriterAndNeverToConsole()
{
using var db = AetherDb.Open();
var expected = new StringWriter();
var expectedCode = EconomyCommand.Report(db.Cache, expected);
var console = new StringWriter();
var previous = Console.Out;
Console.SetOut(console);
var output = new StringWriter();
int code;
try { code = EconomyCommand.Run(new[] { "report" }, output); }
finally { Console.SetOut(previous); }
Assert.Equal(expectedCode, code);
Assert.NotEmpty(expected.ToString());
Assert.Equal(expected.ToString(), output.ToString());
Assert.Empty(console.ToString());
}
[Fact]
public void AnAuthorDryRunReadsTheShippedCatalogAndLeavesItByteIdentical()
{
var path = Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc");
var before = File.ReadAllBytes(path);
var expected = new StringWriter();
var expectedCode = EconomyCommand.Author(path, new EconomyBatch[] { new Munitions() }, false, expected);
var console = new StringWriter();
var previous = Console.Out;
Console.SetOut(console);
var output = new StringWriter();
int code;
try { code = EconomyCommand.Run(new[] { "author", "Munitions" }, output); }
finally { Console.SetOut(previous); }
Assert.Equal(expectedCode, code);
Assert.NotEmpty(expected.ToString());
Assert.Equal(expected.ToString(), output.ToString());
Assert.Empty(console.ToString());
Assert.Equal(before, File.ReadAllBytes(path));
}
[Fact]
public void AnUnknownBatchNameIsRefusedWithoutEchoingIt()
{
var output = new StringWriter();
Assert.Equal(1, EconomyCommand.Run(new[] { "author", "CANARY-9f2e-batch", "other-canary" }, output));
var text = output.ToString();
Assert.Contains("2 of the names given match no batch.", text);
foreach (var batch in typeof(EconomyBatch).Assembly.GetTypes().Where(t => !t.IsAbstract && t.IsSubclassOf(typeof(EconomyBatch))))
Assert.Contains(batch.Name, text);
Assert.DoesNotContain("CANARY", text);
Assert.DoesNotContain("9f2e", text);
}
private static CultRecordKey K(string id) => new CultRecordKey(EconomyFixture.Key(id));
@ -448,6 +506,26 @@ public sealed class EconomyCommandTests
Assert.Contains("Wrote 1 records", output);
}
[Fact]
public void AHeldCycleWhoseRadiusRisesByLessThanABillionthIsRefused()
{
using var f = HeldLoop();
Flush(f);
var bytes = File.ReadAllBytes(f.CatalogPath);
// loopb takes 2000000001 loopa per 1000000000 loopb: the cycle's radius rises from sqrt 2 by about 2.5e-10 relative.
var code = Author(f, true, out var output, a =>
{
var key = RecipeData.KeyOf(K("loopb"));
var recipe = a.Catalog.Get<RecipeData>(key);
recipe.OutputCount = 1000000000;
recipe.Inputs = new List<CommodityCount> { new CommodityCount { Commodity = EconomyFixture.Item(EconomyFixture.Key("loopa")), Count = 2000000001 } };
a.Put(recipe, key);
});
Assert.Equal(1, code);
Assert.Contains("L-productive", output);
Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath));
}
[Fact]
public void AHeldUnproductiveCatalogStillAppliesABatchThatLeavesTheCycleAlone()
{

View file

@ -61,12 +61,12 @@ public static class EconomyCommand
// 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)
public static int Run(string[] args, TextWriter output)
{
if (args.Length > 0 && args[0] == "report")
{
using var db = AetherDb.Open();
return Report(db.Cache, Console.Out);
return Report(db.Cache, output);
}
if (args.Length > 1 && args[0] == "author")
{
@ -77,13 +77,13 @@ public static class EconomyCommand
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))}");
output.WriteLine($"{unknown.Length} of the names given match no batch. 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);
return Author(Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), batches, apply, output);
}
Console.WriteLine(Usage);
output.WriteLine(Usage);
return 1;
}
@ -225,6 +225,9 @@ public static class EconomyCommand
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 held cycle's radius may rise by this fraction without being a worse fault: it is float noise from rebuilding the same graph.
private const double RiseNoise = 1e-12;
// 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) =>
@ -235,7 +238,7 @@ public static class EconomyCommand
// "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;
return fault.Members != null && fault.Radius > was.Radius * (1 + RiseNoise);
}
private static readonly MessagePackSerializerOptions Wire = CultDocumentMessagePackSerialization.OptionsFor(typeof(ItemData).Assembly);

View file

@ -76,7 +76,7 @@ public static class Program
if (apply) db.Cache.FlushAsync().GetAwaiter().GetResult();
return code;
}
case "economy": return EconomyCommand.Run(args.Skip(1).ToArray());
case "economy": return EconomyCommand.Run(args.Skip(1).ToArray(), Console.Out);
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: