Merge eureka/aetheria-release-product-tiers (head e6a17a5e)

Reports cut-product-tiers h1/h2; Soul verdicts s1, s2 hold.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-18 22:29:02 +02:00
commit d83cacc6a7
16 changed files with 949 additions and 43 deletions

View file

@ -41,6 +41,7 @@ public static class AetheriaStores
foreach (var data in cache.GetAll<EquippableItemData>()) CultRecordRefs.Validate(data);
foreach (var data in cache.GetAll<ConsumableItemData>()) CultRecordRefs.Validate(data);
VerseGrammarValidation.ValidateAll(cache.GetAll<VerseVerb>());
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<FactionProductData>())
{
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

View file

@ -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;

View file

@ -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<ProductRole> Roles = new List<ProductRole>();
// 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<FactionProductData> 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)]

View file

@ -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<ItemData>(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<RoleFill>()
};
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<Faction> maker, float quality)
{

View file

@ -176,8 +176,9 @@ public class LoadoutGenerator
public (FactionProductData product, T design)[] RandomProducts<T>(int count, float sizeExponent, Predicate<T> 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);
@ -252,12 +253,34 @@ 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) => 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<string, float> 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)));
// Each line's key to the sum of its bins' pass fractions: read from the catalog once per weighting pass, never kept.
private Dictionary<string, float> BinShares() => ItemManager.ItemData.GetAll<FactionProductData>()
.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<string, float> shares)
{
if (product.Bin != null) return PassFraction(product.Bin.Floor);
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)
{
@ -267,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.