From 7df0a0943f02e6baca51d5961d28cc4fef400d21 Mon Sep 17 00:00:00 2001 From: soul Date: Tue, 27 Oct 2026 04:38:05 +0100 Subject: [PATCH 1/7] Economy checks: consumer demand derived from shared uses; a weapon's draw is a sink ConsumerDemand drops the two-or-three-step window (rulings consumer-demand-shared-only, consumer-demand-skips-final-assemblies): a processed good owes a consumer use when it is shared, derived from the recipe graph each check. A weapon's draw of a round is one use (ruling draw-is-one-use). SourceSink counts a drawn commodity as sunk. Drawn is the one reader of offered designs' WeaponData.AmmoType. Tests are unrun at this commit. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/EconomyChecks.cs | 72 +++++--- .../EconomyChecksFurtherTests.cs | 4 + .../EconomyChecksTests.cs | 171 ++++++++++++++---- 3 files changed, 189 insertions(+), 58 deletions(-) diff --git a/Assets/Scripts/ServerShared/EconomyChecks.cs b/Assets/Scripts/ServerShared/EconomyChecks.cs index 3c8121b72..9a63d1688 100644 --- a/Assets/Scripts/ServerShared/EconomyChecks.cs +++ b/Assets/Scripts/ServerShared/EconomyChecks.cs @@ -170,12 +170,21 @@ public static class EconomyChecks new HashSet(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 Drawn(CultCache catalog) => + new HashSet(catalog.GetAll().Where(p => LoadoutGenerator.IsOffered(catalog, p)) + .Select(p => catalog.Get(p.Design) as EquippableItemData).Where(design => design?.Behaviors != null) + .SelectMany(design => design.Behaviors.OfType()).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 SourceSink(CultCache catalog) { var faults = new List(); var (sells, buys) = Gate(catalog); + var drawn = Drawn(catalog); var grounded = Depths(catalog, sells); var recipes = AllRecipes(catalog).ToList(); var used = new HashSet(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, 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 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 IsCommodityKey(string key) => recipes.TryGetValue(key, out var r) && Recipes.IsCommodity(catalog.Get(r.Output)); var consumerRoles = new HashSet(recipes.Values.Where(r => Recipes.IsConsumerGood(catalog, r)) .SelectMany(r => Recipes.RolesOf(catalog.Get(r.Output))).Select(role => role.Name), StringComparer.Ordinal); - HashSet Below(IEnumerable keys) + var gearRoles = new HashSet(recipes.Values.Where(r => !Recipes.IsCommodity(catalog.Get(r.Output)) && Recipes.IsRoleRecipe(catalog, r)) + .SelectMany(r => Recipes.RolesOf(catalog.Get(r.Output))).Select(role => role.Name), StringComparer.Ordinal); + var upkeep = new HashSet(recipes.Values.SelectMany(r => r.Upkeep).Select(c => c.Commodity.Key.Value), StringComparer.Ordinal); + var takenBy = recipes.Values.Where(r => IsCommodityKey(r.Output.Key.Value)) + .SelectMany(r => r.Inputs.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(); + foreach (var key in recipes.Keys.OrderBy(k => k, StringComparer.Ordinal)) { - var next = new HashSet(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 (!IsCommodityKey(key) || Recipes.IsRaw(catalog, recipe) || Recipes.IsConsumerGood(catalog, recipe)) continue; + var reached = new HashSet(StringComparer.Ordinal) { key }; + var open = new Stack(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(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; } } diff --git a/tests/Aetheria.Shared.Tests/EconomyChecksFurtherTests.cs b/tests/Aetheria.Shared.Tests/EconomyChecksFurtherTests.cs index 151b47b88..e5b029010 100644 --- a/tests/Aetheria.Shared.Tests/EconomyChecksFurtherTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyChecksFurtherTests.cs @@ -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); diff --git a/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs b/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs index 9d9f92f22..4878d1f2a 100644 --- a/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs @@ -304,47 +304,156 @@ 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(key, g => g.Behaviors.Add(new InstantWeaponData + { + AmmoType = draws == null ? default : new CultRecordRef(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("plate", inputs: new[] { ("ore", 1) }, fills: new[] { "Plate" }, upkeep: new[] { ("coke", 1) }); + f.Hull("tug", HullType.Ship, new[] { ("Plate", 1) }); + var fault = Assert.Single(EconomyChecks.ConsumerDemand(f.Cache)); + Assert.Equal("recipe:commodity:coke", fault.Record); + Assert.Contains("upkeep", fault.Message); + + f.Good("pot", roles: new[] { ("Casing", 1) }); + f.EditRecipe("commodity:coke", r => r.Fills.Add("Casing")); + Assert.Empty(EconomyChecks.ConsumerDemand(f.Cache)); + } + + [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("gear:rifle", g => g.Behaviors.Clear()); + Assert.Empty(EconomyChecks.ConsumerDemand(f.Cache)); + } + + [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] From 26bea2cb14c539a5d7ecb9410334fc27191ae6b3 Mon Sep 17 00:00:00 2001 From: soul Date: Tue, 27 Oct 2026 04:38:08 +0100 Subject: [PATCH 2/7] AetherDb economy: unknown batch names are not echoed; held-cycle rise is relative Run takes the output writer like Report and Author do. The unknown-batch refusal says how many names matched no batch and lists the compiled batches. Introduced compares a held cycle's radius relatively with RiseNoise = 1e-12, replacing the bare absolute 1e-9. Co-Authored-By: Claude Opus 5.5 --- .../EconomyCommandTests.cs | 38 ++++++++++++++++++- tools/AetherDb/EconomyCommand.cs | 15 +++++--- tools/AetherDb/Program.cs | 2 +- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs index 7fc19f641..f76b56aeb 100644 --- a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs @@ -263,7 +263,23 @@ 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 AnUnknownBatchNameIsRefusedWithoutEchoingIt() + { + var output = new StringWriter(); + Assert.Equal(1, EconomyCommand.Run(new[] { "author", "CANARY-9f2e-batch" }, output)); + var text = output.ToString(); + Assert.Contains("Batches:", text); + Assert.DoesNotContain("CANARY", text); + Assert.DoesNotContain("9f2e", text); + } private static CultRecordKey K(string id) => new CultRecordKey(EconomyFixture.Key(id)); @@ -448,6 +464,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(key); + recipe.OutputCount = 1000000000; + recipe.Inputs = new List { 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() { diff --git a/tools/AetherDb/EconomyCommand.cs b/tools/AetherDb/EconomyCommand.cs index 6981cc2e3..d0dc1b725 100644 --- a/tools/AetherDb/EconomyCommand.cs +++ b/tools/AetherDb/EconomyCommand.cs @@ -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 ... [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); diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 665ce766b..99963e8dd 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -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: From 8d23a378d68b375b3d28ab5d06a2edddd7da8904 Mon Sep 17 00:00:00 2001 From: soul Date: Tue, 27 Oct 2026 09:19:00 +0100 Subject: [PATCH 3/7] Economy checks: gearRoles reads roles of non-commodity recipes; pin use order and batch list Mutation testing found IsRoleRecipe redundant in the gear-role set (RolesOf is empty for a design without roles) and the message's use order, join and the unknown-batch count unpinned. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/EconomyChecks.cs | 2 +- .../EconomyChecksTests.cs | 18 +++++++++++++++++- .../EconomyCommandTests.cs | 6 ++++-- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/ServerShared/EconomyChecks.cs b/Assets/Scripts/ServerShared/EconomyChecks.cs index 9a63d1688..274e9990b 100644 --- a/Assets/Scripts/ServerShared/EconomyChecks.cs +++ b/Assets/Scripts/ServerShared/EconomyChecks.cs @@ -420,7 +420,7 @@ public static class EconomyChecks bool IsCommodityKey(string key) => recipes.TryGetValue(key, out var r) && Recipes.IsCommodity(catalog.Get(r.Output)); var consumerRoles = new HashSet(recipes.Values.Where(r => Recipes.IsConsumerGood(catalog, r)) .SelectMany(r => Recipes.RolesOf(catalog.Get(r.Output))).Select(role => role.Name), StringComparer.Ordinal); - var gearRoles = new HashSet(recipes.Values.Where(r => !Recipes.IsCommodity(catalog.Get(r.Output)) && Recipes.IsRoleRecipe(catalog, r)) + var gearRoles = new HashSet(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(recipes.Values.SelectMany(r => r.Upkeep).Select(c => c.Commodity.Key.Value), StringComparer.Ordinal); var takenBy = recipes.Values.Where(r => IsCommodityKey(r.Output.Key.Value)) diff --git a/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs b/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs index 4878d1f2a..d3bf3d591 100644 --- a/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs @@ -381,7 +381,7 @@ public sealed class EconomyChecksTests f.Hull("tug", HullType.Ship, new[] { ("Plate", 1) }); var fault = Assert.Single(EconomyChecks.ConsumerDemand(f.Cache)); Assert.Equal("recipe:commodity:coke", fault.Record); - Assert.Contains("upkeep", fault.Message); + Assert.Contains("'coke' leads into upkeep but no use leads into a consumer good.", fault.Message); f.Good("pot", roles: new[] { ("Casing", 1) }); f.EditRecipe("commodity:coke", r => r.Fills.Add("Casing")); @@ -412,6 +412,22 @@ public sealed class EconomyChecksTests 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() { diff --git a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs index f76b56aeb..0a3e8a75a 100644 --- a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs @@ -274,9 +274,11 @@ public sealed class EconomyCommandTests public void AnUnknownBatchNameIsRefusedWithoutEchoingIt() { var output = new StringWriter(); - Assert.Equal(1, EconomyCommand.Run(new[] { "author", "CANARY-9f2e-batch" }, output)); + Assert.Equal(1, EconomyCommand.Run(new[] { "author", "CANARY-9f2e-batch", "other-canary" }, output)); var text = output.ToString(); - Assert.Contains("Batches:", text); + 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); } From 8e6fd43d23365da301946d70834be11f0b95c660 Mon Sep 17 00:00:00 2001 From: soul Date: Tue, 27 Oct 2026 09:32:13 +0100 Subject: [PATCH 4/7] Economy checks: ConsumerDemand tests recipes, not keys; pin upkeep reached through a chain Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/EconomyChecks.cs | 6 +++--- tests/Aetheria.Shared.Tests/EconomyChecksTests.cs | 12 +++++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/Assets/Scripts/ServerShared/EconomyChecks.cs b/Assets/Scripts/ServerShared/EconomyChecks.cs index 274e9990b..20bfdefa4 100644 --- a/Assets/Scripts/ServerShared/EconomyChecks.cs +++ b/Assets/Scripts/ServerShared/EconomyChecks.cs @@ -417,20 +417,20 @@ public static class EconomyChecks { 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 IsCommodityKey(string key) => recipes.TryGetValue(key, out var r) && Recipes.IsCommodity(catalog.Get(r.Output)); + bool IsCommodityRecipe(RecipeData r) => Recipes.IsCommodity(catalog.Get(r.Output)); var consumerRoles = new HashSet(recipes.Values.Where(r => Recipes.IsConsumerGood(catalog, r)) .SelectMany(r => Recipes.RolesOf(catalog.Get(r.Output))).Select(role => role.Name), StringComparer.Ordinal); var gearRoles = new HashSet(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(recipes.Values.SelectMany(r => r.Upkeep).Select(c => c.Commodity.Key.Value), StringComparer.Ordinal); - var takenBy = recipes.Values.Where(r => IsCommodityKey(r.Output.Key.Value)) + var takenBy = recipes.Values.Where(IsCommodityRecipe) .SelectMany(r => r.Inputs.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(); foreach (var key in recipes.Keys.OrderBy(k => k, StringComparer.Ordinal)) { var recipe = recipes[key]; - if (!IsCommodityKey(key) || Recipes.IsRaw(catalog, recipe) || Recipes.IsConsumerGood(catalog, recipe)) continue; + if (!IsCommodityRecipe(recipe) || Recipes.IsRaw(catalog, recipe) || Recipes.IsConsumerGood(catalog, recipe)) continue; var reached = new HashSet(StringComparer.Ordinal) { key }; var open = new Stack(reached); while (open.Count > 0) diff --git a/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs b/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs index d3bf3d591..a0ae40c86 100644 --- a/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs @@ -377,14 +377,16 @@ public sealed class EconomyChecksTests using var f = new EconomyFixture(); f.Good("ore"); f.Good("coke", inputs: new[] { ("ore", 1) }); - f.Good("plate", inputs: new[] { ("ore", 1) }, fills: new[] { "Plate" }, upkeep: new[] { ("coke", 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) }); - var fault = Assert.Single(EconomyChecks.ConsumerDemand(f.Cache)); - Assert.Equal("recipe:commodity:coke", fault.Record); - Assert.Contains("'coke' leads into upkeep but no use leads into a consumer good.", fault.Message); + // 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 upkeep but no use leads into a consumer good.", faults[1].Message); f.Good("pot", roles: new[] { ("Casing", 1) }); - f.EditRecipe("commodity:coke", r => r.Fills.Add("Casing")); + f.EditRecipe("commodity:fuel", r => r.Fills.Add("Casing")); Assert.Empty(EconomyChecks.ConsumerDemand(f.Cache)); } From da067a34a28b2aefe8b3adbe2f4bddb6f8957045 Mon Sep 17 00:00:00 2001 From: soul Date: Tue, 27 Oct 2026 10:06:15 +0100 Subject: [PATCH 5/7] Economy checks: the consumer walk follows upkeep edges; pin upkeep into a consumer good and a role filler Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/EconomyChecks.cs | 4 ++-- .../EconomyChecksTests.cs | 21 ++++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/EconomyChecks.cs b/Assets/Scripts/ServerShared/EconomyChecks.cs index 20bfdefa4..4e51bfd4a 100644 --- a/Assets/Scripts/ServerShared/EconomyChecks.cs +++ b/Assets/Scripts/ServerShared/EconomyChecks.cs @@ -411,7 +411,7 @@ public static class EconomyChecks // 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, into a consumer good or a role one names. A good whose every use is one gear role is exempt like its final assembly. + // 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 ConsumerDemand(CultCache catalog) { @@ -424,7 +424,7 @@ public static class EconomyChecks .SelectMany(r => Recipes.RolesOf(catalog.Get(r.Output))).Select(role => role.Name), StringComparer.Ordinal); var upkeep = new HashSet(recipes.Values.SelectMany(r => r.Upkeep).Select(c => c.Commodity.Key.Value), StringComparer.Ordinal); var takenBy = recipes.Values.Where(IsCommodityRecipe) - .SelectMany(r => r.Inputs.Select(input => (Input: input.Commodity.Key.Value, Output: r.Output.Key.Value))) + .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(); foreach (var key in recipes.Keys.OrderBy(k => k, StringComparer.Ordinal)) diff --git a/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs b/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs index a0ae40c86..539832ba7 100644 --- a/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs @@ -383,13 +383,32 @@ public sealed class EconomyChecksTests // 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 upkeep but no use leads into a consumer good.", faults[1].Message); + 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() + { + using var f = new EconomyFixture(); + f.Good("ore"); + f.Good("coolant", inputs: new[] { ("ore", 1) }); + Assert.Equal(new[] { "recipe:commodity:coolant" }, DemandRecords(f)); + + // The refrigerator is a consumer good (a commodity made from roles) that takes coolant as upkeep: coolant leads into it. + f.Good("fridge", roles: new[] { ("Casing", 1) }, upkeep: new[] { ("coolant", 1) }); + Assert.Empty(DemandRecords(f)); + + // Upkeep taken by a filler of a consumer role leads into that role the same way. + f.EditRecipe("commodity:fridge", r => r.Upkeep.Clear()); + Assert.Equal(new[] { "recipe:commodity:coolant" }, DemandRecords(f)); + f.Good("gasket", inputs: new[] { ("ore", 1) }, fills: new[] { "Casing" }, upkeep: new[] { ("coolant", 1) }); + Assert.Empty(DemandRecords(f)); + } + [Fact] public void ConsumerDemandCountsADrawnRoundAsOneUse() { From 3c3ecd40dab30025532c31d73aca1ad6c001f9d9 Mon Sep 17 00:00:00 2001 From: soul Date: Tue, 27 Oct 2026 10:06:17 +0100 Subject: [PATCH 6/7] AetherDb economy: pin Run's report and author dry-run branches on the shipped catalog Co-Authored-By: Claude Opus 5.5 --- .../EconomyCommandTests.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs index 0a3e8a75a..dbd0d0f6f 100644 --- a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs @@ -270,6 +270,46 @@ public sealed class EconomyCommandTests 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() { From dc4838bd9ef4247c592b190b486e1c75081b068d Mon Sep 17 00:00:00 2001 From: soul Date: Tue, 27 Oct 2026 10:07:40 +0100 Subject: [PATCH 7/7] Economy checks: upkeep walk test gets a control case and a baseline that is actually shared Co-Authored-By: Claude Opus 5.5 --- .../EconomyChecksTests.cs | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs b/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs index 539832ba7..40c533bb9 100644 --- a/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs @@ -393,20 +393,28 @@ public sealed class EconomyChecksTests [Fact] public void ConsumerDemandFollowsUpkeepIntoAConsumerGoodAndIntoAFillerOfItsRole() { - using var f = new EconomyFixture(); - f.Good("ore"); - f.Good("coolant", inputs: new[] { ("ore", 1) }); - Assert.Equal(new[] { "recipe:commodity:coolant" }, DemandRecords(f)); + // 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. - f.Good("fridge", roles: new[] { ("Casing", 1) }, upkeep: new[] { ("coolant", 1) }); - Assert.Empty(DemandRecords(f)); + 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. - f.EditRecipe("commodity:fridge", r => r.Upkeep.Clear()); - Assert.Equal(new[] { "recipe:commodity:coolant" }, DemandRecords(f)); - f.Good("gasket", inputs: new[] { ("ore", 1) }, fills: new[] { "Casing" }, upkeep: new[] { ("coolant", 1) }); - Assert.Empty(DemandRecords(f)); + 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]