Economy tests: role inputs persist, are read by every rule as any filler, offered recipe-less designs demand their roles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-27 10:56:21 +01:00
commit ccd9792d2f
5 changed files with 179 additions and 3 deletions

View file

@ -53,6 +53,10 @@ public sealed class EconomyChecksTests
yield return Case("upkeep that is not a commodity", f => f.EditRecipe("commodity:plate", r => r.Upkeep.Add(new CommodityCount { Commodity = EconomyFixture.Item("hull:skiff"), Count = 1 })), "recipe:commodity:plate");
yield return Case("input naming nothing", f => f.EditRecipe("commodity:plate", r => r.Inputs.Add(new CommodityCount { Commodity = EconomyFixture.Item("commodity:ghost"), Count = 1 })), "recipe:commodity:plate");
yield return Case("inputs on a role recipe", f => f.EditRecipe("gear:laser", r => r.Inputs.Add(new CommodityCount { Commodity = EconomyFixture.Item(Ore), Count = 1 })), "recipe:gear:laser");
yield return Case("role input count zero", f => f.EditRecipe("commodity:plate", r => r.RoleInputs.Add(new ItemRole { Name = "Plate", Count = 0 })), "recipe:commodity:plate", says: "Role 'Plate' count");
yield return Case("role input with no filler", f => f.EditRecipe("commodity:plate", r => r.RoleInputs.Add(new ItemRole { Name = "Mystery", Count = 1 })), "recipe:commodity:plate", says: "Role 'Mystery' has no commodity");
yield return Case("role input with an empty name", f => f.EditRecipe("commodity:plate", r => r.RoleInputs.Add(new ItemRole { Name = " ", Count = 1 })), "recipe:commodity:plate", says: "empty name");
yield return Case("role inputs on a role recipe", f => f.EditRecipe("gear:laser", r => r.RoleInputs.Add(new ItemRole { Name = "Plate", Count = 1 })), "recipe:gear:laser", says: "no RoleInputs");
yield return Case("a role with no filler", f => f.EditItem<GearData>("gear:laser", g => g.Roles.Add(new ItemRole { Name = "Mystery" })), "recipe:gear:laser");
yield return Case("a role count of zero", f => f.EditItem<GearData>("gear:laser", g => g.Roles[0].Count = 0), "recipe:gear:laser");
yield return Case("fills on a design", f => f.EditRecipe("gear:laser", r => r.Fills.Add("Plate")), "recipe:gear:laser");

View file

@ -254,6 +254,12 @@ public sealed class EconomyCommandTests
Assert.Contains("commodity:ore <- nothing | Mill, cycle 1, workforce 0 | a good", text);
Assert.Contains("commodity:plate <- 2 x commodity:ore | Mill, cycle 1, workforce 0 | a good", text);
Assert.Contains("gear:laser <- 2 x Plate | Mill", text);
f.Good("ore2", fills: new[] { "OreRole" });
f.Good("steel", roleInputs: new[] { ("OreRole", 2) });
var withRole = new StringWriter();
EconomyCommand.Report(f.Cache, withRole);
Assert.Contains("commodity:steel <- 2 x OreRole | Mill", withRole.ToString());
Assert.Contains("gear:laser <- 2 x Plate | Mill", withRole.ToString());
Assert.True(text.IndexOf("commodity:ore <-", StringComparison.Ordinal) < text.IndexOf("commodity:plate <-", StringComparison.Ordinal));
Assert.True(text.IndexOf("commodity:plate <-", StringComparison.Ordinal) < text.IndexOf("gear:laser <-", StringComparison.Ordinal));
f.EditRecipe("commodity:plate", r => r.LoreHook = "");

View file

@ -52,9 +52,11 @@ public sealed class EconomyFixture : IDisposable
private void Sell(CultRecordKey design) =>
Cache.Upsert(new FactionProductData { Name = design.Value, Design = new CultRecordRef<CraftedItemData>(design), Manufacturer = Maker });
// A commodity and its recipe. Roles make it a consumer good (a role recipe); Inputs make it a compound commodity.
// A commodity and its recipe. Roles make it a consumer good (a role recipe); Inputs make it a compound commodity; roleInputs are
// the roles its recipe takes, any filler will do.
public string Good(string id, (string Id, int Count)[] inputs = null, string[] fills = null, int outputCount = 1, float cycle = 1,
int workforce = 0, (string Id, int Count)[] upkeep = null, string lore = "a good", (string Role, int Count)[] roles = null, CultRecordRef<StationKindData>? kind = null)
int workforce = 0, (string Id, int Count)[] upkeep = null, string lore = "a good", (string Role, int Count)[] roles = null, CultRecordRef<StationKindData>? kind = null,
(string Role, int Count)[] roleInputs = null)
{
var key = new CultRecordKey(Key(id));
var roleList = (roles ?? Array.Empty<(string, int)>()).Select(r => new ItemRole { Name = r.Role, Count = r.Count }).ToList();
@ -70,7 +72,8 @@ public sealed class EconomyFixture : IDisposable
Upkeep = (upkeep ?? Array.Empty<(string, int)>()).Select(i => new CommodityCount { Commodity = Item(Key(i.Id)), Count = i.Count }).ToList(),
StationKind = kind ?? Mill,
LoreHook = lore,
Fills = (fills ?? Array.Empty<string>()).ToList()
Fills = (fills ?? Array.Empty<string>()).ToList(),
RoleInputs = (roleInputs ?? Array.Empty<(string, int)>()).Select(r => new ItemRole { Name = r.Role, Count = r.Count }).ToList()
}, RecipeData.KeyOf(key));
return key.Value;
}

View file

@ -12,12 +12,14 @@ public sealed class EconomyNullGuardTests
if (list == "Fills") r.Fills = null;
if (list == "Inputs") r.Inputs = null;
if (list == "Upkeep") r.Upkeep = null;
if (list == "RoleInputs") r.RoleInputs = null;
});
[Theory]
[InlineData("Fills")]
[InlineData("Inputs")]
[InlineData("Upkeep")]
[InlineData("RoleInputs")]
public void ANullListIsAnLRecipeFaultNotAThrow(string list)
{
using var f = EconomyFixture.Healthy();
@ -30,6 +32,7 @@ public sealed class EconomyNullGuardTests
[InlineData("Fills")]
[InlineData("Inputs")]
[InlineData("Upkeep")]
[InlineData("RoleInputs")]
public void EveryRuleSurvivesANullList(string list)
{
using var f = EconomyFixture.Healthy();
@ -49,5 +52,9 @@ public sealed class EconomyNullGuardTests
f.EditRecipe("commodity:plate", r => r.Inputs.Add(null));
var report = EconomyChecks.Check(f.Cache);
Assert.Contains(report.Faults, x => x.Invariant == EconomyChecks.LRecipe && x.Record == "recipe:commodity:plate" && x.Message.Contains("Inputs"));
f.EditRecipe("commodity:plate", r => { r.Inputs.Remove(null); r.RoleInputs.Add(null); });
report = EconomyChecks.Check(f.Cache);
Assert.Contains(report.Faults, x => x.Invariant == EconomyChecks.LRecipe && x.Record == "recipe:commodity:plate" && x.Message.Contains("RoleInputs"));
}
}

View file

@ -0,0 +1,156 @@
using System;
using System.Linq;
using GameCult.Caching;
using Xunit;
// A commodity's recipe takes roles as well as named commodities (aetheria-living-world, cut recipe-role-inputs): RecipeData.RoleInputs,
// the one reader of role slots (Recipes.RoleInputsOf), and the checks reading a role input as any commodity that fills it.
public sealed class RecipeRoleInputTests
{
private static string Iron => EconomyFixture.Key("iron");
// hematite and ilmenite are raw ores filling iron-ore (ilmenite also titanium-ore); iron takes 2 iron-ore.
private static EconomyFixture Smelter()
{
var f = new EconomyFixture();
f.Good("hematite", fills: new[] { "iron-ore" });
f.Good("ilmenite", fills: new[] { "iron-ore", "titanium-ore" });
f.Good("iron", roleInputs: new[] { ("iron-ore", 2) }, fills: new[] { "Plate" });
f.Gear("laser", new[] { ("Plate", 2) });
return f;
}
private static string[] Records(System.Collections.Generic.IEnumerable<EconomyFault> faults, string invariant) =>
faults.Where(x => x.Invariant == invariant).Select(x => x.Record).ToArray();
[Fact]
public void ShippedRecipesReadAnEmptyRoleInputList()
{
using var cache = AetheriaStores.Open(TestCatalog.Repo, registry: TestCatalog.Registry());
var recipes = cache.GetAll<RecipeData>().ToArray();
Assert.NotEmpty(recipes);
Assert.All(recipes, r => { Assert.NotNull(r.RoleInputs); Assert.Empty(r.RoleInputs); });
}
[Fact]
public void ARoleInputSurvivesReopen()
{
using var f = Smelter();
f.Reopen();
var role = Assert.Single(f.Cache.Get<RecipeData>(RecipeData.KeyOf(new CultRecordKey(Iron))).RoleInputs);
Assert.Equal(("iron-ore", 2), (role.Name, role.Count));
}
[Fact]
public void RoleInputsOfReadsTheDesignOrTheRecipe()
{
using var f = Smelter();
var laser = Assert.Single(Recipes.RoleInputsOf(f.Cache, f.Cache.Get<RecipeData>(RecipeData.KeyOf(new CultRecordKey("gear:laser")))));
Assert.Equal(("Plate", 2), (laser.Name, laser.Count));
var ironRecipe = f.Cache.Get<RecipeData>(RecipeData.KeyOf(new CultRecordKey(Iron)));
var ore = Assert.Single(Recipes.RoleInputsOf(f.Cache, ironRecipe));
Assert.Equal(("iron-ore", 2), (ore.Name, ore.Count));
Assert.False(Recipes.IsRaw(f.Cache, ironRecipe));
Assert.True(Recipes.IsRaw(f.Cache, f.Cache.Get<RecipeData>(RecipeData.KeyOf(new CultRecordKey(EconomyFixture.Key("hematite"))))));
Assert.False(Recipes.IsConsumerGood(f.Cache, ironRecipe));
}
[Fact]
public void DepthReadsARoleInputAsItsShallowestFiller()
{
using var f = new EconomyFixture();
f.Good("hematite", fills: new[] { "iron-ore" });
f.Good("grit");
f.Good("pulp", inputs: new[] { ("grit", 1) });
f.Good("sludge", inputs: new[] { ("pulp", 1) }, fills: new[] { "iron-ore" });
f.Good("iron", roleInputs: new[] { ("iron-ore", 1) });
Assert.Equal(1, EconomyChecks.Deep(f.Cache)[Iron]);
f.EditRecipe(EconomyFixture.Key("hematite"), r => r.Fills.Clear());
Assert.Equal(3, EconomyChecks.Deep(f.Cache)[Iron]);
f.EditRecipe(EconomyFixture.Key("sludge"), r => r.Fills.Clear());
Assert.Null(EconomyChecks.Deep(f.Cache)[Iron]);
}
[Fact]
public void SourceComesThroughAnyFillerOfARoleInput()
{
using var f = new EconomyFixture();
f.Good("hematite", fills: new[] { "iron-ore" });
f.Good("loopa", inputs: new[] { ("loopb", 1) }, fills: new[] { "iron-ore" });
f.Good("loopb", inputs: new[] { ("loopa", 1) });
f.Good("iron", roleInputs: new[] { ("iron-ore", 1) });
bool NoSource() => EconomyChecks.SourceSink(f.Cache).Any(x => x.Record == "recipe:" + Iron && x.Message.Contains("no source"));
Assert.False(NoSource());
f.EditRecipe(EconomyFixture.Key("hematite"), r => r.Fills.Clear());
Assert.True(NoSource());
}
[Fact]
public void AGoodFillingOnlyARoleInputHasASink()
{
using var f = Smelter();
f.Good("titanium", roleInputs: new[] { ("titanium-ore", 1) });
bool NoSink() => EconomyChecks.SourceSink(f.Cache).Any(x => x.Record == "recipe:" + EconomyFixture.Key("ilmenite") && x.Message.Contains("no sink"));
Assert.False(NoSink());
f.EditRecipe(Iron, r => r.RoleInputs.Clear());
f.EditRecipe(EconomyFixture.Key("titanium"), r => r.RoleInputs.Clear());
Assert.True(NoSink());
}
[Fact]
public void ProductiveReadsTheWorstFillerOfARoleInput()
{
using var f = new EconomyFixture();
f.Good("hematite", fills: new[] { "iron-ore" });
f.Good("iron", roleInputs: new[] { ("iron-ore", 2) });
Assert.Empty(EconomyChecks.Productive(f.Cache));
f.Good("scrap", inputs: new[] { ("iron", 2) }, fills: new[] { "iron-ore" });
var fault = Assert.Single(EconomyChecks.Productive(f.Cache));
Assert.Contains("commodity:iron", fault.Message);
Assert.Contains("commodity:scrap", fault.Message);
}
[Fact]
public void ConsumerDemandWalksThroughARoleInput()
{
using var f = new EconomyFixture();
f.Good("rawx");
f.Good("acid", inputs: new[] { ("rawx", 1) }, fills: new[] { "reagent" });
f.Good("g1", roleInputs: new[] { ("reagent", 1) }, fills: new[] { "Barrel" });
f.Good("g2", roleInputs: new[] { ("reagent", 1) }, fills: new[] { "Plate" });
f.Gear("cannon", new[] { ("Barrel", 1) });
f.Hull("tug", HullType.Ship, new[] { ("Plate", 1) });
Assert.Equal(new[] { "recipe:" + EconomyFixture.Key("acid") }, Records(EconomyChecks.ConsumerDemand(f.Cache), EconomyChecks.LDemand));
f.Good("toaster", roles: new[] { ("Barrel", 1) });
Assert.Empty(EconomyChecks.ConsumerDemand(f.Cache));
}
private static EconomyFixture Casings(Action<EconomyFixture> design)
{
var f = EconomyFixture.Healthy();
f.Good("cap", inputs: new[] { ("ore", 1) }, fills: new[] { "Casing" });
f.Good("alloy", inputs: new[] { ("ore", 1) }, fills: new[] { "Casing", "Plate" });
design(f);
return f;
}
[Fact]
public void AnOfferedDesignWithoutARecipeStillNamesItsRoles()
{
using (var offered = Casings(f => f.Gear("shell", new[] { ("Casing", 1) }, recipe: false)))
{
Assert.DoesNotContain(EconomyChecks.SourceSink(offered.Cache), x => x.Record == "recipe:" + EconomyFixture.Key("cap") && x.Message.Contains("no sink"));
Assert.Equal(new[] { "recipe:" + EconomyFixture.Key("alloy") }, Records(EconomyChecks.ConsumerDemand(offered.Cache), EconomyChecks.LDemand));
}
foreach (var design in new Action<EconomyFixture>[]
{
f => f.Gear("shell", new[] { ("Casing", 1) }, offered: false, recipe: false),
f => f.Hull("outpost", HullType.Station, new[] { ("Casing", 1) }, recipe: false)
})
using (var f = Casings(design))
{
Assert.Contains(EconomyChecks.SourceSink(f.Cache), x => x.Record == "recipe:" + EconomyFixture.Key("cap") && x.Message.Contains("no sink"));
Assert.Empty(EconomyChecks.ConsumerDemand(f.Cache));
}
}
}