diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 9992c293f..6dcd61811 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -7,7 +7,7 @@ using GameCult.Caching.MessagePack; // Aetheria's stores: one home store per type, composed here and nowhere else. public static class AetheriaStores { - public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(InputLayout), typeof(Loadout), typeof(FieldKindData), typeof(VerseGrammar), typeof(VerseVerb), typeof(StationKindData) }; + public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(InputLayout), typeof(Loadout), typeof(FieldKindData), typeof(VerseGrammar), typeof(VerseVerb), typeof(StationKindData), typeof(RecipeData), typeof(OutsideMarketData) }; public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame), typeof(ProvenanceLedger) }; public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) }; diff --git a/Assets/Scripts/ServerShared/EconomyChecks.cs b/Assets/Scripts/ServerShared/EconomyChecks.cs new file mode 100644 index 000000000..4340ee5ae --- /dev/null +++ b/Assets/Scripts/ServerShared/EconomyChecks.cs @@ -0,0 +1,394 @@ +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; + + public EconomyFault(string invariant, string record, string message) + { + 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)> AllRecipes(CultCache catalog) => + catalog.GetAll().Select(recipe => (catalog.RefOf(recipe).Key.Value, recipe)).OrderBy(pair => pair.Item1, StringComparer.Ordinal); + + 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) + Add(catalog.RefOf(item).Key.Value, $"Commodity '{item.Name}' has no recipe."); + 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) + Add(key, $"Offered design '{design.Name}' has no recipe."); + } + var kindsPlaced = new HashSet(catalog.GetAll().SelectMany(f => StationKinds.KindsOf(catalog, f)).Select(k => k.Key.Value), StringComparer.Ordinal); + foreach (var (key, recipe) in AllRecipes(catalog)) + { + var output = catalog.Get(recipe.Output); + if (output == null) { Add(key, "Output names no item this catalog holds."); continue; } + if (!catalog.RefOf(recipe).Key.Equals(RecipeData.KeyOf(recipe.Output.Key))) + 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).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)); + } + + // Every commodity has a source (a grounded chain, or a gate that sells it) and a sink (a recipe uses it, a role some design + // names, or a gate that buys it). A consumer good's sink is judged once needs tables exist. + public static List SourceSink(CultCache catalog) + { + var faults = new List(); + var (sells, buys) = Gate(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 => catalog.Get(p.Recipe.Output) != null && 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) && !buys.Contains(outputKey)) + faults.Add(new EconomyFault(LSource, key, $"'{output.Name}' has no sink: no recipe uses it, no design names a role it fills, no gate buys it.")); + } + 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 (rho, component) = Radius(final, n); + var faults = new List(); + if (rho >= 1 - Margin) + { + string Name(int i) => i == labour ? "labour" : names[i]; + 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))}.")); + } + 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 a nonnegative matrix: the largest over its strongly connected components that hold a cycle, each by + // power iteration on A + I (primitive when irreducible). An edge j -> i is a nonzero A[i, j]. Also the component carrying it. + private static (double Rho, List Component) Radius(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 best = 0.0; + var bestComponent = new 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; + if (component.Count == 1 && !(a[start, start] > 0)) continue; + var rho = Irreducible(a, component); + if (rho > best) { best = rho; bestComponent = component; } + } + return (best, bestComponent); + } + + 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(); + for (var r = 0; r < m; r++) next[r] /= norm; + var settled = Math.Abs(norm - lambda) < 1e-14; + lambda = norm; + x = next; + if (settled) 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; + } + + // Every processed commodity two or three steps below a gear or hull (its role fillers are one step, the gear-specific final + // assemblies, exempt) fills a role some consumer good names, so the shared layer is shared. Raw goods and the gate do not count. + 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 consumerRoles = new HashSet(recipes.Values.Where(r => Recipes.IsConsumerGood(catalog, r)) + .SelectMany(r => Recipes.RolesOf(catalog.Get(r.Output))).Select(role => role.Name), StringComparer.Ordinal); + HashSet Below(IEnumerable keys) + { + var next = new HashSet(StringComparer.Ordinal); + foreach (var key in keys) + { + if (!recipes.TryGetValue(key, out var recipe)) continue; + foreach (var input in recipe.Inputs) next.Add(input.Commodity.Key.Value); + foreach (var role in Recipes.RolesOf(catalog.Get(recipe.Output))) + foreach (var filler in Recipes.Fillers(catalog, role.Name)) next.Add(filler.Value); + } + return next; + } + var deep = new HashSet(StringComparer.Ordinal); + foreach (var key in recipes.Where(p => !Recipes.IsCommodity(catalog.Get(p.Value.Output)) && Recipes.IsRoleRecipe(catalog, p.Value)).Select(p => p.Key)) + { + var distance2 = Below(Below(new[] { key })); + deep.UnionWith(distance2); + deep.UnionWith(Below(distance2)); + } + return deep.OrderBy(key => key, StringComparer.Ordinal) + .Where(key => recipes.TryGetValue(key, out var recipe) && Recipes.IsCommodity(catalog.Get(recipe.Output)) && !Recipes.IsRaw(catalog, recipe) && !recipe.Fills.Any(consumerRoles.Contains)) + .Select(key => new EconomyFault(LDemand, RecipeData.KeyOf(new CultRecordKey(key)).Value, + $"'{catalog.Get(recipes[key].Output).Name}' is a processed good below a gear or hull that fills no role a consumer good names.")) + .ToList(); + } +} diff --git a/Assets/Scripts/ServerShared/EconomyChecks.cs.meta b/Assets/Scripts/ServerShared/EconomyChecks.cs.meta new file mode 100644 index 000000000..065d1d058 --- /dev/null +++ b/Assets/Scripts/ServerShared/EconomyChecks.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5e0d62a8f1c34b79b2f58ea3c6d19042 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index d9e972a85..fe705b4f6 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -337,14 +337,17 @@ public abstract class CraftedItemData : ItemData public List Roles = new List(); } -// One slot in a design: every laser has a focusing array. A role is a name and nothing else. What fills it is -// a quality, authored per role by each manufacturer's product; the part itself earns a record when crafting -// needs one to exist. +// One slot in a design: every laser has a focusing array. A role is a named slot with a count: the design takes Count units +// of a part, and any commodity whose recipe Fills the role can be that part. What fills it is a quality, authored per role +// by each manufacturer's product. Records written before Count existed read it as 1. [MessagePackObject, JsonObject(MemberSerialization.OptIn)] public class ItemRole { [Inspectable, JsonProperty("name"), Key(0)] public string Name; + + [Inspectable, JsonProperty("count"), Key(1)] + public int Count = 1; } [CultDocument("aetheria.compoundcommoditydata", "1"), Inspectable, MessagePackObject] diff --git a/Assets/Scripts/ServerShared/Recipes.cs b/Assets/Scripts/ServerShared/Recipes.cs new file mode 100644 index 000000000..5a939b9ea --- /dev/null +++ b/Assets/Scripts/ServerShared/Recipes.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GameCult.Caching; +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. +[CultDocument("aetheria.recipedata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class RecipeData +{ + [JsonProperty("output"), Key(0)] + public CultRecordRef Output; + + [JsonProperty("outputCount"), Key(1)] + public int OutputCount = 1; + + // Commodities one cycle consumes. Empty on a raw good and on a role recipe. + [JsonProperty("inputs"), Key(2)] + public List Inputs = new List(); + + // Seconds of sim time for one cycle. + [JsonProperty("cycle"), Key(3)] + public float Cycle = 1; + + // Crew per running facility; no kind until workforce kinds exist. + [JsonProperty("workforce"), Key(4)] + public int Workforce; + + // Commodities one cycle uses up on the side (power, coolant), per cycle. + [JsonProperty("upkeep"), Key(5)] + public List Upkeep = new List(); + + // The kind of station that runs this recipe. + [JsonProperty("stationKind"), Key(6)] + public CultRecordRef StationKind; + + // Why this good exists in the world, in the operator's reading. Presence is checked, never its meaning. + [JsonProperty("loreHook"), Key(7)] + public string LoreHook; + + // The roles of a design this commodity can fill. Only a commodity's recipe has any. + [JsonProperty("fills"), Key(8)] + public List Fills = new List(); + + public static CultRecordKey KeyOf(CultRecordKey outputKey) => new CultRecordKey("recipe:" + outputKey.Value); +} + +[MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class CommodityCount +{ + [JsonProperty("commodity"), Key(0)] + public CultRecordRef Commodity; + + [JsonProperty("count"), Key(1)] + public int Count = 1; +} + +// A region gate's trade with the world beyond it: what the outside buys and sells, at what price and at most how fast. +// The union of all records is the outside; keyed outside-market:. +[CultDocument("aetheria.outsidemarketdata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class OutsideMarketData +{ + [CultName, JsonProperty("name"), Key(0)] + public string Name; + + [JsonProperty("rows"), Key(1)] + public List Rows = new List(); + + public static CultRecordKey KeyOf(string id) => new CultRecordKey("outside-market:" + id); +} + +[MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class OutsideMarketRow +{ + [JsonProperty("commodity"), Key(0)] + public CultRecordRef Commodity; + + [JsonProperty("price"), Key(1)] + public int Price; + + [JsonProperty("buys"), Key(2)] + public bool Buys; + + [JsonProperty("sells"), Key(3)] + public bool Sells; + + [JsonProperty("ratePerHour"), Key(4)] + 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. +public static class Recipes +{ + public static RecipeData RecipeOf(CultCache catalog, ItemData item) => + item == null ? null : catalog.Get(RecipeData.KeyOf(catalog.RefOf(item).Key)); + + public static bool IsCommodity(ItemData item) => item is SimpleCommodityData || item is CompoundCommodityData; + + public static IReadOnlyList RolesOf(ItemData item) => + (item as CraftedItemData)?.Roles ?? new List(); + + // A recipe whose output is a design with roles: a part list rather than a list of inputs. + public static bool IsRoleRecipe(CultCache catalog, RecipeData recipe) + { + var output = catalog.Get(recipe.Output); + 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); + + // A commodity made by a role recipe: it is assembled from the same components gear is. + public static bool IsConsumerGood(CultCache catalog, RecipeData recipe) => + IsCommodity(catalog.Get(recipe.Output)) && IsRoleRecipe(catalog, recipe); + + // The commodities whose recipes Fill the role, ordered by key so a choice never depends on store order. + public static CultRecordKey[] Fillers(CultCache catalog, string role) => + catalog.GetAll() + .Where(recipe => recipe.Fills != null && recipe.Fills.Contains(role) && IsCommodity(catalog.Get(recipe.Output))) + .Select(recipe => recipe.Output.Key) + .OrderBy(key => key.Value, StringComparer.Ordinal) + .ToArray(); +} diff --git a/Assets/Scripts/ServerShared/Recipes.cs.meta b/Assets/Scripts/ServerShared/Recipes.cs.meta new file mode 100644 index 000000000..f98aa618a --- /dev/null +++ b/Assets/Scripts/ServerShared/Recipes.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3c9a51d7e0b24f68a1e47d92b5c08f31 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs b/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs new file mode 100644 index 000000000..cbc68bc2a --- /dev/null +++ b/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs @@ -0,0 +1,366 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GameCult.Caching; +using MessagePack; +using Xunit; + +// The economy checks over small fixture catalogs (aetheria-living-world, cut recipe-schema-and-checks). Each rule is refused by +// a catalog that breaks only it. +public sealed class EconomyChecksTests +{ + private static string[] Records(IEnumerable faults, string invariant) => + faults.Where(f => f.Invariant == invariant).Select(f => f.Record).OrderBy(r => r, StringComparer.Ordinal).ToArray(); + + private static string Ore => EconomyFixture.Key("ore"); + + [Fact] + public void AHealthyCatalogHasNoFaults() + { + using var f = EconomyFixture.Healthy(); + var report = EconomyChecks.Check(f.Cache); + Assert.Empty(report.Faults); + Assert.Equal(2, report.Depth["gear:laser"]); + Assert.Equal(2, report.Depth["hull:skiff"]); + } + + [Fact] + public void RecipeRefusesAGoodWithoutOne() + { + using var f = EconomyFixture.Healthy(); + f.Cache.Remove(RecipeData.KeyOf(new CultRecordKey(Ore))); + f.Gear("bare", new[] { ("Plate", 1) }, recipe: false); + f.Hull("barehull", HullType.Ship, new[] { ("Plate", 1) }, recipe: false); + f.Hull("barestation", HullType.Station, new (string, int)[0], recipe: false); + f.Hull("bareturret", HullType.Turret, new (string, int)[0], recipe: false); + f.Gear("unoffered", new[] { ("Plate", 1) }, offered: false, recipe: false); + Assert.Equal(new[] { "commodity:ore", "gear:bare", "hull:barehull" }, Records(EconomyChecks.Recipe(f.Cache), EconomyChecks.LRecipe)); + } + + public static IEnumerable BadShapes() + { + object[] Case(string name, Action mutate, string record, string invariant = "L-recipe") => new object[] { name, mutate, record, invariant }; + yield return Case("zero output count", f => f.EditRecipe(Ore, r => r.OutputCount = 0), "recipe:" + Ore); + yield return Case("negative output count", f => f.EditRecipe(Ore, r => r.OutputCount = -1), "recipe:" + Ore); + yield return Case("zero cycle", f => f.EditRecipe(Ore, r => r.Cycle = 0), "recipe:" + Ore); + yield return Case("negative cycle", f => f.EditRecipe(Ore, r => r.Cycle = -2), "recipe:" + Ore); + yield return Case("NaN cycle", f => f.EditRecipe(Ore, r => r.Cycle = float.NaN), "recipe:" + Ore); + yield return Case("infinite cycle", f => f.EditRecipe(Ore, r => r.Cycle = float.PositiveInfinity), "recipe:" + Ore); + yield return Case("negative workforce", f => f.EditRecipe(Ore, r => r.Workforce = -1), "recipe:" + Ore); + yield return Case("zero input count", f => f.EditRecipe("commodity:plate", r => r.Inputs[0].Count = 0), "recipe:commodity:plate"); + yield return Case("zero upkeep count", f => f.EditRecipe("commodity:plate", r => r.Upkeep.Add(new CommodityCount { Commodity = EconomyFixture.Item(Ore), Count = 0 })), "recipe:commodity:plate"); + yield return Case("input that is not a commodity", f => f.EditRecipe("commodity:plate", r => r.Inputs.Add(new CommodityCount { Commodity = EconomyFixture.Item("gear:laser"), Count = 1 })), "recipe:commodity:plate"); + 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("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"); + yield return Case("a design with no roles", f => f.EditItem("gear:laser", g => g.Roles.Clear()), "recipe:gear:laser"); + yield return Case("an unknown station kind", f => f.EditRecipe(Ore, r => r.StationKind = new CultRecordRef(StationKindData.KeyOf("nowhere"))), "recipe:" + Ore); + yield return Case("an unset station kind", f => f.EditRecipe(Ore, r => r.StationKind = default), "recipe:" + Ore); + yield return Case("an output naming nothing", f => f.EditRecipe(Ore, r => r.Output = EconomyFixture.Item("commodity:ghost")), "recipe:" + Ore); + yield return Case("a kind no faction places", f => + { + var ghost = f.Station("ghost", "Ghost"); + f.EditRecipe(Ore, r => r.StationKind = ghost); + }, "recipe:" + Ore, EconomyChecks.LOneKindList); + } + + [Theory] + [MemberData(nameof(BadShapes))] + public void RecipeRefusesBadShape(string name, Action mutate, string record, string invariant) + { + using var f = EconomyFixture.Healthy(); + Assert.Empty(EconomyChecks.Recipe(f.Cache)); + mutate(f); + var faults = EconomyChecks.Recipe(f.Cache); + Assert.True(faults.Any(x => x.Invariant == invariant && x.Record == record), name + ": " + string.Join(" | ", faults)); + if (invariant == EconomyChecks.LOneKindList) Assert.DoesNotContain(faults, x => x.Invariant == EconomyChecks.LRecipe); + } + + [Fact] + public void RecipeRefusesARecipeUnderTheWrongKey() + { + using var f = EconomyFixture.Healthy(); + var recipe = f.Cache.Get(RecipeData.KeyOf(new CultRecordKey(Ore))); + f.Cache.Remove(RecipeData.KeyOf(new CultRecordKey(Ore))); + f.Cache.Upsert(typeof(RecipeData), recipe, new CultRecordKey("recipe:stray")); + var faults = EconomyChecks.Recipe(f.Cache); + Assert.Contains(faults, x => x.Record == "recipe:stray" && x.Message.Contains("key")); + Assert.Contains(faults, x => x.Record == Ore); + } + + [Fact] + public void LoreRefusesBlankHookOnCommodities() + { + using var f = EconomyFixture.Healthy(); + Assert.Empty(EconomyChecks.Lore(f.Cache)); + f.EditRecipe(Ore, r => r.LoreHook = ""); + f.EditRecipe("commodity:plate", r => r.LoreHook = " \t"); + f.Good("tar"); + f.EditRecipe("commodity:tar", r => r.LoreHook = null); + Assert.Equal(new[] { "recipe:commodity:ore", "recipe:commodity:plate", "recipe:commodity:tar" }, Records(EconomyChecks.Lore(f.Cache), EconomyChecks.LLore)); + Assert.DoesNotContain(EconomyChecks.Lore(f.Cache), x => x.Record.Contains("gear") || x.Record.Contains("hull")); + } + + [Fact] + public void SourceSinkCountsTheGate() + { + using var f = EconomyFixture.Healthy(); + f.Good("waste"); + f.Good("loopa", inputs: new[] { ("loopb", 1) }, fills: new[] { "Plate" }); + f.Good("loopb", inputs: new[] { ("loopa", 1) }, fills: new[] { "Plate" }); + var faults = EconomyChecks.SourceSink(f.Cache); + Assert.All(faults, x => Assert.Equal(EconomyChecks.LSource, x.Invariant)); + Assert.Equal(new[] { "recipe:commodity:loopa", "recipe:commodity:loopb" }, faults.Where(x => x.Message.Contains("source")).Select(x => x.Record).OrderBy(x => x, StringComparer.Ordinal)); + Assert.Equal(new[] { "recipe:commodity:waste" }, faults.Where(x => x.Message.Contains("sink")).Select(x => x.Record)); + + f.Gate(("commodity:waste", true, false), ("commodity:loopa", false, true)); + Assert.Empty(EconomyChecks.SourceSink(f.Cache)); + + f.Gate(("commodity:waste", false, true), ("commodity:loopa", true, false)); + Assert.Equal(new[] { "recipe:commodity:loopa", "recipe:commodity:loopb", "recipe:commodity:waste" }, + EconomyChecks.SourceSink(f.Cache).Select(x => x.Record).Distinct().OrderBy(x => x, StringComparer.Ordinal)); + } + + [Fact] + public void SourceSinkLeavesAConsumerGoodWithoutASink() + { + using var f = EconomyFixture.Healthy(); + f.Good("kettle", roles: new[] { ("Plate", 1) }); + Assert.Empty(EconomyChecks.SourceSink(f.Cache)); + } + + [Fact] + public void SourceSinkTakesARoleSomeDesignNamesAsASink() + { + using var f = EconomyFixture.Healthy(); + f.Good("rivet", fills: new[] { "Plate" }); + f.Good("spare", fills: new[] { "Unwanted" }); + Assert.Equal(new[] { "recipe:commodity:spare" }, EconomyChecks.SourceSink(f.Cache).Select(x => x.Record).ToArray()); + } + + private static EconomyFixture Loop(int aCount, int aOut, int bCount = 1, int bOut = 1) + { + var f = new EconomyFixture(); + f.Good("a", inputs: new[] { ("b", aCount) }, outputCount: aOut); + f.Good("b", inputs: new[] { ("a", bCount) }, outputCount: bOut); + return f; + } + + [Fact] + public void ProductiveRefusesALoopAtOne() + { + using var f = Loop(1, 1); + var fault = Assert.Single(EconomyChecks.Productive(f.Cache)); + Assert.Equal("L-productive", fault.Invariant); + Assert.Equal(EconomyFixture.Key("a"), fault.Record); + Assert.Contains("spectral radius 1 ", fault.Message); + Assert.Contains("commodity:a -> commodity:b.", fault.Message); + using var gentle = Loop(9, 10); + Assert.Empty(EconomyChecks.Productive(gentle.Cache)); + using var feed = Loop(1, 1); + feed.Good("c", inputs: new[] { ("a", 1) }); + Assert.Single(EconomyChecks.Productive(feed.Cache)); + } + + [Fact] + public void ProductiveRefusesJustBelowTheLine() + { + using var f = Loop(1999999999, 2000000000); + Assert.Single(EconomyChecks.Productive(f.Cache)); + using var g = Loop(1999999900, 2000000000); + Assert.Empty(EconomyChecks.Productive(g.Cache)); + } + + [Fact] + public void ProductiveIncludesInputsAndTheInputFactor() + { + using var f = Loop(1, 2); + Assert.Empty(EconomyChecks.Productive(f.Cache)); + Assert.Single(EconomyChecks.Productive(f.Cache, new EconomyLoad { InputFactor = 2 })); + Assert.Empty(EconomyChecks.Productive(f.Cache, new EconomyLoad { UpkeepFactor = 4, WorkforceFactor = 4 })); + } + + [Fact] + public void ProductiveIncludesUpkeepAndItsFactor() + { + using var f = new EconomyFixture(); + f.Good("a", upkeep: new[] { ("b", 3) }, outputCount: 4); + f.Good("b", upkeep: new[] { ("a", 1) }); + Assert.Empty(EconomyChecks.Productive(f.Cache)); + Assert.Single(EconomyChecks.Productive(f.Cache, new EconomyLoad { UpkeepFactor = 2 })); + Assert.Empty(EconomyChecks.Productive(f.Cache, new EconomyLoad { InputFactor = 4, WorkforceFactor = 4 })); + f.EditRecipe("commodity:a", r => r.Upkeep[0].Count = 4); + Assert.Single(EconomyChecks.Productive(f.Cache)); + } + + [Fact] + public void ProductiveIncludesLabourAndTheWorkforceFactor() + { + using var f = new EconomyFixture(); + f.Good("a", outputCount: 2, workforce: 1, cycle: 1); + var needs = new EconomyLoad { LabourNeeds = { { "commodity:a", 1.5 } } }; + Assert.Empty(EconomyChecks.Productive(f.Cache)); + Assert.Empty(EconomyChecks.Productive(f.Cache, needs)); + Assert.Single(EconomyChecks.Productive(f.Cache, new EconomyLoad { LabourNeeds = { { "commodity:a", 1.5 } }, WorkforceFactor = 2 })); + Assert.Empty(EconomyChecks.Productive(f.Cache, new EconomyLoad { LabourNeeds = { { "commodity:a", 1.5 } }, InputFactor = 4, UpkeepFactor = 4 })); + f.EditRecipe("commodity:a", r => r.Cycle = 3); + var fault = Assert.Single(EconomyChecks.Productive(f.Cache, needs)); + Assert.Contains("labour", fault.Message); + f.EditRecipe("commodity:a", r => { r.Cycle = 3; r.Workforce = 0; }); + Assert.Empty(EconomyChecks.Productive(f.Cache, needs)); + } + + [Fact] + public void ProductiveRefusesFactorsBelowOne() + { + using var f = new EconomyFixture(); + Assert.Throws(() => EconomyChecks.Productive(f.Cache, new EconomyLoad { InputFactor = 0.5 })); + Assert.Throws(() => EconomyChecks.Productive(f.Cache, new EconomyLoad { WorkforceFactor = 0.5 })); + Assert.Throws(() => EconomyChecks.Productive(f.Cache, new EconomyLoad { UpkeepFactor = double.NaN })); + Assert.Empty(EconomyChecks.Productive(f.Cache, new EconomyLoad { InputFactor = 1, WorkforceFactor = 1, UpkeepFactor = 1 })); + } + + // Labour needs a meal, the meal takes one filler of "Fuel", the filler takes labour: the loop closes through the role. + private static EconomyFixture MealLoop(int firstWorkers, int secondWorkers) + { + var f = new EconomyFixture(); + f.Good("meal", roles: new[] { ("Fuel", 1) }); + f.Good("afuel", fills: new[] { "Fuel" }, workforce: firstWorkers, outputCount: 5); + f.Good("bfuel", fills: new[] { "Fuel" }, workforce: secondWorkers, outputCount: 5); + return f; + } + + private static EconomyLoad MealNeeds => new EconomyLoad { LabourNeeds = { { "commodity:meal", 1 } } }; + + [Fact] + public void ProductiveTakesTheWorstSingleFill() + { + foreach (var (first, second) in new[] { (3, 6), (6, 3) }) + { + using var f = MealLoop(first, second); + var fault = Assert.Single(EconomyChecks.Productive(f.Cache, MealNeeds)); + Assert.Contains(first > second ? "commodity:afuel" : "commodity:bfuel", fault.Message); + } + using var even = MealLoop(3, 3); + Assert.Empty(EconomyChecks.Productive(even.Cache, MealNeeds)); + Assert.Empty(EconomyChecks.Productive(even.Cache)); + using var better = MealLoop(2, 3); + Assert.Empty(EconomyChecks.Productive(better.Cache, MealNeeds)); + } + + [Fact] + public void ProductiveFindsTheWorstFillAmongSeveralRoles() + { + using var f = new EconomyFixture(); + f.Good("meal", roles: new[] { ("Fuel", 1), ("Spice", 1) }); + f.Good("fuela", fills: new[] { "Fuel" }, workforce: 1, outputCount: 5); + f.Good("fuelb", fills: new[] { "Fuel" }, workforce: 1, outputCount: 5); + f.Good("spicea", fills: new[] { "Spice" }, workforce: 1, outputCount: 5); + f.Good("spiceb", fills: new[] { "Spice" }, workforce: 3, outputCount: 5); + Assert.Empty(EconomyChecks.Productive(f.Cache, MealNeeds)); + f.EditItem("commodity:meal", m => m.Roles[1].Count = 2); + var fault = Assert.Single(EconomyChecks.Productive(f.Cache, new EconomyLoad { LabourNeeds = { { "commodity:meal", 1.3 } } })); + Assert.Contains("commodity:spiceb", fault.Message); + } + + [Fact] + public void DeepIsMinOverFillersMaxOverInputs() + { + using var f = new EconomyFixture(); + f.Good("ore"); + f.Good("bar", inputs: new[] { ("ore", 1) }); + f.Good("cores", inputs: new[] { ("ore", 1) }, fills: new[] { "Core" }); + f.Good("cored", inputs: new[] { ("bar", 1) }, fills: new[] { "Core" }); + f.Good("alloy", inputs: new[] { ("bar", 1), ("cored", 1) }, fills: new[] { "Cover" }); + f.Gear("rifle", new[] { ("Core", 1) }); + f.Hull("frigate", HullType.Ship, new[] { ("Core", 1), ("Cover", 1) }); + f.Good("loopa", inputs: new[] { ("loopb", 1) }); + f.Good("loopb", inputs: new[] { ("loopa", 1) }); + var depth = EconomyChecks.Deep(f.Cache); + Assert.Equal(0, depth["commodity:ore"]); + Assert.Equal(1, depth["commodity:bar"]); + Assert.Equal(1, depth["commodity:cores"]); + Assert.Equal(2, depth["commodity:cored"]); + Assert.Equal(3, depth["commodity:alloy"]); + Assert.Equal(2, depth["gear:rifle"]); + Assert.Equal(4, depth["hull:frigate"]); + Assert.Null(depth["commodity:loopa"]); + Assert.Null(depth["commodity:loopb"]); + } + + [Fact] + public void DeepReportsAndNeverRefuses() + { + using var f = EconomyFixture.Healthy(); + var report = EconomyChecks.Check(f.Cache); + Assert.Equal(2, report.Depth["hull:skiff"]); + Assert.DoesNotContain(report.Faults, x => x.Invariant.Contains("deep", StringComparison.OrdinalIgnoreCase)); + Assert.Empty(report.Faults); + } + + private static EconomyFixture Cannon() + { + var f = new EconomyFixture(); + f.Good("ore"); + f.Good("slag", inputs: new[] { ("ore", 1) }); + f.Good("ingot", inputs: new[] { ("slag", 1) }); + f.Good("steel", inputs: new[] { ("ingot", 1) }); + f.Good("barrel", inputs: new[] { ("steel", 1), ("ore", 1) }, fills: new[] { "Barrel" }); + f.Gear("cannon", new[] { ("Barrel", 1) }); + return f; + } + + [Fact] + public void ConsumerDemandCoversTheSharedLayer() + { + using var f = Cannon(); + // barrel is distance 1 (exempt); steel is 2 and ingot 3 (faults); slag is 4 and ore is raw (neither is). + var faults = EconomyChecks.ConsumerDemand(f.Cache); + Assert.Equal(new[] { "recipe:commodity:ingot", "recipe:commodity:steel" }, faults.Select(x => x.Record).OrderBy(x => x, StringComparer.Ordinal)); + Assert.All(faults, x => Assert.Equal(EconomyChecks.LDemand, x.Invariant)); + + f.Good("pot", roles: new[] { ("Casing", 1) }); + f.EditRecipe("commodity:steel", r => r.Fills.Add("Casing")); + Assert.Equal(new[] { "recipe:commodity:ingot" }, EconomyChecks.ConsumerDemand(f.Cache).Select(x => x.Record)); + f.EditRecipe("commodity:ingot", r => r.Fills.Add("Casing")); + Assert.Empty(EconomyChecks.ConsumerDemand(f.Cache)); + + f.Gate(("commodity:slag", true, false), ("commodity:steel", true, false)); + f.EditRecipe("commodity:steel", r => r.Fills.Clear()); + Assert.Equal(new[] { "recipe:commodity:steel" }, EconomyChecks.ConsumerDemand(f.Cache).Select(x => x.Record)); + } + + [Fact] + public void ConsumerDemandReadsHullsAsWellAsGear() + { + using var f = new EconomyFixture(); + f.Good("ore"); + f.Good("ingot", inputs: new[] { ("ore", 1) }); + f.Good("plate", inputs: new[] { ("ingot", 1) }, fills: new[] { "Plate" }); + f.Hull("tug", HullType.Ship, new[] { ("Plate", 1) }); + Assert.Equal(new[] { "recipe:commodity:ingot" }, EconomyChecks.ConsumerDemand(f.Cache).Select(x => x.Record)); + } + + [Fact] + public void ChecksArePure() + { + using var f = EconomyFixture.Healthy(); + f.Good("loopa", inputs: new[] { ("loopb", 1) }); + f.Good("loopb", inputs: new[] { ("loopa", 1) }); + f.EditRecipe(Ore, r => r.LoreHook = ""); + var options = GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.OptionsFor(typeof(ItemData).Assembly); + string[] Snapshot() => f.Cache.AllStoredDocuments + .Select(d => d.Key.Value + "|" + Convert.ToBase64String(MessagePackSerializer.Serialize(d.Document.GetType(), d.Document, options))) + .OrderBy(s => s, StringComparer.Ordinal).ToArray(); + var before = Snapshot(); + var first = EconomyChecks.Check(f.Cache); + var second = EconomyChecks.Check(f.Cache); + Assert.NotEmpty(first.Faults); + Assert.Equal(first.Faults.Select(x => x.ToString()), second.Faults.Select(x => x.ToString())); + Assert.Equal(first.Depth.OrderBy(p => p.Key, StringComparer.Ordinal), second.Depth.OrderBy(p => p.Key, StringComparer.Ordinal)); + Assert.Equal(before, Snapshot()); + } +} diff --git a/tests/Aetheria.Shared.Tests/EconomyFixture.cs b/tests/Aetheria.Shared.Tests/EconomyFixture.cs new file mode 100644 index 000000000..528e01c51 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/EconomyFixture.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameCult.Caching; + +// A small writable catalog for the economy tests: one maker that places one station kind (Mill), and builders for commodities, +// gear, hulls, recipes and the outside market. Keys are readable: commodity:, gear:, hull:. +internal sealed class EconomyFixture : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "aetheria-economy-" + Guid.NewGuid().ToString("N")); + public string CatalogPath => Path.Combine(_dir, "Aetheria.cc"); + public CultCache Cache { get; private set; } + public CultRecordRef Maker { get; private set; } + public CultRecordRef Mill { get; private set; } + + public EconomyFixture() + { + Directory.CreateDirectory(_dir); + Cache = AetheriaStores.Open(CatalogPath, catalogWritable: true, registry: TestCatalog.Registry()); + Cache.Upsert(new VerseGrammar { Revision = 1 }); + Cache.Upsert(new InputLayout { Rows = new InputLayoutRow[] { new InputLayoutRowSpacer { Height = 1 } } }); + Maker = new CultRecordRef(Cache.Upsert(new Faction { Name = "Maker", ShortName = "MKR" }).Key); + Mill = Station("mill", "Mill"); + Sell(Cache.Upsert(typeof(HullData), new HullData { Name = "Yard", HullType = HullType.Station, Kind = Mill, Shape = new Shape(), Price = 1 }, new CultRecordKey("hull:yard"))); + } + + public void Dispose() + { + Cache.Dispose(); + try { Directory.Delete(_dir, true); } catch (IOException) { } + } + + // Closes the catalog and opens the file again, as the game does. + public void Reopen() + { + Cache.FlushAsync().GetAwaiter().GetResult(); + Cache.Dispose(); + Cache = AetheriaStores.Open(CatalogPath, catalogWritable: true, registry: TestCatalog.Registry()); + } + + public static CultRecordRef Item(string key) => new CultRecordRef(new CultRecordKey(key)); + public static string Key(string id) => "commodity:" + id; + + public CultRecordRef Station(string id, string name) + { + var key = StationKindData.KeyOf(id); + Cache.Upsert(typeof(StationKindData), new StationKindData { Name = name }, key); + return new CultRecordRef(key); + } + + 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. + 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) + { + var key = new CultRecordKey(Key(id)); + var roleList = (roles ?? Array.Empty<(string, int)>()).Select(r => new ItemRole { Name = r.Role, Count = r.Count }).ToList(); + if (inputs == null && roles == null) Cache.Upsert(typeof(SimpleCommodityData), new SimpleCommodityData { Name = id }, key); + else Cache.Upsert(typeof(CompoundCommodityData), new CompoundCommodityData { Name = id, Roles = roleList }, key); + Cache.Upsert(typeof(RecipeData), new RecipeData + { + Output = Item(key.Value), + OutputCount = outputCount, + Cycle = cycle, + Workforce = workforce, + Inputs = (inputs ?? Array.Empty<(string, int)>()).Select(i => new CommodityCount { Commodity = Item(Key(i.Id)), Count = i.Count }).ToList(), + 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() + }, RecipeData.KeyOf(key)); + return key.Value; + } + + // Gear taking the given roles, with its recipe, sold by the maker unless offered is false. + public string Gear(string id, (string Role, int Count)[] roles, bool offered = true, bool recipe = true) + { + var key = new CultRecordKey("gear:" + id); + Cache.Upsert(typeof(GearData), new GearData + { + Name = id, Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, + Roles = roles.Select(r => new ItemRole { Name = r.Role, Count = r.Count }).ToList() + }, key); + Design(key, offered, recipe); + return key.Value; + } + + public string Hull(string id, HullType type, (string Role, int Count)[] roles, bool offered = true, bool recipe = true) + { + var key = new CultRecordKey("hull:" + id); + Cache.Upsert(typeof(HullData), new HullData + { + Name = id, HullType = type, Shape = new Shape(), Price = 1, Kind = type == HullType.Station ? Mill : default, + Roles = roles.Select(r => new ItemRole { Name = r.Role, Count = r.Count }).ToList() + }, key); + Design(key, offered, recipe); + return key.Value; + } + + private void Design(CultRecordKey key, bool offered, bool recipe) + { + Cache.Upsert(new FactionProductData + { + Name = key.Value, Design = new CultRecordRef(key), Manufacturer = Maker, NotOffered = !offered + }); + if (recipe) + Cache.Upsert(typeof(RecipeData), new RecipeData { Output = Item(key.Value), StationKind = Mill }, RecipeData.KeyOf(key)); + } + + public void EditRecipe(string outputKey, Action edit) + { + var key = RecipeData.KeyOf(new CultRecordKey(outputKey)); + var recipe = Cache.Get(key); + edit(recipe); + Cache.Upsert(typeof(RecipeData), recipe, key); + } + + public void EditItem(string key, Action edit) where T : class + { + var item = Cache.Get(new CultRecordKey(key)); + edit(item); + Cache.Upsert(typeof(T), item, new CultRecordKey(key)); + } + + public void Gate(params (string Commodity, bool Buys, bool Sells)[] rows) => + Cache.Upsert(typeof(OutsideMarketData), new OutsideMarketData + { + Name = "Gate", + Rows = rows.Select(r => new OutsideMarketRow { Commodity = Item(r.Commodity), Price = 10, Buys = r.Buys, Sells = r.Sells, RatePerHour = 5 }).ToList() + }, OutsideMarketData.KeyOf("gate")); + + // ore (raw) -> plate (fills Plate) -> a laser and a skiff that each take two plates: no fault anywhere. + public static EconomyFixture Healthy() + { + var f = new EconomyFixture(); + f.Good("ore"); + f.Good("plate", inputs: new[] { ("ore", 2) }, fills: new[] { "Plate" }); + f.Gear("laser", new[] { ("Plate", 2) }); + f.Hull("skiff", HullType.Ship, new[] { ("Plate", 2) }); + return f; + } +} diff --git a/tests/Aetheria.Shared.Tests/RecipeSchemaTests.cs b/tests/Aetheria.Shared.Tests/RecipeSchemaTests.cs new file mode 100644 index 000000000..62b311d22 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RecipeSchemaTests.cs @@ -0,0 +1,94 @@ +using System.Linq; +using GameCult.Caching; +using MessagePack; +using Xunit; + +// Recipe records and role counts as catalog data (aetheria-living-world, cut recipe-schema-and-checks). +public sealed class RecipeSchemaTests +{ + [Fact] + public void RecipeIsKeyedByItsOutput() + { + using var f = new EconomyFixture(); + var ore = f.Good("ore", lore: "first"); + Assert.Equal("recipe:commodity:ore", RecipeData.KeyOf(new CultRecordKey(ore)).Value); + Assert.Equal("first", Recipes.RecipeOf(f.Cache, f.Cache.Get(new CultRecordKey(ore))).LoreHook); + f.Good("ore", lore: "second"); + var recipes = f.Cache.GetAll().ToArray(); + Assert.Single(recipes); + Assert.Equal("second", recipes[0].LoreHook); + Assert.Null(Recipes.RecipeOf(f.Cache, null)); + } + + [Fact] + public void OldRoleReadsCountOne() + { + var options = GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.OptionsFor(typeof(ItemRole).Assembly); + var old = MessagePackSerializer.Serialize(new[] { "Lens" }); + var role = MessagePackSerializer.Deserialize(old, options); + Assert.Equal("Lens", role.Name); + Assert.Equal(1, role.Count); + var counted = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(new ItemRole { Name = "Lens", Count = 4 }, options), options); + Assert.Equal(4, counted.Count); + } + + [Fact] + public void RoleCountSurvivesTheStore() + { + using var f = new EconomyFixture(); + f.Gear("laser", new[] { ("Lens", 3), ("Frame", 1) }); + f.Reopen(); + var laser = f.Cache.Get(new CultRecordKey("gear:laser")); + Assert.Equal(new[] { 3, 1 }, laser.Roles.Select(r => r.Count)); + } + + [Fact] + public void RecipeAndMarketRoundTrip() + { + using var f = new EconomyFixture(); + f.Good("ore"); + f.Good("plate", inputs: new[] { ("ore", 2) }, fills: new[] { "Plate", "Cover" }, outputCount: 3, cycle: 12.5f, workforce: 4, + upkeep: new[] { ("ore", 1) }, lore: "forged in the Mill"); + f.Gate(("commodity:ore", true, false), ("commodity:plate", false, true)); + f.Reopen(); + var recipe = f.Cache.Get(RecipeData.KeyOf(new CultRecordKey("commodity:plate"))); + Assert.Equal("commodity:plate", recipe.Output.Key.Value); + Assert.Equal(3, recipe.OutputCount); + Assert.Equal(12.5f, recipe.Cycle); + Assert.Equal(4, recipe.Workforce); + Assert.Equal(("commodity:ore", 2), (recipe.Inputs.Single().Commodity.Key.Value, recipe.Inputs.Single().Count)); + Assert.Equal(("commodity:ore", 1), (recipe.Upkeep.Single().Commodity.Key.Value, recipe.Upkeep.Single().Count)); + Assert.Equal(f.Mill.Key.Value, recipe.StationKind.Key.Value); + Assert.Equal("forged in the Mill", recipe.LoreHook); + Assert.Equal(new[] { "Plate", "Cover" }, recipe.Fills); + var market = f.Cache.Get(OutsideMarketData.KeyOf("gate")); + Assert.Equal("Gate", market.Name); + Assert.Equal(2, market.Rows.Count); + var row = market.Rows[1]; + Assert.Equal(("commodity:plate", 10, false, true, 5f), (row.Commodity.Key.Value, row.Price, row.Buys, row.Sells, row.RatePerHour)); + Assert.True(market.Rows[0].Buys); + } + + [Fact] + public void RecipesReadDerivedFacts() + { + using var f = new EconomyFixture(); + f.Good("zinc", fills: new[] { "Plate" }); + f.Good("alum", fills: new[] { "Plate", "Cover" }); + f.Good("ore"); + f.Good("tin", inputs: new[] { ("ore", 1) }); + f.Good("kettle", roles: new[] { ("Plate", 1) }); + var gear = f.Gear("laser", new[] { ("Plate", 1) }); + Assert.Equal(new[] { "commodity:alum", "commodity:zinc" }, Recipes.Fillers(f.Cache, "Plate").Select(k => k.Value)); + Assert.Empty(Recipes.Fillers(f.Cache, "Nothing")); + RecipeData Of(string key) => f.Cache.Get(RecipeData.KeyOf(new CultRecordKey(key))); + Assert.True(Recipes.IsRaw(f.Cache, Of("commodity:ore"))); + Assert.False(Recipes.IsRaw(f.Cache, Of("commodity:tin"))); + Assert.False(Recipes.IsRaw(f.Cache, Of("commodity:kettle"))); + Assert.True(Recipes.IsRoleRecipe(f.Cache, Of(gear))); + Assert.False(Recipes.IsRoleRecipe(f.Cache, Of("commodity:tin"))); + Assert.True(Recipes.IsConsumerGood(f.Cache, Of("commodity:kettle"))); + Assert.False(Recipes.IsConsumerGood(f.Cache, Of(gear))); + Assert.False(Recipes.IsConsumerGood(f.Cache, Of("commodity:tin"))); + } +}