From 49853d3166bdeaaf1f784d447501eeff62efcf15 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 17:58:51 +0200
Subject: [PATCH 01/13] ProductBin: a binned product names its maker's line and
a floor; the mint draws above that floor
FactionProductData slot 7 Bin (schema id unchanged, additive as slot 6). Mint derives a bin's
distributions from its line on every lot, truncated by NextGaussianAbove (64 tries, then the cutoff).
Co-Authored-By: Claude Sonnet 5.5
---
Assets/Scripts/ServerShared/Extensions.cs | 15 ++
Assets/Scripts/ServerShared/FactionProduct.cs | 32 +++-
Assets/Scripts/ServerShared/ItemManager.cs | 19 +-
.../Aetheria.Shared.Tests/ProductBinTests.cs | 175 ++++++++++++++++++
4 files changed, 230 insertions(+), 11 deletions(-)
create mode 100644 tests/Aetheria.Shared.Tests/ProductBinTests.cs
diff --git a/Assets/Scripts/ServerShared/Extensions.cs b/Assets/Scripts/ServerShared/Extensions.cs
index 656e85ba2..97433a299 100644
--- a/Assets/Scripts/ServerShared/Extensions.cs
+++ b/Assets/Scripts/ServerShared/Extensions.cs
@@ -180,6 +180,21 @@ public static class Extensions
return mean + deviation * sqrt(-2 * log(u1)) * cos(2 * PI * u2);
}
+ // NextGaussian truncated below mean + floorDeviations * deviation: draws until one clears that cutoff, at most 64 times,
+ // then returns the cutoff itself. Exact for any reachable floor and bounded and deterministic per seed for an unreachable
+ // one. A binned product's fills and assembly (ProductBin). Callers clamp, as with NextGaussian.
+ public static float NextGaussianAbove(this ref Random random, float mean, float deviation, float floorDeviations)
+ {
+ var cutoff = mean + floorDeviations * deviation;
+ for (var tries = 0; tries < 64; tries++)
+ {
+ var draw = random.NextGaussian(mean, deviation);
+ if (draw >= cutoff) return draw;
+ }
+
+ return cutoff;
+ }
+
public static float NextUnbounded(this ref Random random) => 1 / (1 - random.NextFloat()) - 1;
public static float NextUnbounded(this ref Random random, float bias, float power, float ceiling) => 1 / (1 - pow(min(random.NextFloat(), ceiling), 1 - pow(clamp(bias,0,.99f), 1 / power))) - 1;
diff --git a/Assets/Scripts/ServerShared/FactionProduct.cs b/Assets/Scripts/ServerShared/FactionProduct.cs
index cdaed4f28..b9a3f0009 100644
--- a/Assets/Scripts/ServerShared/FactionProduct.cs
+++ b/Assets/Scripts/ServerShared/FactionProduct.cs
@@ -8,9 +8,9 @@ using GameCult.Caching;
using MessagePack;
using Newtonsoft.Json;
-// A manufacturer's branded variant of a generic design: its name, its flavor text, and how it builds each of
-// the design's roles. Which products exist is what a faction makes, so market segmentation and regional
-// progression are authored here rather than as duplicate item entries.
+// What a manufacturer sells of a generic design: its name, its flavor text, and either a line (how the maker builds each of the
+// design's roles) or a bin of one of its own lines (maker-design-tiers-allowed). Which products exist is what a faction makes, so
+// market segmentation and regional progression are authored here rather than as duplicate item entries.
[CultDocument("aetheria.factionproductdata", "1"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class FactionProductData
{
@@ -29,16 +29,21 @@ public class FactionProductData
[Inspectable, JsonProperty("roles"), Key(5)]
public List Roles = new List();
- // This line's own final test (maker-design-tiers-allowed): a binned or premium line is a better assembly as well as better
- // parts. Null assembles as its maker does.
+ // This line's own final test (maker-design-tiers-allowed): a premium line is a better assembly as well as better parts.
+ // Null assembles as its maker does. A binned product leaves it null and reads its line's.
[Inspectable, JsonProperty("assembly"), Key(6)]
public AssemblyDistribution Assembly;
+
+ // Set on a binned product: the line it bins and the floor above which it takes that line's output. A bin authors no Roles and
+ // no Assembly; the mint derives both from the line.
+ [Inspectable, JsonProperty("bin"), Key(7)]
+ public ProductBin Bin;
}
// How good this product's part is in one of its design's roles: a pseudo-gaussian distribution whose mean is
// the technology the manufacturer puts in and whose standard deviation is their quality control. A market
-// segment is a second product with a role pumped up; what makes that part better is the flavor text's to tell,
-// because a part here carries quality and nothing else.
+// segment is a bin of the line (ProductBin), not a second product with a role pumped up; what makes a part better is the
+// flavor text's to tell, because a part here carries quality and nothing else.
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class ProductRole
{
@@ -52,6 +57,19 @@ public class ProductRole
public float StandardDeviation = .15f;
}
+// A bin of one of the maker's lines (ruling maker-design-tiers-allowed): the line it bins (the same design and maker, itself
+// unbinned) and the floor, in standard deviations above the mean of each of the line's distributions, below which the bin
+// takes nothing. 0 is the top half of the line, .67 the top quarter.
+[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
+public class ProductBin
+{
+ [Inspectable, JsonProperty("line"), Key(0)]
+ public CultRecordRef Line;
+
+ [Inspectable, JsonProperty("floor"), Key(1)]
+ public float Floor;
+}
+
// How well a maker puts the thing together: the technology and quality control of final assembly. A lot's own quality is drawn
// from it, and it is the ceiling on every role the lot fills. Defaults are a middling maker.
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
diff --git a/Assets/Scripts/ServerShared/ItemManager.cs b/Assets/Scripts/ServerShared/ItemManager.cs
index 12d45d101..b33b17224 100644
--- a/Assets/Scripts/ServerShared/ItemManager.cs
+++ b/Assets/Scripts/ServerShared/ItemManager.cs
@@ -121,6 +121,9 @@ public class ItemManager
// Builds one of a manufacturer's branded products (quality-is-assembly-over-supply-chain): the unit's own quality is its
// final assembly, drawn from the product's assembly distribution, else its maker's, else AssemblyDistribution's defaults,
// and each of the design's roles is filled with a part whose quality is drawn from the product's distribution for it.
+ // A binned product (maker-design-tiers-allowed) has no distributions of its own: it is its line's, derived here on every
+ // mint, with each draw (the assembly and every fill) truncated below the line's mean + Bin.Floor deviations. The lot is
+ // still the bin's: its Product, its title and so its brand.
public int CreateLot(FactionProductData product) => Mint(product, null);
// The same lot at a stated quality: the assembly and every role fill are that quality and nothing is drawn, so a staged
@@ -130,29 +133,37 @@ public class ItemManager
private int Mint(FactionProductData product, float? stated)
{
var design = ItemData.Get(product.Design);
- var assembly = product.Assembly ?? ItemData.Get(product.Manufacturer)?.Assembly ?? new AssemblyDistribution();
+ var line = product.Bin != null ? ItemData.Get(product.Bin.Line) : null;
+ if (product.Bin != null && line == null)
+ Log("A binned product names a line the catalog does not hold; it mints as a product with no roles of its own.");
+ var source = line ?? product;
+ var floor = line != null ? product.Bin.Floor : (float?) null;
+ var assembly = source.Assembly ?? ItemData.Get(product.Manufacturer)?.Assembly ?? new AssemblyDistribution();
var lot = new Lot
{
Design = ItemData.RefOf(design),
Origin = new Attributed { Faction = product.Manufacturer },
Product = ItemData.RefOf(product),
- Quality = stated ?? clamp(Random.NextGaussian(assembly.Mean, assembly.StandardDeviation), .01f, 1),
+ Quality = stated ?? clamp(Draw(assembly.Mean, assembly.StandardDeviation, floor), .01f, 1),
Roles = new List()
};
if (design.Roles != null)
foreach (var role in design.Roles)
{
- var build = product.Roles?.FirstOrDefault(b => b.Role == role.Name) ?? new ProductRole();
+ var build = source.Roles?.FirstOrDefault(b => b.Role == role.Name) ?? new ProductRole();
lot.Roles.Add(new RoleFill
{
Role = role.Name,
- Quality = stated ?? clamp(Random.NextGaussian(build.Mean, build.StandardDeviation), .01f, 1)
+ Quality = stated ?? clamp(Draw(build.Mean, build.StandardDeviation, floor), .01f, 1)
});
}
return Lots.Add(lot);
}
+ private float Draw(float mean, float deviation, float? floorDeviations) =>
+ floorDeviations is { } floor ? Random.NextGaussianAbove(mean, deviation, floor) : Random.NextGaussian(mean, deviation);
+
// No branded product: a lot attributed to maker (which may be unset) with no role fills, e.g. console `give`.
public int CreateLot(CraftedItemData design, CultRecordRef maker, float quality)
{
diff --git a/tests/Aetheria.Shared.Tests/ProductBinTests.cs b/tests/Aetheria.Shared.Tests/ProductBinTests.cs
new file mode 100644
index 000000000..44bbfe077
--- /dev/null
+++ b/tests/Aetheria.Shared.Tests/ProductBinTests.cs
@@ -0,0 +1,175 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using GameCult.Caching;
+using Xunit;
+
+// A bin is a product of its maker's line above a floor (aetheria-release, cut product-tiers; ruling maker-design-tiers-allowed):
+// the mint derives its distributions from the line on every lot, truncated below line mean + Floor deviations, and the lot is
+// still the bin's. Lots here come from CreateLot(product), the production mint, on the AssemblyFromMakerTests fixture.
+public sealed class ProductBinTests : IDisposable
+{
+ private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-bins-" + Guid.NewGuid().ToString("N"));
+ private readonly CultCache _cache;
+ private readonly ItemManager _items;
+ private readonly CultRecordRef _lens;
+ private readonly List _log = new List();
+
+ public ProductBinTests()
+ {
+ Directory.CreateDirectory(_root);
+ _cache = AetheriaStores.Open(Path.Combine(_root, "Aetheria.cc"), catalogWritable: true);
+ var gear = _cache.Upsert(new GearData
+ {
+ Name = "Lensed", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10,
+ Roles = { new ItemRole { Name = "lens" } }
+ });
+ _lens = new CultRecordRef(gear.Key);
+ _items = new ItemManager(_cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _log.Add) { Random = new CultMath.Random(7u) };
+ }
+
+ public void Dispose()
+ {
+ _cache.Dispose();
+ Directory.Delete(_root, true);
+ }
+
+ private static AssemblyDistribution A(float mean, float deviation) => new AssemblyDistribution { Mean = mean, StandardDeviation = deviation };
+
+ // A maker at assembly .6 +- .1 and a line of the Lensed design at lens .5 +- .1.
+ private FactionProductData Line(string name = "Line", float lensMean = .5f, float lensDeviation = .1f, AssemblyDistribution own = null, float makerMean = .6f)
+ {
+ var maker = _cache.Upsert(new Faction { Name = name + " Maker", Assembly = A(makerMean, .1f) });
+ _cache.Upsert(new FactionProductData
+ {
+ Name = name, Design = _lens, Manufacturer = maker, Assembly = own,
+ Roles = { new ProductRole { Role = "lens", Mean = lensMean, StandardDeviation = lensDeviation } }
+ });
+ return _cache.GetAll().Single(p => p.Name == name);
+ }
+
+ private FactionProductData Bin(FactionProductData line, string name, float floor)
+ {
+ _cache.Upsert(new FactionProductData
+ {
+ Name = name, Design = line.Design, Manufacturer = line.Manufacturer,
+ Bin = new ProductBin { Line = _cache.RefOf(line), Floor = floor }
+ });
+ return _cache.GetAll().Single(p => p.Name == name);
+ }
+
+ private Lot[] Mint(FactionProductData product, int count) =>
+ Enumerable.Range(0, count).Select(_ => _items.Lots[_items.CreateLot(product)]).ToArray();
+
+ private static float[] Fills(Lot[] lots) => lots.Select(l => l.Roles.Single().Quality).ToArray();
+
+ // Floor 0 is the top half of the line: nothing under the line's mean, in the fills or in the assembly.
+ [Fact]
+ public void BinnedFillsAndAssemblyNeverFallBelowTheFloor()
+ {
+ var lots = Mint(Bin(Line(), "Top Half", 0), 400);
+ Assert.All(lots, lot =>
+ {
+ Assert.True(lot.Roles.Single().Quality >= .5f - 1e-5f, "a fill fell under the line's mean");
+ Assert.True(lot.Quality >= .6f - 1e-5f, "an assembly fell under the maker's mean");
+ });
+ Assert.InRange(Fills(lots).Average(), .55f, .60f);
+ }
+
+ // The floor is in the line's standard deviations: .67 on .5 +- .1 cuts at .567 and the truncated mean is .627.
+ [Fact]
+ public void FloorIsInDeviations()
+ {
+ var fills = Fills(Mint(Bin(Line(), "Top Quarter", .67f), 400));
+ Assert.All(fills, fill => Assert.True(fill >= .567f - 1e-5f, "a fill fell under mean + floor deviations"));
+ Assert.InRange(fills.Average(), .61f, .645f);
+ }
+
+ // A bin keeps no distribution of its own: after it exists, the line and the maker move and the bin follows.
+ [Fact]
+ public void BinDerivesFromItsLineNotACopy()
+ {
+ var line = Line();
+ var bin = Bin(line, "Follower", 0);
+ Assert.Empty(bin.Roles);
+ Assert.Null(bin.Assembly);
+ line.Roles.Single().Mean = .7f;
+ var maker = _cache.Get(line.Manufacturer);
+ maker.Assembly = A(.8f, .1f);
+ _cache.Commit(batch =>
+ {
+ batch.Upsert(typeof(FactionProductData), line, _cache.RefOf(line).Key);
+ batch.Upsert(typeof(Faction), maker, _cache.RefOf(maker).Key);
+ });
+ var lots = Mint(bin, 300);
+ Assert.All(lots, lot =>
+ {
+ Assert.True(lot.Roles.Single().Quality >= .7f - 1e-5f, "the bin read a copy of its line's fill");
+ Assert.True(lot.Quality >= .8f - 1e-5f, "the bin read a copy of its maker's assembly");
+ });
+ }
+
+ // A line's own assembly is the final test a bin of it sits: the maker's is read only when the line sets none.
+ [Fact]
+ public void BinReadsTheLinesOwnAssemblyBeforeTheMakers()
+ {
+ var bin = Bin(Line(own: A(.85f, .05f), makerMean: .4f), "Certified", 0);
+ Assert.All(Mint(bin, 300), lot => Assert.True(lot.Quality >= .85f - 1e-5f, "the bin assembled as its maker, not its line"));
+ }
+
+ // 6 deviations above a .2 +- .01 line is out of reach in 64 draws: the mint returns the cutoff, .26, and does not loop.
+ [Fact]
+ public void UnreachableFloorMintsAtTheCutoff()
+ {
+ var bin = Bin(Line(lensMean: .2f, lensDeviation: .01f, own: A(.2f, .01f)), "Unreachable", 6);
+ Assert.All(Mint(bin, 20), lot =>
+ {
+ Assert.Equal(.26f, lot.Roles.Single().Quality, 5);
+ Assert.Equal(.26f, lot.Quality, 5);
+ });
+ }
+
+ // Snapshot of the first five assemblies and fills at base, before the bin existed (seed 20261018, lens .5 +- .1, maker .6 +- .1).
+ [Fact]
+ public void UnbinnedMintConsumesTheStreamAsBefore()
+ {
+ var line = Line();
+ _items.Random = new CultMath.Random(20261018u);
+ var lots = Mint(line, 5);
+ var assemblies = new[] { .59560823f, .68289334f, .57563853f, .5639112f, .59828424f };
+ var fills = new[] { .6311789f, .5835483f, .47641385f, .46416125f, .45124212f };
+ for (var i = 0; i < 5; i++)
+ {
+ Assert.Equal(assemblies[i], lots[i].Quality, 5);
+ Assert.Equal(fills[i], lots[i].Roles.Single().Quality, 5);
+ }
+ }
+
+ // The lot is the bin's: its product, its title, and the line's maker as its brand.
+ [Fact]
+ public void ABinnedLotIsBrandedAsTheBin()
+ {
+ var line = Line();
+ var bin = Bin(line, "Premiere", 0);
+ var lotId = _items.CreateLot(bin);
+ Assert.Equal(_cache.RefOf(bin), _items.Lots[lotId].Product);
+ var instance = _items.CreateInstance(lotId);
+ Assert.Equal("Premiere", _items.Title(instance));
+ var (maker, product) = _items.Brand(instance);
+ Assert.Equal(_cache.Get(line.Manufacturer).Name, maker.Name);
+ Assert.Equal("Premiere", product.Name);
+ }
+
+ // A bin whose line the catalog does not hold mints as a product with no roles of its own, and says so.
+ [Fact]
+ public void ABinOfALineTheCatalogLacksMintsAtDefaultsAndLogs()
+ {
+ var line = Line();
+ var bin = Bin(line, "Orphan", 0);
+ _cache.Remove(_cache.RefOf(line).Key);
+ var lots = Mint(bin, 5);
+ Assert.Equal(5, lots.Length);
+ Assert.NotEmpty(_log);
+ }
+}
From 7f09b05725f3f8fd6106e1955fe1a09a6c01228e Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:04:08 +0200
Subject: [PATCH 02/13] ProductWeight: a bin takes its pass fraction of its
maker's preference and its line the rest (ruling bin-share-by-floor)
Phi comes from CultMath's math.erf.
Co-Authored-By: Claude Sonnet 5.5
---
.../Scripts/ServerShared/LoadoutGenerator.cs | 17 ++++++-
.../ProductChoiceTests.cs | 51 ++++++++++++++++++-
2 files changed, 65 insertions(+), 3 deletions(-)
diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs
index 55eea27f8..0206935fb 100644
--- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs
+++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs
@@ -252,8 +252,21 @@ public class LoadoutGenerator
Galaxy == null || Faction == null ||
ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer);
- // How much this outfitter favors a product: its maker's preference. The one weight of generation and of ChooseProduct.
- public float ProductWeight(FactionProductData product) => ManufacturerPreference(product.Manufacturer);
+ // How much this outfitter favors a product: its maker's preference times the product's share of it. The one weight of
+ // generation and of ChooseProduct. A bin takes the share of its maker's stock that would pass its floor and its line takes
+ // the rest (ruling bin-share-by-floor), so binning never changes a maker's presence for the design.
+ public float ProductWeight(FactionProductData product) => ManufacturerPreference(product.Manufacturer) * Share(product);
+
+ // 1 - Phi(floor): the fraction of a normal draw that lies at least floor deviations above its mean.
+ private static float PassFraction(float floor) => .5f * (1 - erf(floor / sqrt(2f)));
+
+ private float Share(FactionProductData product)
+ {
+ if (product.Bin != null) return PassFraction(product.Bin.Floor);
+ var key = ItemManager.ItemData.RefOf(product).Key;
+ var binned = ItemManager.ItemData.GetAll().Where(other => other.Bin != null && other.Bin.Line.Key.Equals(key)).Sum(bin => PassFraction(bin.Bin.Floor));
+ return max(0f, 1 - binned);
+ }
// The product a design named without one is built as (ruling loadout-product-optional-context-chooses): among the
// products of the design this outfitter can get, one draw from the run's seeded Random weighted by ProductWeight;
diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs
index 888634f8e..a11708393 100644
--- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs
+++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs
@@ -63,7 +63,10 @@ public sealed class ProductChoiceTests : IDisposable
}
}
- private Rig Build(params string[] lampOrder)
+ private Rig Build(params string[] lampOrder) => Build(new float[0], lampOrder);
+
+ // binFloors adds one bin of lamp-a's line per floor, named "lamp-a bin" and "lamp-a bin 2"..., by the same maker.
+ private Rig Build(float[] binFloors, string[] lampOrder)
{
var root = Path.Combine(_root, "rig" + _catalogs++);
Directory.CreateDirectory(root);
@@ -97,6 +100,12 @@ public sealed class ProductChoiceTests : IDisposable
// lampOrder holds product names; the record key of the i-th inserted is "k" + i, so keys and insertion order
// both follow the permutation.
for (var i = 0; i < lampOrder.Length; i++) Sell(lampOrder[i], lamp.Key, makers[lampOrder[i]], "k" + i);
+ for (var i = 0; i < binFloors.Length; i++)
+ seed.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData
+ {
+ Name = i == 0 ? "lamp-a bin" : "lamp-a bin " + (i + 1), Design = new CultRecordRef(lamp.Key), Manufacturer = alpha,
+ Bin = new ProductBin { Line = new CultRecordRef(new CultRecordKey("k" + Array.IndexOf(lampOrder, "lamp-a"))), Floor = binFloors[i] }
+ }, new CultRecordKey("bin" + i)));
seed.FlushAsync().Wait();
}
@@ -249,6 +258,46 @@ public sealed class ProductChoiceTests : IDisposable
Assert.InRange(MakerDraws(rig, rig.OutEven, 1, "lamp-a", 300), 80, 120);
}
+ // Ruling bin-share-by-floor: a bin of lamp-a's line takes the fraction of lamp-a's stock that would pass its floor, 1 - Phi(Floor),
+ // of lamp-a's preference, and the line keeps the rest, so the maker's presence for the design is what it was without the bin.
+ [Fact]
+ public void ABinTakesItsPassFractionOfItsMakersPreference()
+ {
+ var alone = Build("lamp-z", "lamp-a");
+ var preference = alone.Outfitter(alone.OutEven, 1, 0).ProductWeight(alone.Product("lamp-a"));
+ Assert.True(preference > 0);
+
+ var quarter = Build(new[] { .67f }, new[] { "lamp-z", "lamp-a" });
+ var generator = quarter.Outfitter(quarter.OutEven, 1, 0);
+ Assert.Equal(.2514f * preference, generator.ProductWeight(quarter.Product("lamp-a bin")), 3);
+ Assert.Equal(.7486f * preference, generator.ProductWeight(quarter.Product("lamp-a")), 3);
+ Assert.Equal(preference, generator.ProductWeight(quarter.Product("lamp-a bin")) + generator.ProductWeight(quarter.Product("lamp-a")), 3);
+
+ var half = Build(new[] { 0f }, new[] { "lamp-z", "lamp-a" });
+ generator = half.Outfitter(half.OutEven, 1, 0);
+ Assert.Equal(.5f * preference, generator.ProductWeight(half.Product("lamp-a bin")), 3);
+ Assert.Equal(.5f * preference, generator.ProductWeight(half.Product("lamp-a")), 3);
+
+ // The maker keeps its presence: Alpha's line and bin together draw as lamp-a alone did.
+ var lamp = quarter.Cache.GetByName("Lamp");
+ var alpha = Enumerable.Range(1, 300).Count(i => quarter.Cache.Get(quarter.Outfitter(quarter.OutEven, Seed(i), 0).ChooseProduct(lamp).Manufacturer).Name == "Alpha");
+ Assert.InRange(alpha, 180, 220);
+ }
+
+ // A line gives up the sum of its bins' pass fractions and no more: two top-half bins leave it nothing, and a third does not
+ // take it below nothing.
+ [Fact]
+ public void ALineKeepsWhatItsBinsLeaveAndNeverLessThanNothing()
+ {
+ var two = Build(new[] { 0f, .67f }, new[] { "lamp-z", "lamp-a" });
+ var alone = Build("lamp-z", "lamp-a");
+ var preference = alone.Outfitter(alone.OutEven, 1, 0).ProductWeight(alone.Product("lamp-a"));
+ Assert.Equal((1 - .5f - .2514f) * preference, two.Outfitter(two.OutEven, 1, 0).ProductWeight(two.Product("lamp-a")), 3);
+
+ var three = Build(new[] { 0f, 0f, 0f }, new[] { "lamp-z", "lamp-a" });
+ Assert.Equal(0f, three.Outfitter(three.OutEven, 1, 0).ProductWeight(three.Product("lamp-a")));
+ }
+
// A weight that read the allegiance alone would give 1 : .25 here, an 80% draw; the zone and allegiance together
// give 1 : .125 for alpha at its own zone, 89%.
[Fact]
From 2806b443c9c20f58c7ffc615d1e672f73b358d59 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:06:07 +0200
Subject: [PATCH 03/13] Census, roles-migrate and roles-backfill skip a bin: it
authors no roles, it reads its line's
RolesMigrate takes an optional root, as RolesBackfill does, so its bin skip has a test.
Co-Authored-By: Claude Sonnet 5.5
---
.../Aetheria.Shared.Tests/CatalogRoleTests.cs | 8 +--
.../RolesBackfillCommandTests.cs | 53 +++++++++++++++++++
tools/AetherDb/Program.cs | 10 ++--
3 files changed, 64 insertions(+), 7 deletions(-)
diff --git a/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs b/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs
index b50e0142d..10056b421 100644
--- a/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs
+++ b/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs
@@ -36,7 +36,7 @@ public sealed class CatalogRoleTests
}
// The whole of CB-R1 over every design in the catalog: each non-flat stat reads exactly one declared role, every
- // declared role is read, and every seller authors each role once. Weapons, and designs of a kind the tool keeps a role
+ // declared role is read, and every seller authors each role once (a bin authors none: it reads its line's). Weapons, and designs of a kind the tool keeps a role
// map for, must also carry a non-flat stat (a design with no stat at all has nothing for quality to vary).
[Fact]
public void ShippedCatalogRolesAreLive()
@@ -59,13 +59,15 @@ public sealed class CatalogRoleTests
broken.Add($"{design.Name}: declares [{string.Join(", ", declared)}] but non-flat stats read [{string.Join(", ", read)}]");
if (varying.Length == 0 && stats.Length > 0 && (design is WeaponItemData || Program.HasRoleMap(design)))
broken.Add($"{design.Name}: every stat is flat");
- foreach (var product in products.Where(p => p.Design.Key.Equals(cache.RefOf(design).Key)))
+ foreach (var product in products.Where(p => p.Bin == null && p.Design.Key.Equals(cache.RefOf(design).Key)))
{
var authored = (product.Roles ?? new List()).Select(r => r.Role).OrderBy(r => r, StringComparer.Ordinal).ToArray();
if (!authored.SequenceEqual(declared))
broken.Add($"{design.Name}: product \"{product.Name}\" authors [{string.Join(", ", authored)}], not each of [{string.Join(", ", declared)}] once");
}
}
+ foreach (var bin in products.Where(p => p.Bin != null))
+ if (bin.Roles?.Count > 0 || bin.Assembly != null) broken.Add($"bin \"{bin.Name}\" authors roles or an assembly of its own");
Assert.True(broken.Count == 0, string.Join("\n", broken));
}
@@ -264,7 +266,7 @@ public sealed class CatalogRoleTests
var profiles = new Dictionary(Program.MakerProfiles) { ["Pirates"] = (.45f, .22f) };
foreach (var design in new[] { autocannon, lrm, srm, mine, drive })
{
- var sellers = cache.GetAll().Where(p => p.Design.Key.Equals(cache.RefOf(design).Key)).ToArray();
+ var sellers = cache.GetAll().Where(p => p.Bin == null && p.Design.Key.Equals(cache.RefOf(design).Key)).ToArray();
Assert.NotEmpty(sellers);
foreach (var product in sellers)
{
diff --git a/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs b/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs
index 1f276c639..1f0f974f5 100644
--- a/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs
+++ b/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs
@@ -207,6 +207,59 @@ public sealed class RolesBackfillCommandTests : IDisposable
finally { db.Cache.Dispose(); }
}
+ // A bin authors no roles (ProductBin): the backfill that restores its line's missing roles leaves the bin's list empty.
+ [Fact]
+ public void A_binned_seller_gains_no_roles()
+ {
+ Reset(flattened: false, unnamed: false);
+ var db = AetherDb.Open(catalogWritable: true, root: _root);
+ var autocannon = db.Cache.GetAll().Single(weapon => weapon.Name == "Autocannon");
+ var seller = db.Cache.GetAll().OrderBy(p => p.Name, StringComparer.Ordinal).First(p => p.Design.Key.Equals(db.Cache.RefOf(autocannon).Key) && p.Roles.Count == 2);
+ var sellerName = seller.Name;
+ seller.Roles = new List { seller.Roles.OrderBy(r => r.Role, StringComparer.Ordinal).First() };
+ var bin = new FactionProductData
+ {
+ Name = "Binned " + sellerName, Design = seller.Design, Manufacturer = seller.Manufacturer,
+ Bin = new ProductBin { Line = db.Cache.RefOf(seller), Floor = 0f }
+ };
+ db.Cache.Commit(batch =>
+ {
+ batch.Upsert(typeof(FactionProductData), seller, db.Cache.RefOf(seller).Key);
+ batch.Upsert(typeof(FactionProductData), bin);
+ });
+ db.Cache.Dispose();
+
+ Assert.Equal(0, Program.RolesBackfill(apply: true, root: _root));
+ db = AetherDb.Open(root: _root);
+ try
+ {
+ Assert.Equal(2, db.Cache.GetAll().Single(p => p.Name == sellerName).Roles.Count);
+ Assert.Empty(db.Cache.GetAll().Single(p => p.Name == "Binned " + sellerName).Roles);
+ }
+ finally { db.Cache.Dispose(); }
+ }
+
+ // roles-migrate authors a role on every product that sells a design lacking one; a bin sells none of its own.
+ [Fact]
+ public void A_binned_seller_gains_no_roles_from_roles_migrate()
+ {
+ var db = AetherDb.Open(catalogWritable: true, root: _root);
+ var autocannon = db.Cache.GetAll().Single(weapon => weapon.Name == "Autocannon");
+ var seller = db.Cache.GetAll().OrderBy(p => p.Name, StringComparer.Ordinal).First(p => p.Design.Key.Equals(db.Cache.RefOf(autocannon).Key));
+ var bin = new FactionProductData
+ {
+ Name = "Binned " + seller.Name, Design = seller.Design, Manufacturer = seller.Manufacturer,
+ Bin = new ProductBin { Line = db.Cache.RefOf(seller), Floor = 0f }
+ };
+ db.Cache.Commit(batch => batch.Upsert(typeof(FactionProductData), bin));
+ db.Cache.Dispose();
+
+ Assert.Equal(0, Program.RolesMigrate(apply: true, root: _root));
+ db = AetherDb.Open(root: _root);
+ try { Assert.Empty(db.Cache.GetAll().Single(p => p.Name == "Binned " + seller.Name).Roles); }
+ finally { db.Cache.Dispose(); }
+ }
+
[Fact]
public void A_design_the_catalog_does_not_hold_refuses_and_lands_nothing()
{
diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs
index 5df9ed99c..46a812ba2 100644
--- a/tools/AetherDb/Program.cs
+++ b/tools/AetherDb/Program.cs
@@ -132,7 +132,8 @@ public static class Program
foreach (var item in items.Where(i => i.Roles != null && i.Roles.Count > 0))
{
var design = db.Cache.RefOf(item).Key;
- var sellers = products.Where(p => p.Design.Key.Equals(design)).ToArray();
+ // A bin authors no spread: it reads its line's (ProductBin).
+ var sellers = products.Where(p => p.Bin == null && p.Design.Key.Equals(design)).ToArray();
foreach (var product in sellers)
{
var covered = new HashSet((product.Roles ?? new List()).Select(r => r.Role));
@@ -309,9 +310,9 @@ public static class Program
// Dry run unless passed "apply", which alone opens the catalog writable; every record must derive cleanly or
// nothing is written, the same all-or-nothing contract ShieldMigrate and BrownoutMigrate already use. Prints
// the operator review table (grouped by item kind) either way.
- private static int RolesMigrate(bool apply)
+ public static int RolesMigrate(bool apply, string root = null)
{
- var db = AetherDb.Open(catalogWritable: apply);
+ var db = AetherDb.Open(catalogWritable: apply, root: root);
var items = db.Cache.GetAll().ToArray();
var products = db.Cache.GetAll().ToArray();
var changedDesigns = new List<(object Document, CultRecordKey Key)>();
@@ -366,6 +367,7 @@ public static class Program
var byDesign = items.ToDictionary(i => db.Cache.RefOf(i).Key, i => i);
foreach (var product in products.OrderBy(p => p.Name, StringComparer.Ordinal))
{
+ if (product.Bin != null) continue; // a bin authors no roles; the mint reads its line's
if (!byDesign.TryGetValue(product.Design.Key, out var design) || design.Roles == null || design.Roles.Count == 0)
continue;
var existing = new HashSet((product.Roles ?? new List()).Select(r => r.Role));
@@ -2218,7 +2220,7 @@ public static class Program
var changedProducts = new List<(FactionProductData Document, CultRecordKey Key)>();
foreach (var design in targets)
- foreach (var product in products.Where(p => p.Design.Key.Equals(db.Cache.RefOf(design).Key)).OrderBy(p => p.Name, StringComparer.Ordinal))
+ foreach (var product in products.Where(p => p.Bin == null && p.Design.Key.Equals(db.Cache.RefOf(design).Key)).OrderBy(p => p.Name, StringComparer.Ordinal))
{
var maker = db.Cache.Get(product.Manufacturer);
var profile = maker?.Name == "Pirates" ? PirateProfile
From 6768cdcb954d0ff018d82b70eebf58f1c0588d2c Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:08:56 +0200
Subject: [PATCH 04/13] product-lines tiers: authors the six bins from a table
of (line, bin, floor, text)
A bin gets its line's design and maker, Bin = { Line, Floor }, and no roles or assembly. Resolve-then-write
as the other lines do; a replay is a no-op. The catalog is not yet applied.
Co-Authored-By: Claude Sonnet 5.5
---
.../ProductTiersCommandTests.cs | 177 ++++++++++++++++++
tools/AetherDb/ProductLinesCommand.cs | 88 ++++++++-
tools/AetherDb/Program.cs | 2 +-
3 files changed, 263 insertions(+), 4 deletions(-)
create mode 100644 tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs
diff --git a/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs b/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs
new file mode 100644
index 000000000..856cca7dc
--- /dev/null
+++ b/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs
@@ -0,0 +1,177 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using GameCult.Caching;
+using GameCult.Caching.MessagePack;
+using Xunit;
+
+// AetherDb's product-lines tiers command (cut product-tiers; rulings maker-design-tiers-allowed, bin-share-by-floor), run on a
+// scratch copy of the shipped catalog with the table's bins removed from it. A standalone class: a tier row is not a Row.
+public sealed class ProductTiersCommandTests : IDisposable
+{
+ private static readonly string[] Apply = { "tiers", "apply" };
+ private static readonly string[] DryRun = { "tiers" };
+
+ private readonly string _root = Path.Combine(Path.GetTempPath(), "product-tiers-command-" + Guid.NewGuid().ToString("N"));
+ private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc");
+ private readonly string[] _names = ProductLinesCommand.TierTable().Select(r => r.Name).ToArray();
+
+ public ProductTiersCommandTests()
+ {
+ Directory.CreateDirectory(Path.Combine(_root, "GameData"));
+ Directory.CreateDirectory(Path.Combine(_root, "Assets", "Resources"));
+ var shipped = AetherDb.FindRoot();
+ File.Copy(Path.Combine(shipped, "GameData", "Aetheria.cc"), CatalogPath);
+ File.Copy(Path.Combine(shipped, "Assets", "Resources", "Settings.asset"), Path.Combine(_root, "Assets", "Resources", "Settings.asset"));
+ using var cache = Open();
+ foreach (var bin in cache.GetAll().Where(p => _names.Contains(p.Name)).ToArray()) Assert.True(cache.Remove(cache.RefOf(bin).Key));
+ cache.FlushAsync().Wait();
+ }
+
+ public void Dispose() => Directory.Delete(_root, true);
+
+ private CultCache Open()
+ {
+ var cache = new CultCache(TestCatalog.Registry());
+ cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes);
+ return cache;
+ }
+
+ private Dictionary Records() =>
+ CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(CatalogPath)).Records.ToDictionary(r => r.Key, StringComparer.Ordinal);
+
+ private FactionProductData[] Bins()
+ {
+ using var cache = Open();
+ return cache.GetAll().Where(p => _names.Contains(p.Name)).ToArray();
+ }
+
+ // Edits the scratch catalog before a refusal case; the bins the table authors stay out of it.
+ private void Edit(Action edit)
+ {
+ using var cache = Open();
+ edit(cache);
+ cache.FlushAsync().Wait();
+ }
+
+ // The command refuses the row with a message naming the rule, and the catalog's bytes are as they were.
+ private void Refused(ProductLinesCommand.TierRow row, string phrase)
+ {
+ var before = File.ReadAllBytes(CatalogPath);
+ var error = Assert.Throws(() => ProductLinesCommand.RunTiers(new[] { row }, true, _root));
+ Assert.Contains(phrase, error.Message);
+ Assert.Equal(before, File.ReadAllBytes(CatalogPath));
+ }
+
+ [Fact]
+ public void A_dry_run_writes_nothing_and_apply_lands_the_table_once_and_replays_as_a_no_op()
+ {
+ var before = File.ReadAllBytes(CatalogPath);
+ Assert.Empty(Bins());
+
+ Assert.Equal(0, ProductLinesCommand.Run(DryRun, _root));
+ Assert.Equal(before, File.ReadAllBytes(CatalogPath));
+
+ Assert.Equal(0, ProductLinesCommand.Run(Apply, _root));
+ var landed = File.ReadAllBytes(CatalogPath);
+ Assert.NotEqual(before, landed);
+ Assert.Equal(6, Bins().Length);
+
+ Assert.Equal(0, ProductLinesCommand.Run(Apply, _root));
+ Assert.Equal(landed, File.ReadAllBytes(CatalogPath));
+ Assert.Equal(0, ProductLinesCommand.Run(DryRun, _root));
+ Assert.Equal(landed, File.ReadAllBytes(CatalogPath));
+ }
+
+ [Fact]
+ public void Each_bin_is_its_lines_design_and_maker_at_its_floor_with_nothing_of_its_own()
+ {
+ Assert.Equal(0, ProductLinesCommand.Run(Apply, _root));
+ using var cache = Open();
+ foreach (var row in ProductLinesCommand.TierTable())
+ {
+ var bin = Assert.Single(cache.GetAll(), p => p.Name == row.Name);
+ var line = Assert.Single(cache.GetAll(), p => p.Name == row.Line);
+ Assert.Equal(cache.RefOf(line).Key, bin.Bin.Line.Key);
+ Assert.Equal(line.Design.Key, bin.Design.Key);
+ Assert.Equal(line.Manufacturer.Key, bin.Manufacturer.Key);
+ Assert.Equal(row.Floor, bin.Bin.Floor);
+ Assert.Equal(row.Description, bin.Description);
+ Assert.Empty(bin.Roles);
+ Assert.Null(bin.Assembly);
+ Assert.Null(line.Bin);
+ }
+ }
+
+ [Fact]
+ public void Apply_writes_only_the_records_it_authors()
+ {
+ var before = Records();
+ Assert.Equal(0, ProductLinesCommand.Run(Apply, _root));
+ var after = Records();
+ Assert.Equal(before.Count + 6, after.Count);
+ foreach (var (key, record) in before)
+ {
+ Assert.True(after.TryGetValue(key, out var kept), "a record the command did not author is gone");
+ Assert.Equal(record.StoredAt, kept.StoredAt);
+ Assert.True(record.Payload.AsSpan().SequenceEqual(kept.Payload), "a record the command did not author has changed bytes");
+ }
+ }
+
+ [Fact]
+ public void A_bin_already_authored_at_another_floor_is_refused()
+ {
+ Assert.Equal(0, ProductLinesCommand.Run(Apply, _root));
+ Refused(new ProductLinesCommand.TierRow("Sparrow", "Goldcrest", .5f, "x"), "already belongs");
+ }
+
+ [Fact]
+ public void A_line_the_catalog_does_not_hold_is_refused()
+ {
+ Refused(new ProductLinesCommand.TierRow("No Such Line", "Fresh Bin", 0f, "x"), "found 0");
+ }
+
+ [Fact]
+ public void An_ambiguous_line_is_refused()
+ {
+ Edit(cache =>
+ {
+ var sparrow = cache.GetAll().Single(p => p.Name == "Sparrow");
+ cache.Upsert(new FactionProductData { Name = "Sparrow", Design = sparrow.Design, Manufacturer = sparrow.Manufacturer });
+ });
+ Refused(new ProductLinesCommand.TierRow("Sparrow", "Fresh Bin", 0f, "x"), "found 2");
+ }
+
+ [Fact]
+ public void A_line_that_is_itself_a_bin_is_refused()
+ {
+ Assert.Equal(0, ProductLinesCommand.Run(Apply, _root));
+ Refused(new ProductLinesCommand.TierRow("Goldcrest", "Fresh Bin", 0f, "x"), "itself a bin");
+ }
+
+ [Theory]
+ [InlineData(-.1f)]
+ [InlineData(float.NaN)]
+ [InlineData(float.PositiveInfinity)]
+ public void A_floor_that_is_negative_or_not_finite_is_refused(float floor)
+ {
+ Refused(new ProductLinesCommand.TierRow("Sparrow", "Fresh Bin", floor, "x"), "finite number");
+ }
+
+ [Theory]
+ [InlineData("Spotlight")]
+ [InlineData("SPOTLIGHT")]
+ public void A_bin_name_held_by_another_product_is_refused_ignoring_case(string name)
+ {
+ Refused(new ProductLinesCommand.TierRow("Sparrow", name, 0f, "x"), "already belongs");
+ }
+
+ [Fact]
+ public void A_bin_name_that_is_a_designs_is_refused()
+ {
+ string design;
+ using (var cache = Open()) design = cache.GetAll().First().Name;
+ Refused(new ProductLinesCommand.TierRow("Sparrow", design.ToUpperInvariant(), 0f, "x"), "name of a design");
+ }
+}
diff --git a/tools/AetherDb/ProductLinesCommand.cs b/tools/AetherDb/ProductLinesCommand.cs
index 911f01ffb..5ee316a38 100644
--- a/tools/AetherDb/ProductLinesCommand.cs
+++ b/tools/AetherDb/ProductLinesCommand.cs
@@ -16,9 +16,13 @@ using GameCult.Caching;
// carries no quality numbers. A row already in the catalog (same name, design and maker) is skipped, so the same command is the
// first apply, the second apply and the replay onto a merged catalog. A maker that already sells the design is not refused: a
// tier is allowed. Dry run unless passed "apply".
+// product-lines tiers [apply] (cut product-tiers; rulings maker-design-tiers-allowed, bin-share-by-floor): authors the bins of
+// those lines. A tier row is (line product name, bin name, floor, text); the bin gets Design and Manufacturer from its line,
+// Bin = { Line, Floor }, and no Roles or Assembly of its own: the mint derives both from the line.
public static class ProductLinesCommand
{
internal sealed record Row(string Design, string Name, string Maker, string Description);
+ internal sealed record TierRow(string Line, string Name, float Floor, string Description);
private static readonly Row[] Weapons =
{
@@ -85,6 +89,19 @@ public static class ProductLinesCommand
new("Industrial Thermostatic Heater", "WarmUp+-", "NiteLife", "Room temperature, whatever the room."),
};
+ // The bins of the shipped lines. The names and text are drafts the operator may rename.
+ private static readonly TierRow[] Tiers =
+ {
+ new("Sparrow", "Goldcrest", .67f, "Only the Sparrows that sang on the test bench. The rest still fly."),
+ new("Spotlight", "Spotlight Premiere", 0f, "Hand-picked for opening night."),
+ new("Rattler", "Rattler Select", 0f, "Pick of the batch. Still loud."),
+ new("SM-6 Marksman", "SM-6C Marksman Certified", 0f, "Individually certified to published tolerance.*"),
+ new("PowerHour 2.0", "PowerHour 2.0 VIP", .67f, "Skip the line. Same party, better parts."),
+ new("Juggernaut", "Juggernaut Guards", 0f, "The column's best drives go to the front of the column."),
+ };
+
+ internal static IReadOnlyList TierTable() => Tiers;
+
// Every line the command knows, by the word that follows "product-lines".
private static readonly Dictionary Lines = new() { ["weapons"] = Weapons, ["minor-powers"] = MinorPowers, ["gear"] = Gear };
@@ -93,16 +110,19 @@ public static class ProductLinesCommand
// A product's record key is derived from its name, so the same table lands the same records every time it is applied: the
// replay at a merge reproduces the branch's blob, and nothing that orders products by key reads a different order after it.
- private static CultRecordKey KeyOf(Row row) =>
- new(new Guid(SHA256.HashData(Encoding.UTF8.GetBytes("product-line:" + row.Name)).AsSpan(0, 16)).ToString("N"));
+ private static CultRecordKey KeyOf(Row row) => KeyOf(row.Name);
+
+ private static CultRecordKey KeyOf(string name) =>
+ new(new Guid(SHA256.HashData(Encoding.UTF8.GetBytes("product-line:" + name)).AsSpan(0, 16)).ToString("N"));
public static int Run(string[] args, string root = null)
{
var apply = args.Contains("apply");
var line = args.FirstOrDefault(a => a != "apply");
+ if (line == "tiers") return RunTiers(Tiers, apply, root);
if (line == null || !Lines.TryGetValue(line, out var rows))
{
- Console.WriteLine($"usage: product-lines {string.Join("|", Lines.Keys)} [apply]");
+ Console.WriteLine($"usage: product-lines {string.Join("|", Lines.Keys)}|tiers [apply]");
return 1;
}
@@ -178,4 +198,66 @@ public static class ProductLinesCommand
Console.WriteLine($"\nAuthored {missing.Length} products in Aetheria.cc");
return 0;
}
+
+ // The tiers line has Run's shape: resolve every row first, so a refusal leaves the catalog untouched, then land the missing
+ // bins in one batch that expects each of their keys absent. A bin already in the catalog (this line, this floor) is skipped,
+ // so the same command is the first apply and the replay onto a merged catalog.
+ internal static int RunTiers(IReadOnlyList tiers, bool apply, string root)
+ {
+ var db = AetherDb.Open(catalogWritable: apply, root: root);
+ var designs = db.Cache.GetAll().ToArray();
+ var products = db.Cache.GetAll().ToArray();
+
+ var resolved = new List<(TierRow Row, FactionProductData Line, bool Present)>();
+ for (var i = 0; i < tiers.Count; i++)
+ {
+ var row = tiers[i];
+ var lines = products.Where(p => p.Name == row.Line).ToArray();
+ if (lines.Length != 1) throw new InvalidOperationException($"Tier row {i}: expected exactly one line product, found {lines.Length}.");
+ var parent = lines[0];
+ if (parent.Bin != null) throw new InvalidOperationException($"Tier row {i}: the line is itself a bin.");
+ if (!float.IsFinite(row.Floor) || row.Floor < 0) throw new InvalidOperationException($"Tier row {i}: the floor is not a finite number of at least 0.");
+
+ var named = products.Where(p => string.Equals(p.Name, row.Name, StringComparison.OrdinalIgnoreCase)).ToArray();
+ var present = named.Length == 1 && named[0].Name == row.Name && named[0].Bin != null &&
+ named[0].Bin.Line.Key.Equals(db.Cache.RefOf(parent).Key) && named[0].Bin.Floor == row.Floor;
+ if (named.Length != 0 && !present) throw new InvalidOperationException($"Tier row {i}: the bin name already belongs to another product.");
+ if (!present && designs.Any(d => string.Equals(d.Name, row.Name, StringComparison.OrdinalIgnoreCase)))
+ throw new InvalidOperationException($"Tier row {i}: the bin name is the name of a design.");
+ resolved.Add((row, parent, present));
+ }
+
+ var missing = resolved.Where(r => !r.Present).ToArray();
+ if (missing.Length == 0)
+ {
+ Console.WriteLine("The tiers are already authored; nothing to do.");
+ return 0;
+ }
+
+ Console.WriteLine("Product tiers:");
+ foreach (var (row, parent, present) in resolved)
+ Console.WriteLine($" {(present ? "present" : "new ")} {row.Line} -> \"{row.Name}\" by {db.Cache.Get(parent.Manufacturer)?.ShortName}, floor {row.Floor:0.##}: {row.Description}");
+ if (!apply)
+ {
+ Console.WriteLine($"\nDry run. Pass \"apply\" to author {missing.Length} bins.");
+ return 0;
+ }
+
+ if (!db.Cache.Commit(batch =>
+ {
+ foreach (var (row, _, _) in missing) batch.Expect(KeyOf(row.Name), null);
+ foreach (var (row, parent, _) in missing)
+ batch.Upsert(typeof(FactionProductData), new FactionProductData
+ {
+ Name = row.Name,
+ Description = row.Description,
+ Design = parent.Design,
+ Manufacturer = parent.Manufacturer,
+ Bin = new ProductBin { Line = db.Cache.RefOf(parent), Floor = row.Floor },
+ }, KeyOf(row.Name));
+ }))
+ throw new InvalidOperationException("The catalog changed while the tiers were being authored; nothing was written. Run it again.");
+ Console.WriteLine($"\nAuthored {missing.Length} bins in Aetheria.cc");
+ return 0;
+ }
}
diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs
index 46a812ba2..d88718817 100644
--- a/tools/AetherDb/Program.cs
+++ b/tools/AetherDb/Program.cs
@@ -63,7 +63,7 @@ public static class Program
return PlaceNamesCommand.Run(args.Skip(1).ToArray());
case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out);
default:
- Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear [apply], doctrine-catalog [apply], assembly-from-maker [apply]");
+ Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply]");
return 1;
}
}
From 9b7a1c00dc2b7034a16a61c436463008f4fa9996 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:11:11 +0200
Subject: [PATCH 05/13] Catalog: the six tier bins (product-lines tiers apply);
TierRowsAreShipped; the tier step pinned from both sides
Aetheria.cc written only by the command, on the pinned CultLib. Replay the command at merges; never merge the blob.
Co-Authored-By: Claude Sonnet 5.5
---
GameData/Aetheria.cc | 4 +-
tests/Aetheria.Shared.Tests/LoadoutTests.cs | 7 +++
.../ProductLinesCatalogTests.cs | 59 +++++++++++++++++++
3 files changed, 68 insertions(+), 2 deletions(-)
diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc
index 9f9ff69e5..a2ddaad5d 100644
--- a/GameData/Aetheria.cc
+++ b/GameData/Aetheria.cc
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:22342ea360c7f453de500da8650e6a23648f8c1250eb22e724765bbcf619eac0
-size 186037
+oid sha256:c327ef1bbabbd34ddb7b9f643f5db8cc54aa5730f232bd42a1fa5f2b03973e89
+size 189753
diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs
index 5673b205d..86bf4e698 100644
--- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs
+++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs
@@ -622,6 +622,13 @@ public sealed class LoadoutTests : IDisposable
var result = items.GetTier((CraftedItemInstance) items.CreateInstance(lot));
Assert.Equal(settings.Tiers[0].Name, result.tier.Name);
Assert.Equal(2, result.upgrades);
+
+ // The same width from the other side (follow-up tier-upgrade-step-two-sided): two steps less a hair is one upgrade, so a
+ // step narrower than .0499 reads two and a wider one reads none.
+ var under = items.Lots.Add(new Lot { Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = settings.Tiers[0].Quality + 2 * .0499f - .0001f, Roles = new List() });
+ var below = items.GetTier((CraftedItemInstance) items.CreateInstance(under));
+ Assert.Equal(settings.Tiers[0].Name, below.tier.Name);
+ Assert.Equal(1, below.upgrades);
}
// S7 (docs/stats-and-power-cut.md Cut 1 findings): a Quality term must raise the lot's quality to its own
diff --git a/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs
index 6a91bb457..7ce78218f 100644
--- a/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs
+++ b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs
@@ -2,6 +2,8 @@ using System;
using System.Collections.Generic;
using System.Linq;
using GameCult.Caching;
+using GameCult.Caching.MessagePack;
+using MessagePack;
using Xunit;
// The weapon product lines the shipped catalog holds (campaign aetheria-release, cut product-lines-weapons; rulings
@@ -99,4 +101,61 @@ public sealed class ProductLinesCatalogTests
}
}
}
+ // Cut product-tiers: the six bins of the command's tier table are in the catalog, each a single product that names its line.
+ [Fact]
+ public void TierRowsAreShipped()
+ {
+ using var cache = RestoredHullsTests.OpenCatalog();
+ var table = ProductLinesCommand.TierTable();
+ Assert.Equal(6, table.Count);
+ foreach (var row in table)
+ {
+ var bin = Assert.Single(cache.GetAll(), p => p.Name == row.Name);
+ var line = Assert.Single(cache.GetAll(), p => p.Name == row.Line);
+ Assert.Equal(cache.RefOf(line).Key, bin.Bin.Line.Key);
+ Assert.Equal(line.Design.Key, bin.Design.Key);
+ Assert.Equal(line.Manufacturer.Key, bin.Manufacturer.Key);
+ Assert.Equal(row.Floor, bin.Bin.Floor);
+ Assert.Equal(row.Description, bin.Description);
+ Assert.Empty(bin.Roles);
+ Assert.Null(bin.Assembly);
+ }
+ }
+
+ // A bin bins an unbinned line of its own design and maker, and the catalog holds no bin the table does not author.
+ [Fact]
+ public void BinsBinAnUnbinnedLineOfTheirOwnDesignAndMaker()
+ {
+ using var cache = RestoredHullsTests.OpenCatalog();
+ var bins = cache.GetAll().Where(p => p.Bin != null).ToArray();
+ Assert.Equal(ProductLinesCommand.TierTable().Select(r => r.Name).OrderBy(n => n, StringComparer.Ordinal), bins.Select(b => b.Name).OrderBy(n => n, StringComparer.Ordinal));
+ foreach (var bin in bins)
+ {
+ var line = cache.Get(bin.Bin.Line);
+ Assert.NotNull(line);
+ Assert.Null(line.Bin);
+ Assert.Equal(line.Design.Key, bin.Design.Key);
+ Assert.Equal(line.Manufacturer.Key, bin.Manufacturer.Key);
+ Assert.True(bin.Bin.Floor >= 0 && float.IsFinite(bin.Bin.Floor));
+ }
+ }
+
+ // FactionProductData slot 7 is additive under the same schema id: a bin round-trips, and a record written before the slot
+ // existed (an array one element shorter) reads no bin.
+ [Fact]
+ public void BinIsSlotSevenAndACatalogWithoutItReadsNoBin()
+ {
+ var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(FactionProductData).Assembly);
+ var binned = MessagePackSerializer.Deserialize(
+ MessagePackSerializer.Serialize(new FactionProductData { Name = "Binned", Bin = new ProductBin { Floor = .67f } }, options), options);
+ Assert.Equal(.67f, binned.Bin.Floor);
+
+ var bytes = MessagePackSerializer.Serialize(new FactionProductData { Name = "Old", Description = "d" }, options);
+ Assert.Equal(0x98, bytes[0]);
+ Assert.Equal(0xC0, bytes[^1]);
+ var before = new byte[] { 0x97 }.Concat(bytes.Skip(1).Take(bytes.Length - 2)).ToArray();
+ var old = MessagePackSerializer.Deserialize(before, options);
+ Assert.Equal("Old", old.Name);
+ Assert.Null(old.Bin);
+ }
}
From aec7b3c019edfdc13e4b6c71cee04d9497a98be0 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:16:27 +0200
Subject: [PATCH 06/13] product-lines tiers lists every row, present or new,
before it reports there is nothing to do
Co-Authored-By: Claude Sonnet 5.5
---
tools/AetherDb/ProductLinesCommand.cs | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/tools/AetherDb/ProductLinesCommand.cs b/tools/AetherDb/ProductLinesCommand.cs
index 5ee316a38..2c82d5ebf 100644
--- a/tools/AetherDb/ProductLinesCommand.cs
+++ b/tools/AetherDb/ProductLinesCommand.cs
@@ -228,15 +228,15 @@ public static class ProductLinesCommand
}
var missing = resolved.Where(r => !r.Present).ToArray();
- if (missing.Length == 0)
- {
- Console.WriteLine("The tiers are already authored; nothing to do.");
- return 0;
- }
-
Console.WriteLine("Product tiers:");
foreach (var (row, parent, present) in resolved)
Console.WriteLine($" {(present ? "present" : "new ")} {row.Line} -> \"{row.Name}\" by {db.Cache.Get(parent.Manufacturer)?.ShortName}, floor {row.Floor:0.##}: {row.Description}");
+ if (missing.Length == 0)
+ {
+ Console.WriteLine("\nThe tiers are already authored; nothing to do.");
+ return 0;
+ }
+
if (!apply)
{
Console.WriteLine($"\nDry run. Pass \"apply\" to author {missing.Length} bins.");
From fb7b95fb0659fbf8bff77787bc997c4662d2b47f Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:19:00 +0200
Subject: [PATCH 07/13] ProductChoiceTests: a bin takes nothing from another
maker's product
Co-Authored-By: Claude Sonnet 5.5
---
tests/Aetheria.Shared.Tests/ProductChoiceTests.cs | 2 ++
1 file changed, 2 insertions(+)
diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs
index a11708393..5061d4603 100644
--- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs
+++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs
@@ -272,6 +272,8 @@ public sealed class ProductChoiceTests : IDisposable
Assert.Equal(.2514f * preference, generator.ProductWeight(quarter.Product("lamp-a bin")), 3);
Assert.Equal(.7486f * preference, generator.ProductWeight(quarter.Product("lamp-a")), 3);
Assert.Equal(preference, generator.ProductWeight(quarter.Product("lamp-a bin")) + generator.ProductWeight(quarter.Product("lamp-a")), 3);
+ // Another maker's product, whose line has no bin, weighs what it did without any bin in the catalog.
+ Assert.Equal(alone.Outfitter(alone.OutEven, 1, 0).ProductWeight(alone.Product("lamp-z")), generator.ProductWeight(quarter.Product("lamp-z")), 3);
var half = Build(new[] { 0f }, new[] { "lamp-z", "lamp-a" });
generator = half.Outfitter(half.OutEven, 1, 0);
From beb733f4b50f556012983d7bb60d43760721c891 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:46:05 +0200
Subject: [PATCH 08/13] Census role gaps become RoleGaps, so a bin skipped by
the census has a test; a bin authored on another line is refused
Co-Authored-By: Claude Sonnet 5.5
---
.../ProductTiersCommandTests.cs | 7 ++++
.../RolesBackfillCommandTests.cs | 30 ++++++++++++++++
tools/AetherDb/Program.cs | 34 ++++++++++++-------
3 files changed, 58 insertions(+), 13 deletions(-)
diff --git a/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs b/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs
index 856cca7dc..2899737e0 100644
--- a/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs
+++ b/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs
@@ -126,6 +126,13 @@ public sealed class ProductTiersCommandTests : IDisposable
Refused(new ProductLinesCommand.TierRow("Sparrow", "Goldcrest", .5f, "x"), "already belongs");
}
+ [Fact]
+ public void A_bin_already_authored_on_another_line_is_refused()
+ {
+ Assert.Equal(0, ProductLinesCommand.Run(Apply, _root));
+ Refused(new ProductLinesCommand.TierRow("Spotlight", "Goldcrest", .67f, "x"), "already belongs");
+ }
+
[Fact]
public void A_line_the_catalog_does_not_hold_is_refused()
{
diff --git a/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs b/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs
index 1f0f974f5..31152b63c 100644
--- a/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs
+++ b/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs
@@ -239,6 +239,36 @@ public sealed class RolesBackfillCommandTests : IDisposable
finally { db.Cache.Dispose(); }
}
+ // The census reports a seller that authors no spread for a role of its design as a gap, and never a bin: a bin reads its line's.
+ [Fact]
+ public void The_census_reports_a_roleless_seller_and_never_a_bin()
+ {
+ var db = AetherDb.Open(catalogWritable: true, root: _root);
+ var autocannon = db.Cache.GetAll().Single(weapon => weapon.Name == "Autocannon");
+ var seller = db.Cache.GetAll().OrderBy(p => p.Name, StringComparer.Ordinal).First(p => p.Design.Key.Equals(db.Cache.RefOf(autocannon).Key));
+ var sellerName = seller.Name;
+ seller.Roles = new List();
+ db.Cache.Commit(batch =>
+ {
+ batch.Upsert(typeof(FactionProductData), seller, db.Cache.RefOf(seller).Key);
+ batch.Upsert(typeof(FactionProductData), new FactionProductData
+ {
+ Name = "Binned " + sellerName, Design = seller.Design, Manufacturer = seller.Manufacturer,
+ Bin = new ProductBin { Line = db.Cache.RefOf(seller), Floor = 0f }
+ });
+ });
+ db.Cache.Dispose();
+
+ db = AetherDb.Open(root: _root);
+ try
+ {
+ var gaps = Program.RoleGaps(db.Cache, db.Cache.GetAll().ToArray(), db.Cache.GetAll().ToArray()).ToArray();
+ Assert.Contains(gaps, gap => gap.Product.Name == sellerName && gap.Missing.Length == 2);
+ Assert.DoesNotContain(gaps, gap => gap.Product.Name == "Binned " + sellerName);
+ }
+ finally { db.Cache.Dispose(); }
+ }
+
// roles-migrate authors a role on every product that sells a design lacking one; a bin sells none of its own.
[Fact]
public void A_binned_seller_gains_no_roles_from_roles_migrate()
diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs
index d88718817..7a28b1aa0 100644
--- a/tools/AetherDb/Program.cs
+++ b/tools/AetherDb/Program.cs
@@ -129,25 +129,33 @@ public static class Program
// or a product added after roles landed), so every sold, role-bearing design is checked against every
// product that actually sells it, not only the ones this run's roles-migrate happened to touch.
var roleGaps = 0;
- foreach (var item in items.Where(i => i.Roles != null && i.Roles.Count > 0))
+ foreach (var (product, item, missing) in RoleGaps(db.Cache, items, products))
{
- var design = db.Cache.RefOf(item).Key;
- // A bin authors no spread: it reads its line's (ProductBin).
- var sellers = products.Where(p => p.Bin == null && p.Design.Key.Equals(design)).ToArray();
- foreach (var product in sellers)
- {
- var covered = new HashSet((product.Roles ?? new List()).Select(r => r.Role));
- var missing = item.Roles.Select(r => r.Name).Where(r => !covered.Contains(r)).ToArray();
- if (missing.Length == 0) continue;
- roleGaps++;
- Console.WriteLine($" GAP: {product.Name} sells {item.Name} but authors no spread for: {string.Join(", ", missing)}" +
- " -- a newly minted lot rolls the ProductRole default (mean .5) for these, not this maker's own quality");
- }
+ roleGaps++;
+ Console.WriteLine($" GAP: {product.Name} sells {item.Name} but authors no spread for: {string.Join(", ", missing)}" +
+ " -- a newly minted lot rolls the ProductRole default (mean .5) for these, not this maker's own quality");
}
Console.WriteLine($"\n{roleGaps} (product, role) pairs where a sold, role-bearing design's product authors no matching spread");
return 0;
}
+ // Every seller of a role-bearing design that authors no spread for one of its roles. A bin authors no spread: it reads its
+ // line's (ProductBin), so it is never a gap.
+ internal static IEnumerable<(FactionProductData Product, EquippableItemData Design, string[] Missing)> RoleGaps(
+ GameCult.Caching.CultCache cache, IEnumerable items, IEnumerable products)
+ {
+ foreach (var item in items.Where(i => i.Roles != null && i.Roles.Count > 0))
+ {
+ var design = cache.RefOf(item).Key;
+ foreach (var product in products.Where(p => p.Bin == null && p.Design.Key.Equals(design)))
+ {
+ var covered = new HashSet((product.Roles ?? new List()).Select(r => r.Role));
+ var missing = item.Roles.Select(r => r.Name).Where(r => !covered.Contains(r)).ToArray();
+ if (missing.Length > 0) yield return (product, item, missing);
+ }
+ }
+ }
+
// Cut 7 (docs/stats-and-power-cut.md): "products author quality, designs author roles." Zero designs
// declared a role and zero products authored role quality before this command; every field->role table below
// is the one place that content is decided, so the operator reviews a legible table instead of hand-picking
From 9457e4c8f93e90fb5e6b6857667f7bd9d2f097f3 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 19:51:40 +0200
Subject: [PATCH 09/13] ProductBinTests: the search's 64-draw bound and its
stop at the first clearing draw, and the missing-line log, are pinned
Co-Authored-By: Claude Sonnet 5.5
---
.../Aetheria.Shared.Tests/ProductBinTests.cs | 25 ++++++++++++++++++-
1 file changed, 24 insertions(+), 1 deletion(-)
diff --git a/tests/Aetheria.Shared.Tests/ProductBinTests.cs b/tests/Aetheria.Shared.Tests/ProductBinTests.cs
index 44bbfe077..03762fdf2 100644
--- a/tests/Aetheria.Shared.Tests/ProductBinTests.cs
+++ b/tests/Aetheria.Shared.Tests/ProductBinTests.cs
@@ -130,6 +130,27 @@ public sealed class ProductBinTests : IDisposable
});
}
+ // The search draws at most 64 times, and stops at the first draw that clears the cutoff: the stream after it is what shows how
+ // many draws it took. An unreachable cutoff takes exactly 64, a deviation of 0 (draw equal to the cutoff) takes 1.
+ [Fact]
+ public void TheSearchDrawsAtMostSixtyFourTimesAndStopsAtTheFirstDrawThatClearsTheCutoff()
+ {
+ float AfterDraws(int draws)
+ {
+ var random = new CultMath.Random(11u);
+ for (var i = 0; i < draws; i++) random.NextGaussian(0, 1);
+ return random.NextGaussian(0, 1);
+ }
+
+ var unreachable = new CultMath.Random(11u);
+ Assert.Equal(40f, unreachable.NextGaussianAbove(0, 1, 40f));
+ Assert.Equal(AfterDraws(64), unreachable.NextGaussian(0, 1));
+
+ var immediate = new CultMath.Random(11u);
+ Assert.Equal(.5f, immediate.NextGaussianAbove(.5f, 0, 0));
+ Assert.Equal(AfterDraws(1), immediate.NextGaussian(0, 1));
+ }
+
// Snapshot of the first five assemblies and fills at base, before the bin existed (seed 20261018, lens .5 +- .1, maker .6 +- .1).
[Fact]
public void UnbinnedMintConsumesTheStreamAsBefore()
@@ -137,6 +158,7 @@ public sealed class ProductBinTests : IDisposable
var line = Line();
_items.Random = new CultMath.Random(20261018u);
var lots = Mint(line, 5);
+ Assert.Empty(_log);
var assemblies = new[] { .59560823f, .68289334f, .57563853f, .5639112f, .59828424f };
var fills = new[] { .6311789f, .5835483f, .47641385f, .46416125f, .45124212f };
for (var i = 0; i < 5; i++)
@@ -170,6 +192,7 @@ public sealed class ProductBinTests : IDisposable
_cache.Remove(_cache.RefOf(line).Key);
var lots = Mint(bin, 5);
Assert.Equal(5, lots.Length);
- Assert.NotEmpty(_log);
+ Assert.Equal(5, _log.Count);
+ Assert.All(_log, message => Assert.False(string.IsNullOrWhiteSpace(message)));
}
}
From 129e5ff4dc506737721e28c2e1a46445bf8a03d1 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 21:08:09 +0200
Subject: [PATCH 10/13] ProductWeight is the one weight of every design-only
draw; a line's bin shares are read once per pass
The all-weights-zero branch of ChooseProduct and the required-item fallback of RandomProducts weighed
every product 1, so a bin doubled its maker's presence there. Both now weigh by Share alone (the
preference drops out, the bins' shares do not). The sum of a line's bins' pass fractions is computed
once per weighting pass instead of scanning the product table per candidate.
Co-Authored-By: Claude Sonnet 5.5
---
.../Scripts/ServerShared/LoadoutGenerator.cs | 29 +++++++++----
.../ProductChoiceTests.cs | 42 +++++++++++++++++++
2 files changed, 62 insertions(+), 9 deletions(-)
diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs
index 0206935fb..834e4dc7c 100644
--- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs
+++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs
@@ -176,8 +176,9 @@ public class LoadoutGenerator
public (FactionProductData product, T design)[] RandomProducts(int count, float sizeExponent, Predicate filter = null, bool required = false) where T : CraftedItemData
{
var (available, preferManufacturers) = AvailableProducts(filter, required);
+ var shares = BinShares();
return available.WeightedRandomElements(ref Random, entry =>
- (preferManufacturers ? ProductWeight(entry.product) : 1) *
+ Weight(entry.product, shares, preferManufacturers) *
pow(entry.design.Shape.Coordinates.Length, sizeExponent) / // Prioritize larger items
pow(entry.design.Price, PriceExponent), // Penalize item price to a controllable degree
count);
@@ -255,22 +256,31 @@ public class LoadoutGenerator
// How much this outfitter favors a product: its maker's preference times the product's share of it. The one weight of
// generation and of ChooseProduct. A bin takes the share of its maker's stock that would pass its floor and its line takes
// the rest (ruling bin-share-by-floor), so binning never changes a maker's presence for the design.
- public float ProductWeight(FactionProductData product) => ManufacturerPreference(product.Manufacturer) * Share(product);
+ public float ProductWeight(FactionProductData product) => Weight(product, BinShares(), true);
+
+ // The one weight, with the share of the stock computed from shares (BinShares, once per weighting pass). A draw that has
+ // fallen back to any manufacturer weighs by share alone: the maker's preference no longer applies, its bins' shares still do.
+ private float Weight(FactionProductData product, Dictionary shares, bool preferManufacturers) =>
+ (preferManufacturers ? ManufacturerPreference(product.Manufacturer) : 1) * Share(product, shares);
// 1 - Phi(floor): the fraction of a normal draw that lies at least floor deviations above its mean.
private static float PassFraction(float floor) => .5f * (1 - erf(floor / sqrt(2f)));
- private float Share(FactionProductData product)
+ // Each line's key to the sum of its bins' pass fractions: read from the catalog once per weighting pass, never kept.
+ private Dictionary BinShares() => ItemManager.ItemData.GetAll()
+ .Where(product => product.Bin != null)
+ .GroupBy(product => product.Bin.Line.Key.Value)
+ .ToDictionary(group => group.Key, group => group.Sum(bin => PassFraction(bin.Bin.Floor)));
+
+ private float Share(FactionProductData product, Dictionary shares)
{
if (product.Bin != null) return PassFraction(product.Bin.Floor);
- var key = ItemManager.ItemData.RefOf(product).Key;
- var binned = ItemManager.ItemData.GetAll().Where(other => other.Bin != null && other.Bin.Line.Key.Equals(key)).Sum(bin => PassFraction(bin.Bin.Floor));
- return max(0f, 1 - binned);
+ return max(0f, 1 - shares.GetValueOrDefault(ItemManager.ItemData.RefOf(product).Key.Value));
}
// The product a design named without one is built as (ruling loadout-product-optional-context-chooses): among the
// products of the design this outfitter can get, one draw from the run's seeded Random weighted by ProductWeight;
- // null when none, and uniform over them when every weight is 0. Candidates are ordered by name so the draw's
+ // null when none, and by share alone when every weight is 0. Candidates are ordered by name so the draw's
// enumeration, and so its result, never depends on record keys. All of the policy is this body.
public FactionProductData ChooseProduct(CraftedItemData design)
{
@@ -280,8 +290,9 @@ public class LoadoutGenerator
.OrderBy(product => product.Name, StringComparer.Ordinal)
.ToArray();
if (candidates.Length == 0) return null;
- var uniform = candidates.All(product => ProductWeight(product) <= 0);
- return candidates.WeightedRandomElements(ref Random, product => uniform ? 1 : ProductWeight(product), 1)[0];
+ var shares = BinShares();
+ var uniform = candidates.All(product => Weight(product, shares, true) <= 0);
+ return candidates.WeightedRandomElements(ref Random, product => Weight(product, shares, !uniform), 1)[0];
}
// Prioritize products from the zone faction and its allies, penalizing distance to the manufacturer's headquarters.
diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs
index 5061d4603..89f262c62 100644
--- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs
+++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs
@@ -300,6 +300,48 @@ public sealed class ProductChoiceTests : IDisposable
Assert.Equal(0f, three.Outfitter(three.OutEven, 1, 0).ProductWeight(three.Product("lamp-a")));
}
+ // Ruling bin-share-by-floor holds on every draw of a design, not only the preference-weighted one. When every reachable maker
+ // weighs 0 the ChooseProduct draw is by share alone, and Alpha's line and bin together keep the half they held as one product.
+ [Fact]
+ public void ABinLeavesItsMakersShareOfTheAllZeroWeightDrawUnchanged()
+ {
+ int AlphaDraws(Rig rig)
+ {
+ var lamp = rig.Cache.GetByName("Lamp");
+ var zero = rig.Cache.GetByName("OutZero");
+ return Enumerable.Range(1, 1000).Count(i => rig.Cache.Get(rig.Outfitter(zero, Seed(i), 0).ChooseProduct(lamp).Manufacturer).Name == "Alpha");
+ }
+
+ Assert.InRange(AlphaDraws(Build("lamp-z", "lamp-a")), 440, 560);
+ Assert.InRange(AlphaDraws(Build(new[] { .67f }, new[] { "lamp-z", "lamp-a" })), 440, 560);
+ Assert.InRange(AlphaDraws(Build(new[] { 0f }, new[] { "lamp-z", "lamp-a" })), 440, 560);
+ }
+
+ // The same for a required item that has fallen back to any manufacturer (OutNone is allied to nobody, so no product is on offer
+ // and the fallback weighs every product alike, but for the bins' shares).
+ [Fact]
+ public void ABinLeavesItsMakersShareOfTheRequiredFallbackDrawUnchanged()
+ {
+ int AlphaDraws(Rig rig)
+ {
+ var log = new List();
+ var items = new ItemManager(rig.Cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), log.Add);
+ var lamp = rig.Cache.GetByName("Lamp");
+ var alpha = Enumerable.Range(1, 1000).Count(i =>
+ {
+ var random = new Random(Seed(i));
+ var generator = new LoadoutGenerator(ref random, items, rig.Galaxy, null, rig.OutNone, .5f);
+ return rig.Cache.Get(generator.RandomProduct(0, design => design == lamp, required: true).product.Manufacturer).Name == "Alpha";
+ });
+ Assert.NotEmpty(log);
+ return alpha;
+ }
+
+ Assert.InRange(AlphaDraws(Build("lamp-z", "lamp-a")), 440, 560);
+ Assert.InRange(AlphaDraws(Build(new[] { .67f }, new[] { "lamp-z", "lamp-a" })), 440, 560);
+ Assert.InRange(AlphaDraws(Build(new[] { 0f }, new[] { "lamp-z", "lamp-a" })), 440, 560);
+ }
+
// A weight that read the allegiance alone would give 1 : .25 here, an 80% draw; the zone and allegiance together
// give 1 : .125 for alpha at its own zone, 89%.
[Fact]
From f73507ff2fa847ac0cd0d0eafcc9d02f32e7f78e Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 21:08:10 +0200
Subject: [PATCH 11/13] Catalog read refuses a bin with a non-finite Floor, a
missing Line or a Line that is a bin; a bin mints from exactly its named line
(tested)
The runtime no longer trusts the authoring tool for a bin: a NaN Floor weighed NaN and nulled every
draw of its design. AetheriaStores.Open names the field and echoes no value.
Co-Authored-By: Claude Sonnet 5.5
---
Assets/Scripts/ServerShared/AetheriaStores.cs | 19 +++++
.../Aetheria.Shared.Tests/ProductBinTests.cs | 78 +++++++++++++++++++
2 files changed, 97 insertions(+)
diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs
index 4afb00b1d..525894ac9 100644
--- a/Assets/Scripts/ServerShared/AetheriaStores.cs
+++ b/Assets/Scripts/ServerShared/AetheriaStores.cs
@@ -41,6 +41,7 @@ public static class AetheriaStores
foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data);
foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data);
VerseGrammarValidation.ValidateAll(cache.GetAll());
+ ValidateBins(cache);
return cache;
}
catch
@@ -49,6 +50,24 @@ public static class AetheriaStores
throw;
}
}
+
+ // The mint and the weighting trust a bin: they never check its Floor or its Line. So the catalog read refuses one whose Floor is
+ // not a finite number (it would weigh NaN and poison every draw of its design), whose Line names no product the catalog holds, or
+ // whose Line is itself a bin (a bin is of a line, never of a bin). The refusals name the field and echo no value.
+ private static void ValidateBins(CultCache cache)
+ {
+ foreach (var product in cache.GetAll())
+ {
+ if (product.Bin == null) continue;
+ if (!float.IsFinite(product.Bin.Floor))
+ throw new InvalidOperationException("A product bin's Floor is not a finite number.");
+ var line = cache.Get(product.Bin.Line);
+ if (line == null)
+ throw new InvalidOperationException("A product bin's Line names no product this catalog holds.");
+ if (line.Bin != null)
+ throw new InvalidOperationException("A product bin's Line names another bin; a bin is of a line.");
+ }
+ }
}
public static class CultRecordRefs
diff --git a/tests/Aetheria.Shared.Tests/ProductBinTests.cs b/tests/Aetheria.Shared.Tests/ProductBinTests.cs
index 03762fdf2..c7b7de1fb 100644
--- a/tests/Aetheria.Shared.Tests/ProductBinTests.cs
+++ b/tests/Aetheria.Shared.Tests/ProductBinTests.cs
@@ -183,6 +183,84 @@ public sealed class ProductBinTests : IDisposable
Assert.Equal("Premiere", product.Name);
}
+ // The line a bin draws from is the one Bin.Line names, not another line of the design: three lines of one design, by three
+ // makers, with the bin on the middle one by name, so a bin that reads the first or the last line of the design is far off.
+ [Fact]
+ public void ABinMintsFromExactlyTheLineItNames()
+ {
+ Line("A line", lensMean: .3f, makerMean: .2f);
+ var middle = Line("M line", lensMean: .6f, makerMean: .5f);
+ Line("Z line", lensMean: .9f, makerMean: .9f);
+ var lots = Mint(Bin(middle, "Middle Premiere", 0), 400);
+ Assert.All(lots, lot =>
+ {
+ Assert.True(lot.Roles.Single().Quality >= .6f - 1e-5f, "a fill came from a line below the named one");
+ Assert.True(lot.Quality >= .5f - 1e-5f, "an assembly came from a maker below the named line's");
+ });
+ Assert.InRange(Fills(lots).Average(), .64f, .72f);
+ Assert.InRange(lots.Average(lot => lot.Quality), .55f, .62f);
+ }
+
+ private const string Canary = "canary-7f3a91";
+
+ private enum Damage { None, LineRemoved, LineIsBin }
+
+ // A catalog on disk holding one line and a bin of it, damaged as asked, for AetheriaStores.Open to read.
+ private string DamagedCatalog(float floor, Damage damage)
+ {
+ var path = Path.Combine(_root, Guid.NewGuid().ToString("N"), "Aetheria.cc");
+ Directory.CreateDirectory(Path.GetDirectoryName(path));
+ using var seed = AetheriaStores.Open(path, catalogWritable: true);
+ seed.Upsert(new VerseGrammar { Revision = 1 });
+ var maker = seed.Upsert(new Faction { Name = Canary + " Maker" });
+ var gear = seed.Upsert(new GearData { Name = "Lensed", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10, Roles = { new ItemRole { Name = "lens" } } });
+ var design = new CultRecordRef(gear.Key);
+ var line = seed.Upsert(new FactionProductData { Name = Canary + " Line", Design = design, Manufacturer = maker, Roles = { new ProductRole { Role = "lens" } } });
+ var bin = seed.Upsert(new FactionProductData { Name = Canary, Design = design, Manufacturer = maker, Bin = new ProductBin { Line = line, Floor = floor } });
+ if (damage == Damage.LineIsBin)
+ seed.Upsert(new FactionProductData { Name = Canary + " Nested", Design = design, Manufacturer = maker, Bin = new ProductBin { Line = bin, Floor = 0 } });
+ if (damage == Damage.LineRemoved) seed.Remove(line.Key);
+ seed.FlushAsync().Wait();
+ return path;
+ }
+
+ // The runtime does not trust authoring for a bin: a NaN floor would weigh NaN and null every draw of its design.
+ [Theory]
+ [InlineData(float.NaN)]
+ [InlineData(float.PositiveInfinity)]
+ [InlineData(float.NegativeInfinity)]
+ public void ACatalogWhoseBinFloorIsNotFiniteIsRefusedWhereItIsRead(float floor)
+ {
+ var refusal = Assert.Throws(() => AetheriaStores.Open(DamagedCatalog(floor, Damage.None)));
+ Assert.Contains("Floor", refusal.Message);
+ Assert.DoesNotContain(Canary, refusal.Message);
+ Assert.DoesNotContain(floor.ToString(), refusal.Message);
+ }
+
+ [Fact]
+ public void ACatalogWhoseBinNamesNoLineIsRefusedWhereItIsRead()
+ {
+ var refusal = Assert.Throws(() => AetheriaStores.Open(DamagedCatalog(.67f, Damage.LineRemoved)));
+ Assert.Contains("Line", refusal.Message);
+ Assert.DoesNotContain(Canary, refusal.Message);
+ }
+
+ [Fact]
+ public void ACatalogWhoseBinNamesAnotherBinIsRefusedWhereItIsRead()
+ {
+ var refusal = Assert.Throws(() => AetheriaStores.Open(DamagedCatalog(.67f, Damage.LineIsBin)));
+ Assert.Contains("Line", refusal.Message);
+ Assert.DoesNotContain(Canary, refusal.Message);
+ }
+
+ // The refusals are of damage, not of bins: a sound bin on a sound line reads.
+ [Fact]
+ public void ACatalogWithASoundBinIsRead()
+ {
+ using var cache = AetheriaStores.Open(DamagedCatalog(.67f, Damage.None));
+ Assert.Equal(.67f, cache.GetAll().Single(product => product.Name == Canary).Bin.Floor);
+ }
+
// A bin whose line the catalog does not hold mints as a product with no roles of its own, and says so.
[Fact]
public void ABinOfALineTheCatalogLacksMintsAtDefaultsAndLogs()
From 8d65e957f81764605416202aab1f31b7f6125678 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 21:08:11 +0200
Subject: [PATCH 12/13] sensor-stat-set skips a bin, as the other role writers
do; it takes an optional root for its test
Co-Authored-By: Claude Sonnet 5.5
---
.../RolesBackfillCommandTests.cs | 21 +++++++++++++++++++
tools/AetherDb/Program.cs | 6 +++---
2 files changed, 24 insertions(+), 3 deletions(-)
diff --git a/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs b/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs
index 31152b63c..58c823af4 100644
--- a/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs
+++ b/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs
@@ -290,6 +290,27 @@ public sealed class RolesBackfillCommandTests : IDisposable
finally { db.Cache.Dispose(); }
}
+ // sensor-stat-set authors an emission-discipline role on every product of a sensor design; a bin of one authors none.
+ [Fact]
+ public void A_binned_seller_gains_no_roles_from_sensor_stat_set()
+ {
+ var db = AetherDb.Open(catalogWritable: true, root: _root);
+ var sensor = db.Cache.GetAll().Where(d => d.Behaviors != null && d.Behaviors.OfType().Any()).OrderBy(d => d.Name, StringComparer.Ordinal).First();
+ var seller = db.Cache.GetAll().OrderBy(p => p.Name, StringComparer.Ordinal).First(p => p.Bin == null && p.Design.Key.Equals(db.Cache.RefOf(sensor).Key));
+ var bin = new FactionProductData
+ {
+ Name = "Binned " + seller.Name, Design = seller.Design, Manufacturer = seller.Manufacturer,
+ Bin = new ProductBin { Line = db.Cache.RefOf(seller), Floor = 0f }
+ };
+ db.Cache.Commit(batch => batch.Upsert(typeof(FactionProductData), bin));
+ db.Cache.Dispose();
+
+ Assert.Equal(0, Program.SensorStatSet(apply: true, root: _root));
+ db = AetherDb.Open(root: _root);
+ try { Assert.Empty(db.Cache.GetAll().Single(p => p.Name == "Binned " + seller.Name).Roles); }
+ finally { db.Cache.Dispose(); }
+ }
+
[Fact]
public void A_design_the_catalog_does_not_hold_refuses_and_lands_nothing()
{
diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs
index 7a28b1aa0..fec3d6d3e 100644
--- a/tools/AetherDb/Program.cs
+++ b/tools/AetherDb/Program.cs
@@ -1719,9 +1719,9 @@ public static class Program
// product of such a design authors that role, and the Passive Array (a generic listener gear, ListenerData) is
// authored with two products from the stealth makers. Prints the sensor census first. Dry run unless passed
// "apply"; idempotent, so a second apply writes nothing and a catalog another cut changed is rerun, never merged.
- private static int SensorStatSet(bool apply)
+ internal static int SensorStatSet(bool apply, string root = null)
{
- var db = AetherDb.Open(catalogWritable: apply);
+ var db = AetherDb.Open(catalogWritable: apply, root: root);
var designs = db.Cache.GetAll().ToArray();
var products = db.Cache.GetAll().ToArray();
Faction MakerOf(FactionProductData p) => db.Cache.Get(p.Manufacturer);
@@ -1760,7 +1760,7 @@ public static class Program
design.Roles ??= new List();
if (!design.Roles.Any(r => r.Name == EmissionDiscipline)) { design.Roles.Add(new ItemRole { Name = EmissionDiscipline }); designChanged = true; }
if (designChanged) changedDesigns.Add((design, key));
- foreach (var product in products.Where(p => p.Design.Key.Equals(key)))
+ foreach (var product in products.Where(p => p.Bin == null && p.Design.Key.Equals(key)))
{
var maker = MakerOf(product) ?? throw new InvalidOperationException($"Product \"{product.Name}\" has no maker.");
product.Roles ??= new List();
From e6a17a5e01dd4d34722de7d857f2ecb95f5f1203 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 21:14:14 +0200
Subject: [PATCH 13/13] ProductLinesCommandTests: the bins of a removed line go
with it, as the catalog read now requires
Co-Authored-By: Claude Sonnet 5.5
---
tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs b/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs
index 53b8fe773..fd8e02aa1 100644
--- a/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs
+++ b/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs
@@ -42,7 +42,9 @@ public abstract class ProductLinesCommandTests : IDisposable
Assert.Equal(Names.Length, products.Length);
_shipped = products.Select(p => Describe(cache, p)).OrderBy(x => x, StringComparer.Ordinal).ToArray();
_shippedKeys = products.Select(p => cache.RefOf(p).Key.Value).OrderBy(x => x, StringComparer.Ordinal).ToArray();
- foreach (var product in products) Assert.True(cache.Remove(cache.RefOf(product).Key));
+ // A bin cannot outlive its line (the catalog read refuses it), so the bins of the removed lines go with them.
+ var binned = cache.GetAll().Where(p => p.Bin != null && products.Any(line => cache.RefOf(line).Key.Equals(p.Bin.Line.Key))).ToArray();
+ foreach (var product in products.Concat(binned)) Assert.True(cache.Remove(cache.RefOf(product).Key));
cache.FlushAsync().Wait();
}