Merge eureka/aetheria-living-world-recipe-role-inputs: recipes take role inputs any filler satisfies; offered designs demand their roles
Soul s1 (aetheria-living-world:verdict:cut-recipe-role-inputs.s1) holds all five promises; its two Medium findings are pre-existing test races, deferred to their follow-ups. Suite 2867/0 at e93f53166; shipped report 146 faults (consumer demand: Ballistic Powder, Nitric Acid, Nitrocellulose, Potassium Nitrate). Unity compile not yet run; it rides with the ore-geography compile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
30030af6e4
8 changed files with 280 additions and 40 deletions
|
|
@ -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<CommodityCount>()).Where(c => c != null).ToList(),
|
||||
Upkeep = (recipe.Upkeep ?? new List<CommodityCount>()).Where(c => c != null).ToList(),
|
||||
Fills = (recipe.Fills ?? new List<string>()).Where(f => f != null).ToList()
|
||||
Fills = (recipe.Fills ?? new List<string>()).Where(f => f != null).ToList(),
|
||||
RoleInputs = (recipe.RoleInputs ?? new List<ItemRole>()).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<ItemData> OfferedDesigns(CultCache catalog)
|
||||
{
|
||||
var designs = new Dictionary<string, ItemData>(StringComparer.Ordinal);
|
||||
foreach (var product in catalog.GetAll<FactionProductData>().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<string> DesignRoles(CultCache catalog) =>
|
||||
new HashSet<string>(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<ItemData> Commodities(CultCache catalog) =>
|
||||
catalog.GetAll<ItemData>().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<string>(StringComparer.Ordinal);
|
||||
foreach (var product in catalog.GetAll<FactionProductData>().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<OutsideMarketData>())
|
||||
{
|
||||
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<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, 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<EconomyFault> 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<string>(recipes.SelectMany(p => p.Recipe.Inputs.Concat(p.Recipe.Upkeep)).Select(c => c.Commodity.Key.Value), StringComparer.Ordinal);
|
||||
var rolesNamed = new HashSet<string>(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<EconomyFault> 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<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);
|
||||
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 gearRoles = DesignRoles(catalog);
|
||||
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)))
|
||||
.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<EconomyFault>();
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ using MessagePack;
|
|||
using Newtonsoft.Json;
|
||||
|
||||
// How a good is made: one authored record per output, keyed recipe:<output key>, 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<string> Fills = new List<string>();
|
||||
|
||||
// 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<ItemRole> RoleInputs = new List<ItemRole>();
|
||||
|
||||
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<ItemRole> RoleInputsOf(CultCache catalog, RecipeData recipe) =>
|
||||
IsRoleRecipe(catalog, recipe) ? RolesOf(catalog.Get(recipe.Output)) : (IReadOnlyList<ItemRole>) recipe.RoleInputs ?? new List<ItemRole>();
|
||||
|
||||
// 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) =>
|
||||
|
|
|
|||
|
|
@ -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<GearData>("gear:laser", g => g.Roles.Add(new ItemRole { Name = "Mystery" })), "recipe:gear:laser");
|
||||
yield return Case("a role count of zero", f => f.EditItem<GearData>("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");
|
||||
|
|
|
|||
|
|
@ -254,6 +254,16 @@ 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());
|
||||
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));
|
||||
f.EditRecipe("commodity:plate", r => r.LoreHook = "");
|
||||
|
|
|
|||
|
|
@ -52,9 +52,11 @@ public sealed class EconomyFixture : IDisposable
|
|||
private void Sell(CultRecordKey design) =>
|
||||
Cache.Upsert(new FactionProductData { Name = design.Value, Design = new CultRecordRef<CraftedItemData>(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<StationKindData>? kind = null)
|
||||
int workforce = 0, (string Id, int Count)[] upkeep = null, string lore = "a good", (string Role, int Count)[] roles = null, CultRecordRef<StationKindData>? 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<string>()).ToList()
|
||||
Fills = (fills ?? Array.Empty<string>()).ToList(),
|
||||
RoleInputs = (roleInputs ?? Array.Empty<(string, int)>()).Select(r => new ItemRole { Name = r.Role, Count = r.Count }).ToList()
|
||||
}, RecipeData.KeyOf(key));
|
||||
return key.Value;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -42,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()
|
||||
{
|
||||
|
|
@ -49,5 +60,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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
182
tests/Aetheria.Shared.Tests/RecipeRoleInputTests.cs
Normal file
182
tests/Aetheria.Shared.Tests/RecipeRoleInputTests.cs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
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<EconomyFault> 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<RecipeData>().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>(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>(RecipeData.KeyOf(new CultRecordKey("gear:laser")))));
|
||||
Assert.Equal(("Plate", 2), (laser.Name, laser.Count));
|
||||
var ironRecipe = f.Cache.Get<RecipeData>(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>(RecipeData.KeyOf(new CultRecordKey(EconomyFixture.Key("hematite"))))));
|
||||
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()
|
||||
{
|
||||
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<EconomyFixture> 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 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()
|
||||
{
|
||||
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<EconomyFixture>[]
|
||||
{
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -96,10 +96,8 @@ public static class EconomyCommand
|
|||
output.WriteLine();
|
||||
foreach (var recipe in catalog.GetAll<RecipeData>().OrderBy(r => catalog.RefOf(r).Key.Value, StringComparer.Ordinal))
|
||||
{
|
||||
var made = catalog.Get(recipe.Output);
|
||||
var takes = Recipes.IsRoleRecipe(catalog, recipe)
|
||||
? string.Join(", ", Recipes.RolesOf(made).Select(r => $"{r.Count} x {r.Name}"))
|
||||
: string.Join(", ", (recipe.Inputs ?? new List<CommodityCount>()).Where(c => c != null).Select(c => $"{c.Count} x {c.Commodity.Key.Value}"));
|
||||
var takes = string.Join(", ", (recipe.Inputs ?? new List<CommodityCount>()).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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue