Recipe schema and catalog checks: RecipeData, OutsideMarketData, role counts, EconomyChecks over fixture catalogs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d233194cb2
commit
bacb56c613
9 changed files with 1155 additions and 4 deletions
|
|
@ -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) };
|
||||
|
||||
|
|
|
|||
394
Assets/Scripts/ServerShared/EconomyChecks.cs
Normal file
394
Assets/Scripts/ServerShared/EconomyChecks.cs
Normal file
|
|
@ -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<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;
|
||||
|
||||
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<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)> AllRecipes(CultCache catalog) =>
|
||||
catalog.GetAll<RecipeData>().Select(recipe => (catalog.RefOf(recipe).Key.Value, recipe)).OrderBy(pair => pair.Item1, 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)
|
||||
Add(catalog.RefOf(item).Key.Value, $"Commodity '{item.Name}' has no recipe.");
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var product in catalog.GetAll<FactionProductData>().Where(p => LoadoutGenerator.IsOffered(catalog, p)))
|
||||
{
|
||||
var design = catalog.Get(product.Design);
|
||||
if (design == null || (design is HullData hull && (hull.HullType == HullType.Station || hull.HullType == HullType.Turret))) continue;
|
||||
var key = catalog.RefOf(design).Key.Value;
|
||||
if (seen.Add(key) && Recipes.RecipeOf(catalog, design) == null)
|
||||
Add(key, $"Offered design '{design.Name}' has no recipe.");
|
||||
}
|
||||
var kindsPlaced = new HashSet<string>(catalog.GetAll<Faction>().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:<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.");
|
||||
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<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).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));
|
||||
}
|
||||
|
||||
// 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<EconomyFault> SourceSink(CultCache catalog)
|
||||
{
|
||||
var faults = new List<EconomyFault>();
|
||||
var (sells, buys) = Gate(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 = new HashSet<string>(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<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.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<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.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<EconomyFault>();
|
||||
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<int> 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<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;
|
||||
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<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();
|
||||
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<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;
|
||||
}
|
||||
|
||||
// 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<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 consumerRoles = new HashSet<string>(recipes.Values.Where(r => Recipes.IsConsumerGood(catalog, r))
|
||||
.SelectMany(r => Recipes.RolesOf(catalog.Get(r.Output))).Select(role => role.Name), StringComparer.Ordinal);
|
||||
HashSet<string> Below(IEnumerable<string> keys)
|
||||
{
|
||||
var next = new HashSet<string>(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<string>(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();
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/EconomyChecks.cs.meta
Normal file
11
Assets/Scripts/ServerShared/EconomyChecks.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5e0d62a8f1c34b79b2f58ea3c6d19042
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -337,14 +337,17 @@ public abstract class CraftedItemData : ItemData
|
|||
public List<ItemRole> Roles = new List<ItemRole>();
|
||||
}
|
||||
|
||||
// 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]
|
||||
|
|
|
|||
127
Assets/Scripts/ServerShared/Recipes.cs
Normal file
127
Assets/Scripts/ServerShared/Recipes.cs
Normal file
|
|
@ -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:<output key>, so a second recipe for one output replaces the
|
||||
// first. A commodity's recipe says what it takes and which roles it can fill. A role recipe (the output is a design with roles,
|
||||
// a ship hull, a piece of gear, a consumer good) restates nothing of the design: its inputs are derived from the design's Roles and
|
||||
// their counts, each filled by any commodity whose recipe Fills the role. Station and Turret hulls take no recipe.
|
||||
[CultDocument("aetheria.recipedata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
public class RecipeData
|
||||
{
|
||||
[JsonProperty("output"), Key(0)]
|
||||
public CultRecordRef<ItemData> 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<CommodityCount> Inputs = new List<CommodityCount>();
|
||||
|
||||
// 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<CommodityCount> Upkeep = new List<CommodityCount>();
|
||||
|
||||
// The kind of station that runs this recipe.
|
||||
[JsonProperty("stationKind"), Key(6)]
|
||||
public CultRecordRef<StationKindData> 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<string> Fills = new List<string>();
|
||||
|
||||
public static CultRecordKey KeyOf(CultRecordKey outputKey) => new CultRecordKey("recipe:" + outputKey.Value);
|
||||
}
|
||||
|
||||
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
public class CommodityCount
|
||||
{
|
||||
[JsonProperty("commodity"), Key(0)]
|
||||
public CultRecordRef<ItemData> 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:<id>.
|
||||
[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<OutsideMarketRow> Rows = new List<OutsideMarketRow>();
|
||||
|
||||
public static CultRecordKey KeyOf(string id) => new CultRecordKey("outside-market:" + id);
|
||||
}
|
||||
|
||||
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
public class OutsideMarketRow
|
||||
{
|
||||
[JsonProperty("commodity"), Key(0)]
|
||||
public CultRecordRef<ItemData> 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>(RecipeData.KeyOf(catalog.RefOf(item).Key));
|
||||
|
||||
public static bool IsCommodity(ItemData item) => item is SimpleCommodityData || item is CompoundCommodityData;
|
||||
|
||||
public static IReadOnlyList<ItemRole> RolesOf(ItemData item) =>
|
||||
(item as CraftedItemData)?.Roles ?? new List<ItemRole>();
|
||||
|
||||
// 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<RecipeData>()
|
||||
.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();
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/Recipes.cs.meta
Normal file
11
Assets/Scripts/ServerShared/Recipes.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 3c9a51d7e0b24f68a1e47d92b5c08f31
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
366
tests/Aetheria.Shared.Tests/EconomyChecksTests.cs
Normal file
366
tests/Aetheria.Shared.Tests/EconomyChecksTests.cs
Normal file
|
|
@ -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<EconomyFault> 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<object[]> BadShapes()
|
||||
{
|
||||
object[] Case(string name, Action<EconomyFixture> 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<GearData>("gear:laser", g => g.Roles.Add(new ItemRole { Name = "Mystery" })), "recipe:gear:laser");
|
||||
yield return Case("a role count of zero", f => f.EditItem<GearData>("gear:laser", g => g.Roles[0].Count = 0), "recipe:gear:laser");
|
||||
yield return Case("fills on a design", f => f.EditRecipe("gear:laser", r => r.Fills.Add("Plate")), "recipe:gear:laser");
|
||||
yield return Case("a design with no roles", f => f.EditItem<GearData>("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>(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<EconomyFixture> 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>(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<ArgumentException>(() => EconomyChecks.Productive(f.Cache, new EconomyLoad { InputFactor = 0.5 }));
|
||||
Assert.Throws<ArgumentException>(() => EconomyChecks.Productive(f.Cache, new EconomyLoad { WorkforceFactor = 0.5 }));
|
||||
Assert.Throws<ArgumentException>(() => 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<CompoundCommodityData>("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());
|
||||
}
|
||||
}
|
||||
145
tests/Aetheria.Shared.Tests/EconomyFixture.cs
Normal file
145
tests/Aetheria.Shared.Tests/EconomyFixture.cs
Normal file
|
|
@ -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:<id>, gear:<id>, hull:<id>.
|
||||
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<Faction> Maker { get; private set; }
|
||||
public CultRecordRef<StationKindData> 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<Faction>(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<ItemData> Item(string key) => new CultRecordRef<ItemData>(new CultRecordKey(key));
|
||||
public static string Key(string id) => "commodity:" + id;
|
||||
|
||||
public CultRecordRef<StationKindData> Station(string id, string name)
|
||||
{
|
||||
var key = StationKindData.KeyOf(id);
|
||||
Cache.Upsert(typeof(StationKindData), new StationKindData { Name = name }, key);
|
||||
return new CultRecordRef<StationKindData>(key);
|
||||
}
|
||||
|
||||
private void Sell(CultRecordKey design) =>
|
||||
Cache.Upsert(new FactionProductData { Name = design.Value, Design = new CultRecordRef<CraftedItemData>(design), Manufacturer = Maker });
|
||||
|
||||
// A commodity and its recipe. Roles make it a consumer good (a role recipe); Inputs make it a compound commodity.
|
||||
public string Good(string id, (string Id, int Count)[] inputs = null, string[] fills = null, int outputCount = 1, float cycle = 1,
|
||||
int workforce = 0, (string Id, int Count)[] upkeep = null, string lore = "a good", (string Role, int Count)[] roles = null, CultRecordRef<StationKindData>? kind = null)
|
||||
{
|
||||
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<string>()).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<CraftedItemData>(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<RecipeData> edit)
|
||||
{
|
||||
var key = RecipeData.KeyOf(new CultRecordKey(outputKey));
|
||||
var recipe = Cache.Get<RecipeData>(key);
|
||||
edit(recipe);
|
||||
Cache.Upsert(typeof(RecipeData), recipe, key);
|
||||
}
|
||||
|
||||
public void EditItem<T>(string key, Action<T> edit) where T : class
|
||||
{
|
||||
var item = Cache.Get<T>(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;
|
||||
}
|
||||
}
|
||||
94
tests/Aetheria.Shared.Tests/RecipeSchemaTests.cs
Normal file
94
tests/Aetheria.Shared.Tests/RecipeSchemaTests.cs
Normal file
|
|
@ -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<ItemData>(new CultRecordKey(ore))).LoreHook);
|
||||
f.Good("ore", lore: "second");
|
||||
var recipes = f.Cache.GetAll<RecipeData>().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<ItemRole>(old, options);
|
||||
Assert.Equal("Lens", role.Name);
|
||||
Assert.Equal(1, role.Count);
|
||||
var counted = MessagePackSerializer.Deserialize<ItemRole>(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<GearData>(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>(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>(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>(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")));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue