From 49853d3166bdeaaf1f784d447501eeff62efcf15 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 17:58:51 +0200
Subject: [PATCH] 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);
+ }
+}