From 129e5ff4dc506737721e28c2e1a46445bf8a03d1 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 21:08:09 +0200
Subject: [PATCH] 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]