Aetheria/Assets/Scripts/ServerShared/EconomyChecks.cs

469 lines
27 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using GameCult.Caching;
// What the economy checks read beyond the catalog: the setting's factors on recipe terms (each at least 1) and the units of a
// commodity one worker consumes per second of work. Neutral by default; later cuts pass real values.
public sealed class EconomyLoad
{
public Dictionary<string, double> LabourNeeds = new Dictionary<string, double>();
public double InputFactor = 1;
public double WorkforceFactor = 1;
public double UpkeepFactor = 1;
}
public sealed class EconomyFault
{
public readonly string Invariant;
public readonly string Record;
public readonly string Message;
// A fault only because the chain has not written the record yet (a commodity with no recipe). Reported, never a refusal.
public readonly bool Pending;
// L-productive only: every good the carrying cycle holds (sorted) and the spectral radius. The fault's identity is the member
// set; a radius that rises is a worse fault of the same identity.
public readonly IReadOnlyList<string> Members;
public readonly double Radius;
public EconomyFault(string invariant, string record, string message, bool pending = false, IReadOnlyList<string> members = null, double radius = 0)
{
Members = members;
Radius = radius;
Pending = pending;
Invariant = invariant;
Record = record;
Message = message;
}
public override string ToString() => $"{Invariant} {Record}: {Message}";
}
public sealed class EconomyReport
{
public readonly IReadOnlyList<EconomyFault> Faults;
// Production steps to each good, by the good's record key; null when no finite chain reaches it. Read by reviewers, never a refusal.
public readonly IReadOnlyDictionary<string, int?> Depth;
public EconomyReport(IReadOnlyList<EconomyFault> faults, IReadOnlyDictionary<string, int?> depth)
{
Faults = faults;
Depth = depth;
}
}
// The one owner of the catalog's economy invariants. Pure: draws nothing, reads no clock, writes nothing. One method per rule.
public static class EconomyChecks
{
public const string LRecipe = "L-recipe", LOneKindList = "L-one-kind-list", LLore = "L-lore", LSource = "L-source-sink", LProductive = "L-productive", LDemand = "L-consumer-demand";
private const double Margin = 1e-9;
public static EconomyReport Check(CultCache catalog, EconomyLoad load = null)
{
var faults = new List<EconomyFault>();
faults.AddRange(Recipe(catalog));
faults.AddRange(Lore(catalog));
faults.AddRange(SourceSink(catalog));
faults.AddRange(Productive(catalog, load));
faults.AddRange(ConsumerDemand(catalog));
return new EconomyReport(faults, Deep(catalog));
}
private static IEnumerable<(string Key, RecipeData Recipe)> StoredRecipes(CultCache catalog) =>
catalog.GetAll<RecipeData>().Select(recipe => (catalog.RefOf(recipe).Key.Value, recipe)).OrderBy(pair => pair.Item1, StringComparer.Ordinal);
// Every rule but Recipe reads recipes through here, so a list a record holds as null (or with a null entry) reads as the list
// without it; Recipe alone names the malformation. The stored document is never changed.
private static IEnumerable<(string Key, RecipeData Recipe)> AllRecipes(CultCache catalog) =>
StoredRecipes(catalog).Select(pair => (pair.Key, Sound(pair.Recipe)));
private static RecipeData Sound(RecipeData recipe) =>
recipe.Inputs != null && recipe.Upkeep != null && recipe.Fills != null && recipe.RoleInputs != null
&& !recipe.Inputs.Contains(null) && !recipe.Upkeep.Contains(null) && !recipe.Fills.Contains(null) && !recipe.RoleInputs.Contains(null)
? recipe
: new RecipeData
{
Output = recipe.Output, OutputCount = recipe.OutputCount, Cycle = recipe.Cycle, Workforce = recipe.Workforce,
StationKind = recipe.StationKind, LoreHook = recipe.LoreHook,
Inputs = (recipe.Inputs ?? new List<CommodityCount>()).Where(c => c != null).ToList(),
Upkeep = (recipe.Upkeep ?? new List<CommodityCount>()).Where(c => c != null).ToList(),
Fills = (recipe.Fills ?? new List<string>()).Where(f => f != null).ToList(),
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);
// Every commodity and every offered design (but a Station or Turret hull) has a recipe, and each recipe is well formed.
public static List<EconomyFault> Recipe(CultCache catalog)
{
var faults = new List<EconomyFault>();
void Add(string record, string message) => faults.Add(new EconomyFault(LRecipe, record, message));
foreach (var item in Commodities(catalog))
if (Recipes.RecipeOf(catalog, item) == null)
faults.Add(new EconomyFault(LRecipe, catalog.RefOf(item).Key.Value, $"Commodity '{item.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;
if (market.Rows == null) Add(marketKey, "Rows is null; an outside market holds a list, empty when it trades nothing.");
else if (market.Rows.Contains(null)) Add(marketKey, "Rows holds a null entry.");
}
var kindsPlaced = new HashSet<string>(catalog.GetAll<Faction>().SelectMany(f => StationKinds.KindsOf(catalog, f)).Select(k => k.Key.Value), StringComparer.Ordinal);
foreach (var (key, stored) in StoredRecipes(catalog))
{
var recipe = Sound(stored);
foreach (var (list, name) in new[] { (stored.Inputs, "Inputs"), (stored.Upkeep, "Upkeep") })
if (list == null) Add(key, $"{name} is null; a recipe holds a list, empty when it takes nothing.");
else if (list.Contains(null)) Add(key, $"{name} holds a null entry.");
if (stored.Fills == null) Add(key, "Fills is null; a recipe holds a list, empty when it fills no role.");
else if (stored.Fills.Contains(null)) Add(key, "Fills holds a null entry.");
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)
Add(key, "The recipe's key is not recipe:<output key>; one recipe per output is its key.");
if (recipe.OutputCount <= 0) Add(key, "OutputCount is not above zero.");
if (!float.IsFinite(recipe.Cycle) || recipe.Cycle <= 0) Add(key, "Cycle is not a finite number above zero.");
if (recipe.Workforce < 0) Add(key, "Workforce is below zero.");
foreach (var (list, name) in new[] { (recipe.Inputs, "Inputs"), (recipe.Upkeep, "Upkeep") })
for (var i = 0; i < list.Count; i++)
{
if (list[i].Count <= 0) Add(key, $"{name}[{i}] count is not above zero.");
if (!Recipes.IsCommodity(catalog.Get(list[i].Commodity))) Add(key, $"{name}[{i}] names no commodity; a recipe takes only commodities.");
}
var kind = catalog.Get(recipe.StationKind);
if (kind == null) Add(key, "StationKind names no station kind this catalog holds.");
else if (!kindsPlaced.Contains(recipe.StationKind.Key.Value))
faults.Add(new EconomyFault(LOneKindList, key, $"No faction places station kind '{kind.Name}', so nothing can run this recipe."));
var roles = Recipes.RolesOf(output);
if (!Recipes.IsCommodity(output))
{
if (recipe.Fills.Count > 0) Add(key, "Fills is set on a design; only a commodity fills a role.");
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.");
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.");
}
}
return faults;
}
// A commodity's lore hook is present. What it says is the operator's review.
public static List<EconomyFault> Lore(CultCache catalog) =>
AllRecipes(catalog).Where(pair => Recipes.IsCommodity(catalog.Get(pair.Recipe.Output)) && string.IsNullOrWhiteSpace(pair.Recipe.LoreHook))
.Select(pair => new EconomyFault(LLore, pair.Key, "A commodity's recipe needs a lore hook.")).ToList();
private static (HashSet<string> Sells, HashSet<string> Buys) Gate(CultCache catalog)
{
var rows = catalog.GetAll<OutsideMarketData>().SelectMany(market => market.Rows ?? new List<OutsideMarketRow>()).Where(r => r != null).ToList();
return (new HashSet<string>(rows.Where(r => r.Sells).Select(r => r.Commodity.Key.Value), StringComparer.Ordinal),
new HashSet<string>(rows.Where(r => r.Buys).Select(r => r.Commodity.Key.Value), StringComparer.Ordinal));
}
// The commodities an offered design's weapon draws as ammunition. One reader, shared by the sink and the consumer-demand rules.
private static HashSet<string> Drawn(CultCache catalog) =>
new HashSet<string>(catalog.GetAll<FactionProductData>().Where(p => LoadoutGenerator.IsOffered(catalog, p))
.Select(p => catalog.Get(p.Design) as EquippableItemData).Where(design => design?.Behaviors != null)
.SelectMany(design => design.Behaviors.OfType<WeaponData>()).Where(weapon => weapon.AmmoType.IsSet())
.Select(weapon => weapon.AmmoType.Key.Value), StringComparer.Ordinal);
// Every commodity has a source (a grounded chain, or a gate that sells it) and a sink (a recipe 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)
{
var faults = new List<EconomyFault>();
var (sells, buys) = Gate(catalog);
var drawn = Drawn(catalog);
var grounded = Depths(catalog, sells);
var recipes = AllRecipes(catalog).ToList();
var used = new HashSet<string>(recipes.SelectMany(p => p.Recipe.Inputs.Concat(p.Recipe.Upkeep)).Select(c => c.Commodity.Key.Value), StringComparer.Ordinal);
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);
if (!Recipes.IsCommodity(output)) continue;
var outputKey = recipe.Output.Key.Value;
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) && !drawn.Contains(outputKey) && !buys.Contains(outputKey))
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;
}
// Production steps to each good: raw goods 0, a recipe's output one more than its deepest input or role, a role as deep as
// its shallowest filler (Knuth's least fixpoint over AND and OR nodes). Null for a good no finite chain reaches. Reported for
// review; it refuses nothing.
public static IReadOnlyDictionary<string, int?> Deep(CultCache catalog) => Depths(catalog, new HashSet<string>());
private static Dictionary<string, int?> Depths(CultCache catalog, ISet<string> seeds)
{
var depth = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var seed in seeds) depth[seed] = 0;
var recipes = AllRecipes(catalog).Where(p => catalog.Get(p.Recipe.Output) != null).ToList();
bool changed;
do
{
changed = false;
foreach (var (_, recipe) in recipes)
{
var candidate = Candidate(catalog, recipe, depth);
var key = recipe.Output.Key.Value;
if (candidate == null || (depth.TryGetValue(key, out var known) && known <= candidate)) continue;
depth[key] = candidate.Value;
changed = true;
}
} while (changed);
var result = new Dictionary<string, int?>(StringComparer.Ordinal);
foreach (var item in Commodities(catalog)) result[catalog.RefOf(item).Key.Value] = null;
foreach (var (_, recipe) in recipes) result[recipe.Output.Key.Value] = null;
foreach (var pair in depth) result[pair.Key] = pair.Value;
return result;
}
private static int? Candidate(CultCache catalog, RecipeData recipe, Dictionary<string, int> depth)
{
if (Recipes.IsRaw(catalog, recipe)) return 0;
var deepest = 0;
foreach (var input in recipe.Inputs)
{
if (!depth.TryGetValue(input.Commodity.Key.Value, out var d)) return null;
deepest = Math.Max(deepest, d);
}
foreach (var role in Recipes.RoleInputsOf(catalog, recipe))
{
int? shallowest = null;
foreach (var filler in Recipes.Fillers(catalog, role.Name))
if (depth.TryGetValue(filler.Value, out var d) && (shallowest == null || d < shallowest)) shallowest = d;
if (shallowest == null) return null;
deepest = Math.Max(deepest, shallowest.Value);
}
return deepest + 1;
}
// 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)
{
load = load ?? new EconomyLoad();
if (!(load.InputFactor >= 1) || !(load.WorkforceFactor >= 1) || !(load.UpkeepFactor >= 1))
throw new ArgumentException("Setting factors are at least 1.", nameof(load));
var columns = AllRecipes(catalog).Where(p => Recipes.IsCommodity(catalog.Get(p.Recipe.Output)) && p.Recipe.OutputCount > 0 && float.IsFinite(p.Recipe.Cycle)).ToList();
var names = columns.Select(p => p.Recipe.Output.Key.Value).ToList();
var index = new Dictionary<string, int>(StringComparer.Ordinal);
for (var i = 0; i < names.Count; i++) index[names[i]] = i;
var n = names.Count + 1;
var labour = names.Count;
var fixedTerms = new double[n, n];
var slots = new List<(int Column, double Amount, int[] Fillers)>();
for (var j = 0; j < columns.Count; j++)
{
var recipe = columns[j].Recipe;
foreach (var input in recipe.Inputs)
if (index.TryGetValue(input.Commodity.Key.Value, out var row) && input.Count > 0) fixedTerms[row, j] += input.Count * load.InputFactor / recipe.OutputCount;
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.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));
}
}
foreach (var need in load.LabourNeeds)
if (index.TryGetValue(need.Key, out var row)) fixedTerms[row, labour] = need.Value;
double[,] Build(int[] choice, double nudge)
{
var a = new double[n, n];
for (var r = 0; r < n; r++)
for (var c = 0; c < n; c++)
a[r, c] = fixedTerms[r, c] + nudge;
for (var s = 0; s < slots.Count; s++) a[slots[s].Fillers[choice[s]], slots[s].Column] += slots[s].Amount;
return a;
}
var pick = new int[slots.Count];
for (var round = 0; round < 500; round++)
{
var left = LeftPerron(Build(pick, 1e-12), n);
var moved = false;
for (var s = 0; s < slots.Count; s++)
{
var best = pick[s];
for (var f = 0; f < slots[s].Fillers.Length; f++)
if (left[slots[s].Fillers[f]] > left[slots[s].Fillers[best]] * (1 + 1e-9)) best = f;
if (best != pick[s]) { pick[s] = best; moved = true; }
}
if (!moved) break;
}
var final = Build(pick, 0);
var faults = new List<EconomyFault>();
string Name(int i) => i == labour ? "labour" : names[i];
foreach (var (rho, component) in Radii(final, n))
{
if (rho < 1 - Margin) continue;
var order = CycleOrder(final, n, component);
faults.Add(new EconomyFault(LProductive, Name(order[0]),
$"The goods cannot sustain themselves: spectral radius {rho:0.######} is not below 1, carried by {string.Join(" -> ", order.Select(Name))}.",
members: order.Select(Name).OrderBy(name => name, StringComparer.Ordinal).ToArray(), radius: rho));
}
return faults;
}
// Left Perron vector of a strictly positive matrix by power iteration on A + I (aperiodic, so a pure cycle converges too).
private static double[] LeftPerron(double[,] a, int n)
{
var u = Enumerable.Repeat(1.0 / n, n).ToArray();
for (var iteration = 0; iteration < 20000; iteration++)
{
var next = new double[n];
for (var c = 0; c < n; c++)
for (var r = 0; r < n; r++)
next[c] += u[r] * (a[r, c] + (r == c ? 1 : 0));
var norm = next.Sum();
var delta = 0.0;
for (var c = 0; c < n; c++) { next[c] /= norm; delta = Math.Max(delta, Math.Abs(next[c] - u[c])); }
u = next;
if (delta < 1e-15) break;
}
return u;
}
// The spectral radius of every strongly connected component of a nonnegative matrix, each by power iteration on A + I
// (primitive when irreducible). An edge j -> i is a nonzero A[i, j]. A component with no cycle has radius 0.
private static List<(double Rho, List<int> Component)> Radii(double[,] a, int n)
{
var reach = new bool[n, n];
for (var i = 0; i < n; i++)
for (var j = 0; j < n; j++)
reach[i, j] = i == j || a[j, i] > 0;
for (var k = 0; k < n; k++)
for (var i = 0; i < n; i++)
if (reach[i, k])
for (var j = 0; j < n; j++)
if (reach[k, j]) reach[i, j] = true;
var done = new bool[n];
var radii = new List<(double, List<int>)>();
for (var start = 0; start < n; start++)
{
if (done[start]) continue;
var component = Enumerable.Range(0, n).Where(i => reach[start, i] && reach[i, start]).ToList();
foreach (var i in component) done[i] = true;
var rho = Irreducible(a, component);
if (double.IsNaN(rho)) rho = double.PositiveInfinity;
radii.Add((rho, component));
}
return radii;
}
private static double Irreducible(double[,] a, List<int> nodes)
{
var m = nodes.Count;
var x = Enumerable.Repeat(1.0, m).ToArray();
var lambda = 0.0;
for (var iteration = 0; iteration < 200000; iteration++)
{
var next = new double[m];
for (var r = 0; r < m; r++)
{
next[r] = x[r];
for (var c = 0; c < m; c++) next[r] += a[nodes[r], nodes[c]] * x[c];
}
var norm = next.Max();
var moved = 0.0;
for (var r = 0; r < m; r++) { next[r] /= norm; moved = Math.Max(moved, Math.Abs(next[r] - x[r])); }
lambda = norm;
x = next;
if (moved < 1e-15) break;
}
return lambda - 1;
}
// The component's goods in cycle order: breadth-first from its first good along "needs" edges, so a simple cycle reads
// around once and a tangled component reads outward from where it starts.
private static List<int> CycleOrder(double[,] a, int n, List<int> component)
{
var inside = new HashSet<int>(component);
var order = new List<int> { component[0] };
var seen = new HashSet<int> { component[0] };
for (var at = 0; at < order.Count; at++)
for (var i = 0; i < n; i++)
if (a[i, order[at]] > 0 && inside.Contains(i) && seen.Add(i)) order.Add(i);
return order;
}
// 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, 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 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.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))
{
var recipe = recipes[key];
if (!IsCommodityRecipe(recipe) || Recipes.IsRaw(catalog, recipe) || Recipes.IsConsumerGood(catalog, recipe)) continue;
var reached = new HashSet<string>(StringComparer.Ordinal) { key };
var open = new Stack<string>(reached);
while (open.Count > 0)
foreach (var next in takenBy[open.Pop()])
if (reached.Add(next)) open.Push(next);
var members = reached.Select(k => recipes[k]).ToList();
if (members.Any(r => Recipes.IsConsumerGood(catalog, r))) 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."));
}
return faults;
}
}