From 3fb5e0e0e64afcc9f9d0c8a70b9a5203f2dc0f73 Mon Sep 17 00:00:00 2001 From: soul Date: Tue, 27 Oct 2026 10:56:01 +0100 Subject: [PATCH 1/4] Economy: RecipeData.RoleInputs, Recipes.RoleInputsOf as the one reader of role slots, checks read a role input as any filler and offered recipe-less designs as demanding their roles Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/EconomyChecks.cs | 71 ++++++++++++-------- Assets/Scripts/ServerShared/Recipes.cs | 23 +++++-- tools/AetherDb/EconomyCommand.cs | 6 +- 3 files changed, 63 insertions(+), 37 deletions(-) diff --git a/Assets/Scripts/ServerShared/EconomyChecks.cs b/Assets/Scripts/ServerShared/EconomyChecks.cs index 4e51bfd4a..75de68f57 100644 --- a/Assets/Scripts/ServerShared/EconomyChecks.cs +++ b/Assets/Scripts/ServerShared/EconomyChecks.cs @@ -79,7 +79,8 @@ public static class EconomyChecks 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.Inputs != null && recipe.Upkeep != null && recipe.Fills != null && recipe.RoleInputs != null + && !recipe.Inputs.Contains(null) && !recipe.Upkeep.Contains(null) && !recipe.Fills.Contains(null) && !recipe.RoleInputs.Contains(null) ? recipe : new RecipeData { @@ -87,9 +88,28 @@ public static class EconomyChecks StationKind = recipe.StationKind, LoreHook = recipe.LoreHook, Inputs = (recipe.Inputs ?? new List()).Where(c => c != null).ToList(), Upkeep = (recipe.Upkeep ?? new List()).Where(c => c != null).ToList(), - Fills = (recipe.Fills ?? new List()).Where(f => f != null).ToList() + Fills = (recipe.Fills ?? new List()).Where(f => f != null).ToList(), + RoleInputs = (recipe.RoleInputs ?? new List()).Where(r => r != null).ToList() }; + // The designs some faction offers, but a Station or Turret hull, distinct and ordered by key: the designs that need a recipe. + private static List OfferedDesigns(CultCache catalog) + { + var designs = new Dictionary(StringComparer.Ordinal); + foreach (var product in catalog.GetAll().Where(p => LoadoutGenerator.IsOffered(catalog, p))) + { + var design = catalog.Get(product.Design); + if (design == null || (design is HullData hull && (hull.HullType == HullType.Station || hull.HullType == HullType.Turret))) continue; + designs.TryAdd(catalog.RefOf(design).Key.Value, design); + } + return designs.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => pair.Value).ToList(); + } + + // The roles some design demands: those of every offered design and of every design a recipe makes, recipe or no. + private static HashSet DesignRoles(CultCache catalog) => + new HashSet(OfferedDesigns(catalog).Concat(AllRecipes(catalog).Select(p => catalog.Get(p.Recipe.Output)).Where(o => o != null && !Recipes.IsCommodity(o))) + .SelectMany(design => Recipes.RolesOf(design)).Select(role => role.Name), StringComparer.Ordinal); + private static IEnumerable Commodities(CultCache catalog) => catalog.GetAll().Where(Recipes.IsCommodity).OrderBy(item => catalog.RefOf(item).Key.Value, StringComparer.Ordinal); @@ -101,15 +121,9 @@ public static class EconomyChecks foreach (var item in Commodities(catalog)) if (Recipes.RecipeOf(catalog, item) == null) faults.Add(new EconomyFault(LRecipe, catalog.RefOf(item).Key.Value, $"Commodity '{item.Name}' has no recipe.", pending: true)); - var seen = new HashSet(StringComparer.Ordinal); - foreach (var product in catalog.GetAll().Where(p => LoadoutGenerator.IsOffered(catalog, p))) - { - var design = catalog.Get(product.Design); - 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) - faults.Add(new EconomyFault(LRecipe, key, $"Offered design '{design.Name}' has no recipe.", pending: true)); - } + foreach (var design in OfferedDesigns(catalog)) + if (Recipes.RecipeOf(catalog, design) == null) + faults.Add(new EconomyFault(LRecipe, catalog.RefOf(design).Key.Value, $"Offered design '{design.Name}' has no recipe.", pending: true)); foreach (var market in catalog.GetAll()) { var marketKey = catalog.RefOf(market).Key.Value; @@ -125,6 +139,8 @@ public static class EconomyChecks 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."); + if (stored.RoleInputs == null) Add(key, "RoleInputs is null; a recipe holds a list, empty when it takes no role."); + else if (stored.RoleInputs.Contains(null)) Add(key, "RoleInputs holds a null entry."); var output = catalog.Get(recipe.Output); if (output == null) { Add(key, "Output names no item this catalog holds."); continue; } if (key != RecipeData.KeyOf(recipe.Output.Key).Value) @@ -149,8 +165,10 @@ public static class EconomyChecks if (roles.Count == 0) Add(key, "A design's recipe needs a design with at least one role."); } if (roles.Count > 0 && recipe.Inputs.Count > 0) Add(key, "A role recipe has no Inputs; its inputs are its design's roles."); - foreach (var role in roles) + if (roles.Count > 0 && recipe.RoleInputs.Count > 0) Add(key, "A role recipe has no RoleInputs; its role inputs are its design's roles."); + foreach (var role in Recipes.RoleInputsOf(catalog, recipe)) { + if (string.IsNullOrWhiteSpace(role.Name)) { Add(key, "A role input has an empty name."); continue; } if (role.Count < 1) Add(key, $"Role '{role.Name}' count is below one."); if (Recipes.Fillers(catalog, role.Name).Length == 0) Add(key, $"Role '{role.Name}' has no commodity whose recipe fills it."); } @@ -177,8 +195,8 @@ public static class EconomyChecks .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, a weapon some offered design carries draws it, or a gate that buys it). A consumer good's sink is judged once needs + // Every commodity has a source (a grounded chain, or a gate that sells it) and a sink (a recipe takes it or a role it fills, a role + // some design (offered, or made by a recipe) 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) { @@ -188,8 +206,8 @@ public static class EconomyChecks 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); - var rolesNamed = new HashSet(recipes.Where(p => Recipes.IsRoleRecipe(catalog, p.Recipe)) - .SelectMany(p => Recipes.RolesOf(catalog.Get(p.Recipe.Output))).Select(r => r.Name), StringComparer.Ordinal); + var rolesNamed = DesignRoles(catalog); + rolesNamed.UnionWith(recipes.SelectMany(p => Recipes.RoleInputsOf(catalog, p.Recipe)).Select(r => r.Name)); foreach (var (key, recipe) in recipes) { var output = catalog.Get(recipe.Output); @@ -199,7 +217,7 @@ public static class EconomyChecks 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) && !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.")); + faults.Add(new EconomyFault(LSource, key, $"'{output.Name}' has no sink: no recipe takes it or a role it fills, no design names a role it fills, no weapon draws it, no gate buys it.")); } return faults; } @@ -243,7 +261,7 @@ public static class EconomyChecks if (!depth.TryGetValue(input.Commodity.Key.Value, out var d)) return null; deepest = Math.Max(deepest, d); } - foreach (var role in Recipes.RolesOf(catalog.Get(recipe.Output))) + foreach (var role in Recipes.RoleInputsOf(catalog, recipe)) { int? shallowest = null; foreach (var filler in Recipes.Fillers(catalog, role.Name)) @@ -254,7 +272,7 @@ public static class EconomyChecks return deepest + 1; } - // Hawkins-Simon over commodities plus one labour good, at the worst single filler of every role slot: the spectral radius + // Hawkins-Simon over commodities plus one labour good, at the worst single filler of every role slot (a recipe's role inputs): the spectral radius // of the requirements matrix stays below one however the roles are filled. The worst fill is the spectral simplex // (Nesterov and Protasov 2013) on the matrix nudged strictly positive, then the radius of the chosen matrix is taken exactly. public static List Productive(CultCache catalog, EconomyLoad load = null) @@ -278,7 +296,7 @@ public static class EconomyChecks foreach (var upkeep in recipe.Upkeep) if (index.TryGetValue(upkeep.Commodity.Key.Value, out var row) && upkeep.Count > 0) fixedTerms[row, j] += upkeep.Count * load.UpkeepFactor / recipe.OutputCount; fixedTerms[labour, j] = recipe.Workforce * (double) recipe.Cycle * load.WorkforceFactor / recipe.OutputCount; - foreach (var role in Recipes.RolesOf(catalog.Get(recipe.Output))) + foreach (var role in Recipes.RoleInputsOf(catalog, recipe)) { var fillers = Recipes.Fillers(catalog, role.Name).Where(k => index.ContainsKey(k.Value)).Select(k => index[k.Value]).ToArray(); if (fillers.Length > 0 && role.Count > 0) slots.Add((j, role.Count * load.InputFactor / recipe.OutputCount, fillers)); @@ -411,20 +429,19 @@ 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 (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. + // chain (an input, upkeep or role-input edge: a role input is an edge from each of its fillers), 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 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 gearRoles = DesignRoles(catalog); 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.Concat(r.Upkeep).Select(input => (Input: input.Commodity.Key.Value, Output: r.Output.Key.Value))) + .SelectMany(r => r.Inputs.Concat(r.Upkeep).Select(input => input.Commodity.Key.Value) + .Concat(Recipes.RoleInputsOf(catalog, r).SelectMany(role => Recipes.Fillers(catalog, role.Name)).Select(filler => filler.Value)) + .Select(input => (Input: input, 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)) @@ -437,7 +454,7 @@ public static class EconomyChecks 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; + if (members.Any(r => Recipes.IsConsumerGood(catalog, r))) 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); diff --git a/Assets/Scripts/ServerShared/Recipes.cs b/Assets/Scripts/ServerShared/Recipes.cs index 5a939b9ea..e434a84a5 100644 --- a/Assets/Scripts/ServerShared/Recipes.cs +++ b/Assets/Scripts/ServerShared/Recipes.cs @@ -6,9 +6,10 @@ using MessagePack; using Newtonsoft.Json; // How a good is made: one authored record per output, keyed recipe:, so a second recipe for one output replaces the -// first. A commodity's recipe says what it takes and which roles it can fill. A role recipe (the output is a design with roles, -// a ship hull, a piece of gear, a consumer good) restates nothing of the design: its inputs are derived from the design's Roles and -// their counts, each filled by any commodity whose recipe Fills the role. Station and Turret hulls take no recipe. +// first. A commodity's recipe takes named commodities (Inputs) and roles (RoleInputs), each role filled by any commodity whose +// recipe Fills it, and says which roles it can fill. A role recipe (the output is a design with roles, a ship hull, a piece of +// gear, a consumer good) restates nothing of the design: its role inputs are the design's Roles and their counts. Station and +// Turret hulls take no recipe. [CultDocument("aetheria.recipedata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)] public class RecipeData { @@ -46,6 +47,11 @@ public class RecipeData [JsonProperty("fills"), Key(8)] public List Fills = new List(); + // Roles one cycle consumes, any filler will do and the facility picks (ruling ores-fill-metal-roles). Empty on a role recipe; + // records written before key 9 read it as empty. + [JsonProperty("roleInputs"), Key(9)] + public List RoleInputs = new List(); + public static CultRecordKey KeyOf(CultRecordKey outputKey) => new CultRecordKey("recipe:" + outputKey.Value); } @@ -92,7 +98,8 @@ public class OutsideMarketRow public float RatePerHour; } -// The one reader of recipes: which recipe makes a good, which commodities fill a role, and what a good is by its recipe. +// The one reader of recipes: which recipe makes a good, a recipe's role slots (RoleInputsOf), which commodities fill a role, and +// what a good is by its recipe. public static class Recipes { public static RecipeData RecipeOf(CultCache catalog, ItemData item) => @@ -110,8 +117,12 @@ public static class Recipes return output is CraftedItemData && RolesOf(output).Count > 0; } - // Raw: no inputs and not a role recipe. Everything else is processed. Derived, never stored. - public static bool IsRaw(CultCache catalog, RecipeData recipe) => recipe.Inputs.Count == 0 && !IsRoleRecipe(catalog, recipe); + // The one reader of a recipe's role slots: the design's Roles for a role recipe, else the recipe's RoleInputs. + public static IReadOnlyList RoleInputsOf(CultCache catalog, RecipeData recipe) => + IsRoleRecipe(catalog, recipe) ? RolesOf(catalog.Get(recipe.Output)) : (IReadOnlyList) recipe.RoleInputs ?? new List(); + + // Raw: takes no commodity and no role. Everything else is processed. Derived, never stored. + public static bool IsRaw(CultCache catalog, RecipeData recipe) => recipe.Inputs.Count == 0 && RoleInputsOf(catalog, recipe).Count == 0; // A commodity made by a role recipe: it is assembled from the same components gear is. public static bool IsConsumerGood(CultCache catalog, RecipeData recipe) => diff --git a/tools/AetherDb/EconomyCommand.cs b/tools/AetherDb/EconomyCommand.cs index d0dc1b725..244fc4279 100644 --- a/tools/AetherDb/EconomyCommand.cs +++ b/tools/AetherDb/EconomyCommand.cs @@ -96,10 +96,8 @@ public static class EconomyCommand output.WriteLine(); foreach (var recipe in catalog.GetAll().OrderBy(r => catalog.RefOf(r).Key.Value, StringComparer.Ordinal)) { - var made = catalog.Get(recipe.Output); - var takes = Recipes.IsRoleRecipe(catalog, recipe) - ? string.Join(", ", Recipes.RolesOf(made).Select(r => $"{r.Count} x {r.Name}")) - : string.Join(", ", (recipe.Inputs ?? new List()).Where(c => c != null).Select(c => $"{c.Count} x {c.Commodity.Key.Value}")); + var takes = string.Join(", ", (recipe.Inputs ?? new List()).Where(c => c != null).Select(c => $"{c.Count} x {c.Commodity.Key.Value}") + .Concat(Recipes.RoleInputsOf(catalog, recipe).Where(r => r != null).Select(r => $"{r.Count} x {r.Name}"))); 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; From ccd9792d2f0e27172bb23cc384f7beec183fea60 Mon Sep 17 00:00:00 2001 From: soul Date: Tue, 27 Oct 2026 10:56:21 +0100 Subject: [PATCH 2/4] Economy tests: role inputs persist, are read by every rule as any filler, offered recipe-less designs demand their roles Co-Authored-By: Claude Opus 5.5 --- .../EconomyChecksTests.cs | 4 + .../EconomyCommandTests.cs | 6 + tests/Aetheria.Shared.Tests/EconomyFixture.cs | 9 +- .../EconomyNullGuardTests.cs | 7 + .../RecipeRoleInputTests.cs | 156 ++++++++++++++++++ 5 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/RecipeRoleInputTests.cs diff --git a/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs b/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs index 40c533bb9..a9e0db99f 100644 --- a/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs @@ -53,6 +53,10 @@ public sealed class EconomyChecksTests yield return Case("upkeep that is not a commodity", f => f.EditRecipe("commodity:plate", r => r.Upkeep.Add(new CommodityCount { Commodity = EconomyFixture.Item("hull:skiff"), Count = 1 })), "recipe:commodity:plate"); yield return Case("input naming nothing", f => f.EditRecipe("commodity:plate", r => r.Inputs.Add(new CommodityCount { Commodity = EconomyFixture.Item("commodity:ghost"), Count = 1 })), "recipe:commodity:plate"); yield return Case("inputs on a role recipe", f => f.EditRecipe("gear:laser", r => r.Inputs.Add(new CommodityCount { Commodity = EconomyFixture.Item(Ore), Count = 1 })), "recipe:gear:laser"); + yield return Case("role input count zero", f => f.EditRecipe("commodity:plate", r => r.RoleInputs.Add(new ItemRole { Name = "Plate", Count = 0 })), "recipe:commodity:plate", says: "Role 'Plate' count"); + yield return Case("role input with no filler", f => f.EditRecipe("commodity:plate", r => r.RoleInputs.Add(new ItemRole { Name = "Mystery", Count = 1 })), "recipe:commodity:plate", says: "Role 'Mystery' has no commodity"); + yield return Case("role input with an empty name", f => f.EditRecipe("commodity:plate", r => r.RoleInputs.Add(new ItemRole { Name = " ", Count = 1 })), "recipe:commodity:plate", says: "empty name"); + yield return Case("role inputs on a role recipe", f => f.EditRecipe("gear:laser", r => r.RoleInputs.Add(new ItemRole { Name = "Plate", Count = 1 })), "recipe:gear:laser", says: "no RoleInputs"); yield return Case("a role with no filler", f => f.EditItem("gear:laser", g => g.Roles.Add(new ItemRole { Name = "Mystery" })), "recipe:gear:laser"); yield return Case("a role count of zero", f => f.EditItem("gear:laser", g => g.Roles[0].Count = 0), "recipe:gear:laser"); yield return Case("fills on a design", f => f.EditRecipe("gear:laser", r => r.Fills.Add("Plate")), "recipe:gear:laser"); diff --git a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs index dbd0d0f6f..74776b9db 100644 --- a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs @@ -254,6 +254,12 @@ public sealed class EconomyCommandTests 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); + f.Good("ore2", fills: new[] { "OreRole" }); + f.Good("steel", roleInputs: new[] { ("OreRole", 2) }); + var withRole = new StringWriter(); + EconomyCommand.Report(f.Cache, withRole); + Assert.Contains("commodity:steel <- 2 x OreRole | Mill", withRole.ToString()); + Assert.Contains("gear:laser <- 2 x Plate | Mill", withRole.ToString()); 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 = ""); diff --git a/tests/Aetheria.Shared.Tests/EconomyFixture.cs b/tests/Aetheria.Shared.Tests/EconomyFixture.cs index 91f691ae9..3aba1fe05 100644 --- a/tests/Aetheria.Shared.Tests/EconomyFixture.cs +++ b/tests/Aetheria.Shared.Tests/EconomyFixture.cs @@ -52,9 +52,11 @@ public sealed class EconomyFixture : IDisposable private void Sell(CultRecordKey design) => Cache.Upsert(new FactionProductData { Name = design.Value, Design = new CultRecordRef(design), Manufacturer = Maker }); - // A commodity and its recipe. Roles make it a consumer good (a role recipe); Inputs make it a compound commodity. + // A commodity and its recipe. Roles make it a consumer good (a role recipe); Inputs make it a compound commodity; roleInputs are + // the roles its recipe takes, any filler will do. public string Good(string id, (string Id, int Count)[] inputs = null, string[] fills = null, int outputCount = 1, float cycle = 1, - int workforce = 0, (string Id, int Count)[] upkeep = null, string lore = "a good", (string Role, int Count)[] roles = null, CultRecordRef? kind = null) + int workforce = 0, (string Id, int Count)[] upkeep = null, string lore = "a good", (string Role, int Count)[] roles = null, CultRecordRef? kind = null, + (string Role, int Count)[] roleInputs = null) { var key = new CultRecordKey(Key(id)); var roleList = (roles ?? Array.Empty<(string, int)>()).Select(r => new ItemRole { Name = r.Role, Count = r.Count }).ToList(); @@ -70,7 +72,8 @@ public sealed class EconomyFixture : IDisposable Upkeep = (upkeep ?? Array.Empty<(string, int)>()).Select(i => new CommodityCount { Commodity = Item(Key(i.Id)), Count = i.Count }).ToList(), StationKind = kind ?? Mill, LoreHook = lore, - Fills = (fills ?? Array.Empty()).ToList() + Fills = (fills ?? Array.Empty()).ToList(), + RoleInputs = (roleInputs ?? Array.Empty<(string, int)>()).Select(r => new ItemRole { Name = r.Role, Count = r.Count }).ToList() }, RecipeData.KeyOf(key)); return key.Value; } diff --git a/tests/Aetheria.Shared.Tests/EconomyNullGuardTests.cs b/tests/Aetheria.Shared.Tests/EconomyNullGuardTests.cs index 61b0cea12..b1b633070 100644 --- a/tests/Aetheria.Shared.Tests/EconomyNullGuardTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyNullGuardTests.cs @@ -12,12 +12,14 @@ public sealed class EconomyNullGuardTests if (list == "Fills") r.Fills = null; if (list == "Inputs") r.Inputs = null; if (list == "Upkeep") r.Upkeep = null; + if (list == "RoleInputs") r.RoleInputs = null; }); [Theory] [InlineData("Fills")] [InlineData("Inputs")] [InlineData("Upkeep")] + [InlineData("RoleInputs")] public void ANullListIsAnLRecipeFaultNotAThrow(string list) { using var f = EconomyFixture.Healthy(); @@ -30,6 +32,7 @@ public sealed class EconomyNullGuardTests [InlineData("Fills")] [InlineData("Inputs")] [InlineData("Upkeep")] + [InlineData("RoleInputs")] public void EveryRuleSurvivesANullList(string list) { using var f = EconomyFixture.Healthy(); @@ -49,5 +52,9 @@ public sealed class EconomyNullGuardTests 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")); + + f.EditRecipe("commodity:plate", r => { r.Inputs.Remove(null); r.RoleInputs.Add(null); }); + report = EconomyChecks.Check(f.Cache); + Assert.Contains(report.Faults, x => x.Invariant == EconomyChecks.LRecipe && x.Record == "recipe:commodity:plate" && x.Message.Contains("RoleInputs")); } } diff --git a/tests/Aetheria.Shared.Tests/RecipeRoleInputTests.cs b/tests/Aetheria.Shared.Tests/RecipeRoleInputTests.cs new file mode 100644 index 000000000..f183e9e8c --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RecipeRoleInputTests.cs @@ -0,0 +1,156 @@ +using System; +using System.Linq; +using GameCult.Caching; +using Xunit; + +// A commodity's recipe takes roles as well as named commodities (aetheria-living-world, cut recipe-role-inputs): RecipeData.RoleInputs, +// the one reader of role slots (Recipes.RoleInputsOf), and the checks reading a role input as any commodity that fills it. +public sealed class RecipeRoleInputTests +{ + private static string Iron => EconomyFixture.Key("iron"); + + // hematite and ilmenite are raw ores filling iron-ore (ilmenite also titanium-ore); iron takes 2 iron-ore. + private static EconomyFixture Smelter() + { + var f = new EconomyFixture(); + f.Good("hematite", fills: new[] { "iron-ore" }); + f.Good("ilmenite", fills: new[] { "iron-ore", "titanium-ore" }); + f.Good("iron", roleInputs: new[] { ("iron-ore", 2) }, fills: new[] { "Plate" }); + f.Gear("laser", new[] { ("Plate", 2) }); + return f; + } + + private static string[] Records(System.Collections.Generic.IEnumerable faults, string invariant) => + faults.Where(x => x.Invariant == invariant).Select(x => x.Record).ToArray(); + + [Fact] + public void ShippedRecipesReadAnEmptyRoleInputList() + { + using var cache = AetheriaStores.Open(TestCatalog.Repo, registry: TestCatalog.Registry()); + var recipes = cache.GetAll().ToArray(); + Assert.NotEmpty(recipes); + Assert.All(recipes, r => { Assert.NotNull(r.RoleInputs); Assert.Empty(r.RoleInputs); }); + } + + [Fact] + public void ARoleInputSurvivesReopen() + { + using var f = Smelter(); + f.Reopen(); + var role = Assert.Single(f.Cache.Get(RecipeData.KeyOf(new CultRecordKey(Iron))).RoleInputs); + Assert.Equal(("iron-ore", 2), (role.Name, role.Count)); + } + + [Fact] + public void RoleInputsOfReadsTheDesignOrTheRecipe() + { + using var f = Smelter(); + var laser = Assert.Single(Recipes.RoleInputsOf(f.Cache, f.Cache.Get(RecipeData.KeyOf(new CultRecordKey("gear:laser"))))); + Assert.Equal(("Plate", 2), (laser.Name, laser.Count)); + var ironRecipe = f.Cache.Get(RecipeData.KeyOf(new CultRecordKey(Iron))); + var ore = Assert.Single(Recipes.RoleInputsOf(f.Cache, ironRecipe)); + Assert.Equal(("iron-ore", 2), (ore.Name, ore.Count)); + Assert.False(Recipes.IsRaw(f.Cache, ironRecipe)); + Assert.True(Recipes.IsRaw(f.Cache, f.Cache.Get(RecipeData.KeyOf(new CultRecordKey(EconomyFixture.Key("hematite")))))); + Assert.False(Recipes.IsConsumerGood(f.Cache, ironRecipe)); + } + + [Fact] + public void DepthReadsARoleInputAsItsShallowestFiller() + { + using var f = new EconomyFixture(); + f.Good("hematite", fills: new[] { "iron-ore" }); + f.Good("grit"); + f.Good("pulp", inputs: new[] { ("grit", 1) }); + f.Good("sludge", inputs: new[] { ("pulp", 1) }, fills: new[] { "iron-ore" }); + f.Good("iron", roleInputs: new[] { ("iron-ore", 1) }); + Assert.Equal(1, EconomyChecks.Deep(f.Cache)[Iron]); + f.EditRecipe(EconomyFixture.Key("hematite"), r => r.Fills.Clear()); + Assert.Equal(3, EconomyChecks.Deep(f.Cache)[Iron]); + f.EditRecipe(EconomyFixture.Key("sludge"), r => r.Fills.Clear()); + Assert.Null(EconomyChecks.Deep(f.Cache)[Iron]); + } + + [Fact] + public void SourceComesThroughAnyFillerOfARoleInput() + { + using var f = new EconomyFixture(); + f.Good("hematite", fills: new[] { "iron-ore" }); + f.Good("loopa", inputs: new[] { ("loopb", 1) }, fills: new[] { "iron-ore" }); + f.Good("loopb", inputs: new[] { ("loopa", 1) }); + f.Good("iron", roleInputs: new[] { ("iron-ore", 1) }); + bool NoSource() => EconomyChecks.SourceSink(f.Cache).Any(x => x.Record == "recipe:" + Iron && x.Message.Contains("no source")); + Assert.False(NoSource()); + f.EditRecipe(EconomyFixture.Key("hematite"), r => r.Fills.Clear()); + Assert.True(NoSource()); + } + + [Fact] + public void AGoodFillingOnlyARoleInputHasASink() + { + using var f = Smelter(); + f.Good("titanium", roleInputs: new[] { ("titanium-ore", 1) }); + bool NoSink() => EconomyChecks.SourceSink(f.Cache).Any(x => x.Record == "recipe:" + EconomyFixture.Key("ilmenite") && x.Message.Contains("no sink")); + Assert.False(NoSink()); + f.EditRecipe(Iron, r => r.RoleInputs.Clear()); + f.EditRecipe(EconomyFixture.Key("titanium"), r => r.RoleInputs.Clear()); + Assert.True(NoSink()); + } + + [Fact] + public void ProductiveReadsTheWorstFillerOfARoleInput() + { + using var f = new EconomyFixture(); + f.Good("hematite", fills: new[] { "iron-ore" }); + f.Good("iron", roleInputs: new[] { ("iron-ore", 2) }); + Assert.Empty(EconomyChecks.Productive(f.Cache)); + f.Good("scrap", inputs: new[] { ("iron", 2) }, fills: new[] { "iron-ore" }); + var fault = Assert.Single(EconomyChecks.Productive(f.Cache)); + Assert.Contains("commodity:iron", fault.Message); + Assert.Contains("commodity:scrap", fault.Message); + } + + [Fact] + public void ConsumerDemandWalksThroughARoleInput() + { + using var f = new EconomyFixture(); + f.Good("rawx"); + f.Good("acid", inputs: new[] { ("rawx", 1) }, fills: new[] { "reagent" }); + f.Good("g1", roleInputs: new[] { ("reagent", 1) }, fills: new[] { "Barrel" }); + f.Good("g2", roleInputs: new[] { ("reagent", 1) }, fills: new[] { "Plate" }); + f.Gear("cannon", new[] { ("Barrel", 1) }); + f.Hull("tug", HullType.Ship, new[] { ("Plate", 1) }); + Assert.Equal(new[] { "recipe:" + EconomyFixture.Key("acid") }, Records(EconomyChecks.ConsumerDemand(f.Cache), EconomyChecks.LDemand)); + f.Good("toaster", roles: new[] { ("Barrel", 1) }); + Assert.Empty(EconomyChecks.ConsumerDemand(f.Cache)); + } + + private static EconomyFixture Casings(Action design) + { + var f = EconomyFixture.Healthy(); + f.Good("cap", inputs: new[] { ("ore", 1) }, fills: new[] { "Casing" }); + f.Good("alloy", inputs: new[] { ("ore", 1) }, fills: new[] { "Casing", "Plate" }); + design(f); + return f; + } + + [Fact] + public void AnOfferedDesignWithoutARecipeStillNamesItsRoles() + { + using (var offered = Casings(f => f.Gear("shell", new[] { ("Casing", 1) }, recipe: false))) + { + Assert.DoesNotContain(EconomyChecks.SourceSink(offered.Cache), x => x.Record == "recipe:" + EconomyFixture.Key("cap") && x.Message.Contains("no sink")); + Assert.Equal(new[] { "recipe:" + EconomyFixture.Key("alloy") }, Records(EconomyChecks.ConsumerDemand(offered.Cache), EconomyChecks.LDemand)); + } + foreach (var design in new Action[] + { + f => f.Gear("shell", new[] { ("Casing", 1) }, offered: false, recipe: false), + f => f.Hull("outpost", HullType.Station, new[] { ("Casing", 1) }, recipe: false) + }) + using (var f = Casings(design)) + { + Assert.Contains(EconomyChecks.SourceSink(f.Cache), x => x.Record == "recipe:" + EconomyFixture.Key("cap") && x.Message.Contains("no sink")); + Assert.Empty(EconomyChecks.ConsumerDemand(f.Cache)); + } + } +} From 1efd8343daa932df5bd3c5f8b246293340ebb14f Mon Sep 17 00:00:00 2001 From: soul Date: Tue, 27 Oct 2026 11:30:14 +0100 Subject: [PATCH 3/4] Economy tests: pin the survivors of the diff (null RoleInputs keeps the rest, key-ordered recipe-less designs, unoffered recipe designs name roles, mixed takes line) Co-Authored-By: Claude Opus 5.5 --- .../EconomyCommandTests.cs | 4 ++++ .../EconomyNullGuardTests.cs | 8 ++++++++ .../RecipeRoleInputTests.cs | 17 +++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs index 74776b9db..c0c04f89c 100644 --- a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs @@ -259,6 +259,10 @@ public sealed class EconomyCommandTests var withRole = new StringWriter(); EconomyCommand.Report(f.Cache, withRole); Assert.Contains("commodity:steel <- 2 x OreRole | Mill", withRole.ToString()); + f.EditRecipe("commodity:steel", r => r.Inputs.Add(new CommodityCount { Commodity = EconomyFixture.Item("commodity:ore"), Count = 1 })); + var both = new StringWriter(); + EconomyCommand.Report(f.Cache, both); + Assert.Contains("commodity:steel <- 1 x commodity:ore, 2 x OreRole | Mill", both.ToString()); Assert.Contains("gear:laser <- 2 x Plate | Mill", withRole.ToString()); 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)); diff --git a/tests/Aetheria.Shared.Tests/EconomyNullGuardTests.cs b/tests/Aetheria.Shared.Tests/EconomyNullGuardTests.cs index b1b633070..dd8e51f31 100644 --- a/tests/Aetheria.Shared.Tests/EconomyNullGuardTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyNullGuardTests.cs @@ -45,6 +45,14 @@ public sealed class EconomyNullGuardTests Assert.NotEmpty(EconomyChecks.Deep(f.Cache)); } + [Fact] + public void ANullRoleInputsReadsAsTheListWithoutItAndKeepsTheRestOfTheRecipe() + { + using var f = EconomyFixture.Healthy(); + f.EditRecipe("commodity:plate", r => r.RoleInputs = null); + Assert.Empty(EconomyChecks.Check(f.Cache).Faults.Where(x => x.Invariant != EconomyChecks.LRecipe)); + } + [Fact] public void ANullEntryInAListIsAnLRecipeFault() { diff --git a/tests/Aetheria.Shared.Tests/RecipeRoleInputTests.cs b/tests/Aetheria.Shared.Tests/RecipeRoleInputTests.cs index f183e9e8c..16d8be24e 100644 --- a/tests/Aetheria.Shared.Tests/RecipeRoleInputTests.cs +++ b/tests/Aetheria.Shared.Tests/RecipeRoleInputTests.cs @@ -134,6 +134,23 @@ public sealed class RecipeRoleInputTests return f; } + [Fact] + public void ADesignWithARecipeNamesItsRolesOfferedOrNot() + { + using var f = Casings(g => g.Gear("shell", new[] { ("Casing", 1) }, offered: false)); + Assert.DoesNotContain(EconomyChecks.SourceSink(f.Cache), x => x.Record == "recipe:" + EconomyFixture.Key("cap") && x.Message.Contains("no sink")); + Assert.Equal(new[] { "recipe:" + EconomyFixture.Key("alloy") }, Records(EconomyChecks.ConsumerDemand(f.Cache), EconomyChecks.LDemand)); + } + + [Fact] + public void OfferedDesignsWithoutARecipeAreFaultedInKeyOrder() + { + using var f = EconomyFixture.Healthy(); + f.Gear("zeta", new[] { ("Plate", 1) }, recipe: false); + f.Gear("alpha", new[] { ("Plate", 1) }, recipe: false); + Assert.Equal(new[] { "gear:alpha", "gear:zeta" }, EconomyChecks.Recipe(f.Cache).Select(x => x.Record)); + } + [Fact] public void AnOfferedDesignWithoutARecipeStillNamesItsRoles() { From e93f5316699af4298cf25605ee5fea2228960afc Mon Sep 17 00:00:00 2001 From: soul Date: Tue, 27 Oct 2026 11:44:30 +0100 Subject: [PATCH 4/4] Economy tests: a null list beside RoleInputs does not drop them Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/RecipeRoleInputTests.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/RecipeRoleInputTests.cs b/tests/Aetheria.Shared.Tests/RecipeRoleInputTests.cs index 16d8be24e..fdf387130 100644 --- a/tests/Aetheria.Shared.Tests/RecipeRoleInputTests.cs +++ b/tests/Aetheria.Shared.Tests/RecipeRoleInputTests.cs @@ -55,6 +55,15 @@ public sealed class RecipeRoleInputTests Assert.False(Recipes.IsConsumerGood(f.Cache, ironRecipe)); } + [Fact] + public void ANullListElsewhereInARecipeLeavesItsRoleInputsToTheRules() + { + using var f = Smelter(); + Assert.Empty(EconomyChecks.Check(f.Cache).Faults.Where(x => x.Invariant != EconomyChecks.LRecipe)); + f.EditRecipe(Iron, r => r.Upkeep = null); + Assert.Empty(EconomyChecks.Check(f.Cache).Faults.Where(x => x.Invariant != EconomyChecks.LRecipe)); + } + [Fact] public void DepthReadsARoleInputAsItsShallowestFiller() {