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 LabourNeeds = new Dictionary(); 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 Members; public readonly double Radius; public EconomyFault(string invariant, string record, string message, bool pending = false, IReadOnlyList 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 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 Depth; public EconomyReport(IReadOnlyList faults, IReadOnlyDictionary 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(); 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().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.Inputs.Contains(null) && !recipe.Upkeep.Contains(null) && !recipe.Fills.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()).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() }; private static IEnumerable Commodities(CultCache catalog) => catalog.GetAll().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 Recipe(CultCache catalog) { var faults = new List(); 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)); 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 market in catalog.GetAll()) { 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(catalog.GetAll().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."); 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:; 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."); foreach (var role in roles) { 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 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 Sells, HashSet Buys) Gate(CultCache catalog) { var rows = catalog.GetAll().SelectMany(market => market.Rows ?? new List()).Where(r => r != null).ToList(); return (new HashSet(rows.Where(r => r.Sells).Select(r => r.Commodity.Key.Value), StringComparer.Ordinal), new HashSet(rows.Where(r => r.Buys).Select(r => r.Commodity.Key.Value), StringComparer.Ordinal)); } // The commodities an offered design's weapon draws as ammunition. One reader, shared by the sink and the consumer-demand rules. private static HashSet Drawn(CultCache catalog) => new HashSet(catalog.GetAll().Where(p => LoadoutGenerator.IsOffered(catalog, p)) .Select(p => catalog.Get(p.Design) as EquippableItemData).Where(design => design?.Behaviors != null) .SelectMany(design => design.Behaviors.OfType()).Where(weapon => weapon.AmmoType.IsSet()) .Select(weapon => weapon.AmmoType.Key.Value), StringComparer.Ordinal); // Every commodity has a source (a grounded chain, or a gate that sells it) and a sink (a recipe uses it, a role some design // names, a weapon some offered design carries draws it, or a gate that buys it). A consumer good's sink is judged once needs // tables exist. public static List SourceSink(CultCache catalog) { var faults = new List(); var (sells, buys) = Gate(catalog); var drawn = Drawn(catalog); var grounded = Depths(catalog, sells); var recipes = AllRecipes(catalog).ToList(); var used = new HashSet(recipes.SelectMany(p => p.Recipe.Inputs.Concat(p.Recipe.Upkeep)).Select(c => c.Commodity.Key.Value), StringComparer.Ordinal); 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); 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 uses it, 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 Deep(CultCache catalog) => Depths(catalog, new HashSet()); private static Dictionary Depths(CultCache catalog, ISet seeds) { var depth = new Dictionary(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(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 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.RolesOf(catalog.Get(recipe.Output))) { 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: 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) { 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(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.RolesOf(catalog.Get(recipe.Output))) { 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(); 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 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)>(); 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 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 CycleOrder(double[,] a, int n, List component) { var inside = new HashSet(component); var order = new List { component[0] }; var seen = new HashSet { 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 edge or an upkeep edge), into a consumer good or a role one names. A good whose every use is one gear role is exempt like its final assembly. // Derived from the recipe graph on every check; depth and distance play no part. Raw goods and the gate count for nothing. public static List ConsumerDemand(CultCache catalog) { 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 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))) .ToLookup(edge => edge.Input, edge => edge.Output, StringComparer.Ordinal); var faults = new List(); foreach (var key in recipes.Keys.OrderBy(k => k, StringComparer.Ordinal)) { var recipe = recipes[key]; if (!IsCommodityRecipe(recipe) || Recipes.IsRaw(catalog, recipe) || Recipes.IsConsumerGood(catalog, recipe)) continue; var reached = new HashSet(StringComparer.Ordinal) { key }; var open = new Stack(reached); while (open.Count > 0) foreach (var next in takenBy[open.Pop()]) if (reached.Add(next)) open.Push(next); var members = reached.Select(k => recipes[k]).ToList(); if (members.Any(r => Recipes.IsConsumerGood(catalog, r) || r.Fills.Any(consumerRoles.Contains))) continue; var uses = members.SelectMany(r => r.Fills).Where(gearRoles.Contains).Distinct(StringComparer.Ordinal).OrderBy(role => role, StringComparer.Ordinal) .Concat(reached.Where(drawn.Contains).OrderBy(k => k, StringComparer.Ordinal).Select(k => catalog.Get(recipes[k].Output).Name)).ToList(); var asUpkeep = reached.Any(upkeep.Contains); if (asUpkeep) uses.Add("upkeep"); if (uses.Count < 2 && !asUpkeep) continue; var list = uses.Count == 1 ? uses[0] : string.Join(", ", uses.Take(uses.Count - 1)) + " and " + uses[uses.Count - 1]; faults.Add(new EconomyFault(LDemand, RecipeData.KeyOf(new CultRecordKey(key)).Value, $"'{catalog.Get(recipe.Output).Name}' leads into {list} but no use leads into a consumer good.")); } return faults; } }