Assembly is an AssemblyDistribution: Faction key 19, optional per product, stated-quality mint

Replaces h1's AssemblyMean/AssemblyDeviation floats with one Faction.Assembly (key 19, null = not authored) and adds FactionProductData.Assembly (key 6, null = its maker's). CreateLot(product) draws from product.Assembly, else the maker's, else AssemblyDistribution defaults; CreateLot(product, quality) mints the lot and every fill at a stated quality and draws nothing. assembly-from-maker now seeds part mean + .1 and writes only a null Assembly.

The shipped-catalog test is red until the catalog commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-17 19:03:12 +02:00
commit 30597b0e45
8 changed files with 160 additions and 78 deletions

View file

@ -64,14 +64,12 @@ public class Faction
[Inspectable, JsonProperty("doctrine"), Key(17)]
public FactionDoctrine Doctrine;
// The maker's final-assembly technology (mean) and quality control (deviation): the distribution every lot of
// theirs draws its own quality from, and the hard ceiling on every one of its roles (quality-lean-assembly-hard-ceiling).
// Key 18 is reserved for Livery and 16 for Relations.
[Inspectable, JsonProperty("assemblyMean"), Key(19)]
public float AssemblyMean = .5f;
[Inspectable, JsonProperty("assemblyDeviation"), Key(20)]
public float AssemblyDeviation = .15f;
// The maker's final assembly: the distribution every lot of theirs draws its own quality from, and the hard ceiling on each
// of its roles (quality-lean-assembly-hard-ceiling). Null is not authored: a lot of theirs assembles at AssemblyDistribution's
// defaults. assembly-from-maker derives it once at the part mean plus a tolerance margin (quality-min-tolerance-binned-assembly);
// tune it in Studio after that. Key 16 is reserved for Relations and 18 for Livery.
[Inspectable, JsonProperty("assembly"), Key(19)]
public AssemblyDistribution Assembly;
}
[CultDocument("aetheria.namefile", "1"), Inspectable, MessagePackObject]

View file

@ -28,6 +28,11 @@ 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.
[Inspectable, JsonProperty("assembly"), Key(6)]
public AssemblyDistribution Assembly;
}
// How good this product's part is in one of its design's roles: a pseudo-gaussian distribution whose mean is
@ -46,3 +51,15 @@ public class ProductRole
[Inspectable, JsonProperty("standardDeviation"), Key(2)]
public float StandardDeviation = .15f;
}
// 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)]
public class AssemblyDistribution
{
[Inspectable, JsonProperty("mean"), Key(0)]
public float Mean = .5f;
[Inspectable, JsonProperty("standardDeviation"), Key(1)]
public float StandardDeviation = .15f;
}

View file

@ -118,19 +118,24 @@ 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 maker's assembly distribution, and each of the design's roles is filled with a part whose
// quality is drawn from the product's distribution for it. A maker the catalog cannot resolve assembles at a new Faction's
// defaults.
public int CreateLot(FactionProductData product)
// 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.
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
// fit does not ride the item stream (ScenarioStage.Fit).
public int CreateLot(FactionProductData product, float quality) => Mint(product, clamp(quality, .01f, 1));
private int Mint(FactionProductData product, float? stated)
{
var design = ItemData.Get(product.Design);
var maker = ItemData.Get(product.Manufacturer) ?? new Faction();
var assembly = product.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 = clamp(Random.NextGaussian(maker.AssemblyMean, maker.AssemblyDeviation), .01f, 1),
Quality = stated ?? clamp(Random.NextGaussian(assembly.Mean, assembly.StandardDeviation), .01f, 1),
Roles = new List<RoleFill>()
};
if (design.Roles != null)
@ -140,7 +145,7 @@ public class ItemManager
lot.Roles.Add(new RoleFill
{
Role = role.Name,
Quality = clamp(Random.NextGaussian(build.Mean, build.StandardDeviation), .01f, 1)
Quality = stated ?? clamp(Random.NextGaussian(build.Mean, build.StandardDeviation), .01f, 1)
});
}

View file

@ -4,32 +4,33 @@ using System.Reflection;
using MessagePack;
using Xunit;
// The shipped catalog carries each maker's derived assembly, and the Faction format pins its wire positions
// The shipped catalog carries each maker's seeded assembly, and the Faction format pins its wire positions
// (aetheria-release, cut assembly-from-maker).
public sealed class AssemblyFromMakerCatalogTests
{
// Every maker of a roled product carries an Assembly the command seeded; no product overrides its maker's.
[Fact]
public void ShippedCatalogCarriesDerivation()
public void ShippedCatalogCarriesAssembly()
{
using var cache = RestoredHullsTests.OpenCatalog();
var products = cache.GetAll<FactionProductData>().Where(p => p.Roles.Count > 0).ToArray();
var makers = products.Select(p => p.Manufacturer.Key).Distinct().ToArray();
Assert.NotEmpty(makers);
var makers = cache.GetAll<FactionProductData>().Where(p => p.Roles.Count > 0).Select(p => p.Manufacturer.Key).Distinct().ToArray();
Assert.Equal(15, makers.Length);
foreach (var key in makers)
{
var roles = products.Where(p => p.Manufacturer.Key.Equals(key)).SelectMany(p => p.Roles).ToArray();
var faction = cache.Get<Faction>(key);
Assert.Equal(Math.Round(roles.Average(r => r.Mean), 2), faction.AssemblyMean, 2);
Assert.Equal(Math.Round(roles.Average(r => r.StandardDeviation), 2), faction.AssemblyDeviation, 2);
var assembly = cache.Get<Faction>(key).Assembly;
Assert.NotNull(assembly);
Assert.InRange(assembly.Mean, .01f, 1f);
}
Assert.All(cache.GetAll<FactionProductData>(), p => Assert.Null(p.Assembly));
}
// 16 is reserved for Relations, 17 is Doctrine, 18 is reserved for Livery.
// 16 is reserved for Relations, 17 is Doctrine, 18 is reserved for Livery, 20 for the place-names pass.
[Fact]
public void AssemblyFieldsSitAtKeys19And20()
public void AssemblyRidesItsWireKeys()
{
int KeyOf(string field) => typeof(Faction).GetField(field).GetCustomAttribute<KeyAttribute>().IntKey.Value;
Assert.Equal(19, KeyOf(nameof(Faction.AssemblyMean)));
Assert.Equal(20, KeyOf(nameof(Faction.AssemblyDeviation)));
int KeyOf(Type type) => type.GetField("Assembly").GetCustomAttribute<KeyAttribute>().IntKey.Value;
Assert.Equal(19, KeyOf(typeof(Faction)));
Assert.Equal(6, KeyOf(typeof(FactionProductData)));
}
}

View file

@ -4,8 +4,8 @@ using System.Linq;
using GameCult.Caching;
using Xunit;
// assembly-from-maker (aetheria-release, cut assembly-from-maker): each maker's assembly distribution is derived from the
// roles of the products it manufactures.
// assembly-from-maker (aetheria-release, cut assembly-from-maker): each maker's unauthored assembly is seeded from the roles
// of the products it manufactures.
public sealed class AssemblyFromMakerCommandTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-assembly-cmd-" + Guid.NewGuid().ToString("N"));
@ -19,7 +19,7 @@ public sealed class AssemblyFromMakerCommandTests : IDisposable
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
cache.Upsert(new VerseGrammar { Revision = 1 });
var maker = cache.Upsert(new Faction { Name = "Maker" });
cache.Upsert(new Faction { Name = "Bystander", AssemblyMean = .9f, AssemblyDeviation = .01f });
cache.Upsert(new Faction { Name = "Bystander" });
var gear = cache.Upsert(new GearData { Name = "Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10 });
var design = new CultRecordRef<CraftedItemData>(gear.Key);
// Two products, three roles: the mean is over roles (.2, .4, .9 -> .5), the deviation likewise (.1, .1, .4 -> .2).
@ -45,6 +45,19 @@ public sealed class AssemblyFromMakerCommandTests : IDisposable
new ProductRole { Role = "z", Mean = .31f, StandardDeviation = .2f }
}
});
// A maker whose parts are near the top: the margin is capped at 1. And one whose assembly was already authored in Studio.
var top = cache.Upsert(new Faction { Name = "Top" });
cache.Upsert(new FactionProductData
{
Name = "D", Design = design, Manufacturer = top,
Roles = { new ProductRole { Role = "x", Mean = .95f, StandardDeviation = .05f } }
});
var tuned = cache.Upsert(new Faction { Name = "Tuned", Assembly = new AssemblyDistribution { Mean = .42f, StandardDeviation = .07f } });
cache.Upsert(new FactionProductData
{
Name = "E", Design = design, Manufacturer = tuned,
Roles = { new ProductRole { Role = "x", Mean = .1f, StandardDeviation = .3f } }
});
cache.FlushAsync().Wait();
}
@ -57,8 +70,31 @@ public sealed class AssemblyFromMakerCommandTests : IDisposable
return output.ToString();
}
private Faction Maker(string name)
{
using var cache = AetheriaStores.Open(Catalog);
return cache.GetAll<Faction>().Single(f => f.Name == name);
}
// The assembly is the part mean plus the tolerance margin and the deviation the parts' mean; the margin is capped at 1; a
// faction that makes no roled product stays unauthored.
[Fact]
public void DryRunWritesNothingApplyIsIdempotent()
public void DerivesPartMeanPlusMargin()
{
Seed();
Run(apply: true);
Assert.Equal(.6f, Maker("Maker").Assembly.Mean, 3);
Assert.Equal(.2f, Maker("Maker").Assembly.StandardDeviation, 3);
Assert.Equal(.36f, Maker("Fine").Assembly.Mean, 3);
Assert.Equal(.16f, Maker("Fine").Assembly.StandardDeviation, 3);
Assert.Equal(1f, Maker("Top").Assembly.Mean, 3);
Assert.Equal(.05f, Maker("Top").Assembly.StandardDeviation, 3);
Assert.Null(Maker("Bystander").Assembly);
}
// A value tuned in Studio survives every replay, a dry run writes nothing, and a second apply has nothing to do.
[Fact]
public void AuthoredAssemblyIsKept()
{
Seed();
var before = File.ReadAllBytes(Catalog);
@ -66,19 +102,10 @@ public sealed class AssemblyFromMakerCommandTests : IDisposable
Assert.Equal(before, File.ReadAllBytes(Catalog));
Run(apply: true);
Assert.Equal(.42f, Maker("Tuned").Assembly.Mean, 3);
Assert.Equal(.07f, Maker("Tuned").Assembly.StandardDeviation, 3);
using (var cache = AetheriaStores.Open(Catalog))
{
var maker = cache.GetAll<Faction>().Single(f => f.Name == "Maker");
Assert.Equal(.5f, maker.AssemblyMean, 3);
Assert.Equal(.2f, maker.AssemblyDeviation, 3);
var fine = cache.GetAll<Faction>().Single(f => f.Name == "Fine");
Assert.Equal(.26f, fine.AssemblyMean, 3);
Assert.Equal(.16f, fine.AssemblyDeviation, 3);
// A faction that manufactures nothing is written the field defaults.
var bystander = cache.GetAll<Faction>().Single(f => f.Name == "Bystander");
Assert.Equal(.5f, bystander.AssemblyMean, 3);
Assert.Equal(.15f, bystander.AssemblyDeviation, 3);
}
Assert.All(cache.GetAll<FactionProductData>(), p => Assert.Null(p.Assembly));
var written = File.ReadAllBytes(Catalog);
Assert.Contains("No changes", Run(apply: true));

View file

@ -45,6 +45,8 @@ public sealed class AssemblyFromMakerTests : IDisposable
return _cache.GetAll<FactionProductData>().Single(p => p.Name == name);
}
private static AssemblyDistribution A(float mean, float deviation) => new AssemblyDistribution { Mean = mean, StandardDeviation = deviation };
private float[] Mint(FactionProductData product, int count) =>
Enumerable.Range(0, count).Select(_ => _items.Lots[_items.CreateLot(product)].Quality).ToArray();
@ -52,8 +54,8 @@ public sealed class AssemblyFromMakerTests : IDisposable
[Fact]
public void LotQualityFollowsMaker()
{
var low = Product("Low", new Faction { Name = "Low Maker", AssemblyMean = .3f, AssemblyDeviation = .02f }, .5f);
var high = Product("High", new Faction { Name = "High Maker", AssemblyMean = .8f, AssemblyDeviation = .02f }, .5f);
var low = Product("Low", new Faction { Name = "Low Maker", Assembly = A(.3f, .02f) }, .5f);
var high = Product("High", new Faction { Name = "High Maker", Assembly = A(.8f, .02f) }, .5f);
var lows = Mint(low, 300);
var highs = Mint(high, 300);
Assert.InRange(lows.Average(), .27f, .33f);
@ -65,27 +67,54 @@ public sealed class AssemblyFromMakerTests : IDisposable
[Fact]
public void LotSpreadFollowsMakerDeviation()
{
var wide = Mint(Product("Wide", new Faction { Name = "Wide Maker", AssemblyMean = .5f, AssemblyDeviation = .2f }, .5f), 400);
var tight = Mint(Product("Tight", new Faction { Name = "Tight Maker", AssemblyMean = .5f, AssemblyDeviation = .02f }, .5f), 400);
var wide = Mint(Product("Wide", new Faction { Name = "Wide Maker", Assembly = A(.5f, .2f) }, .5f), 400);
var tight = Mint(Product("Tight", new Faction { Name = "Tight Maker", Assembly = A(.5f, .02f) }, .5f), 400);
double Spread(float[] lots) => Math.Sqrt(lots.Select(q => Math.Pow(q - lots.Average(), 2)).Average());
Assert.InRange(Spread(wide), .15, .25);
Assert.InRange(Spread(tight), .01, .04);
}
// A maker the catalog cannot resolve assembles at a new Faction's defaults.
// A maker the catalog cannot resolve, and a maker whose Assembly is not authored, assemble at AssemblyDistribution's defaults.
[Fact]
public void UnresolvedMakerAssemblesAtDefaults()
public void UnauthoredAssemblyAssemblesAtDefaults()
{
var orphan = Product("Orphan", null, .5f);
var defaults = new Faction();
Assert.InRange(Mint(orphan, 400).Average(), defaults.AssemblyMean - .03f, defaults.AssemblyMean + .03f);
var defaults = new AssemblyDistribution();
Assert.InRange(Mint(Product("Orphan", null, .5f), 400).Average(), defaults.Mean - .03f, defaults.Mean + .03f);
Assert.InRange(Mint(Product("Blank", new Faction { Name = "Blank Maker" }, .5f), 400).Average(), defaults.Mean - .03f, defaults.Mean + .03f);
}
// A line's own final test (maker-design-tiers-allowed): a product carrying an Assembly mints at it, and a sibling product
// of the same maker without one reads the maker's.
[Fact]
public void ProductAssemblyOverridesMaker()
{
var maker = new Faction { Name = "Binner", Assembly = A(.3f, .02f) };
var plain = Product("Plain", maker, .5f);
var premium = Product("Premium", maker, .5f);
premium.Assembly = A(.85f, .02f);
Assert.InRange(Mint(premium, 300).Average(), .82f, .88f);
Assert.InRange(Mint(plain, 300).Average(), .27f, .33f);
}
// A stated quality is every lot's and every fill's, clamped, and draws nothing from the item stream.
[Fact]
public void StatedQualityDrawsNothing()
{
var product = Product("Staged", new Faction { Name = "Staged Maker", Assembly = A(.3f, .2f) }, .5f, .2f);
var before = _items.Random;
var lot = _items.Lots[_items.CreateLot(product, .6f)];
Assert.Equal(.6f, lot.Quality, 3);
Assert.Equal(.6f, lot.Roles.Single().Quality, 3);
Assert.Equal(before.NextFloat(), _items.Random.NextFloat());
Assert.Equal(1f, _items.Lots[_items.CreateLot(product, 7f)].Quality, 3);
Assert.Equal(.01f, _items.Lots[_items.CreateLot(product, -1f)].Roles.Single().Quality, 3);
}
// The ceiling at the mint: a part drawn at .9 in an assembly of .4 reads .4; a part at .2 in the same assembly reads .2.
[Fact]
public void RoleCappedByAssembly()
{
var maker = new Faction { Name = "Capped", AssemblyMean = .4f, AssemblyDeviation = 0 };
var maker = new Faction { Name = "Capped", Assembly = A(.4f, 0) };
var over = _items.Lots[_items.CreateLot(Product("Over", maker, .9f))];
var under = _items.Lots[_items.CreateLot(Product("Under", maker, .2f))];
Assert.Equal(.4f, over.Quality, 3);
@ -110,7 +139,7 @@ public sealed class AssemblyFromMakerTests : IDisposable
[Fact]
public void QualityTermReadsCeiling()
{
var maker = new Faction { Name = "Capped", AssemblyMean = .4f, AssemblyDeviation = 0 };
var maker = new Faction { Name = "Capped", Assembly = A(.4f, 0) };
var instance = (EquippableItem) _items.CreateInstance(_items.CreateLot(Product("Over", maker, .9f)));
var stat = new PerformanceStat { Min = 0, Max = 1, Terms = { new StatTerm { Source = StatSource.Quality, Exponent = 1, Role = "lens" } } };
Assert.Equal(.4f, _items.Evaluate(stat, instance), 3);

View file

@ -84,7 +84,7 @@ public sealed class RoleAuthoringTests : IDisposable
public void TwoProductsOfOneDesignWithDifferentRoleMeansResolveDifferentValues()
{
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
var makerRef = cache.Upsert(new Faction { Name = "Maker", ShortName = "MKR", AssemblyMean = 1f, AssemblyDeviation = 0 });
var makerRef = cache.Upsert(new Faction { Name = "Maker", ShortName = "MKR", Assembly = new AssemblyDistribution { Mean = 1f, StandardDeviation = 0 } });
var lensGear = cache.Upsert(new GearData
{
Name = "Lensed", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10,

View file

@ -8,42 +8,45 @@ using System.IO;
using System.Linq;
using GameCult.Caching;
// assembly-from-maker [apply] (aetheria-release, cut assembly-from-maker; rulings quality-is-assembly-over-supply-chain,
// quality-lean-assembly-hard-ceiling): derives each maker's final-assembly distribution from the products it sells. A faction
// that manufactures at least one product with roles gets AssemblyMean = the mean of those products' role means and
// AssemblyDeviation = the mean of their standard deviations, rounded to 2 dp; every other faction gets the field defaults.
// The derivation reads the products, so it replays last, after every command that adds products or factions. A faction that
// already holds the derived values is not rewritten, so a rerun writes nothing. Dry run unless apply.
// assembly-from-maker [apply] (aetheria-release, cut assembly-from-maker; rulings quality-lean-assembly-hard-ceiling,
// quality-min-tolerance-binned-assembly): seeds each maker's final assembly from the products it sells. A faction with no
// Assembly that manufactures at least one product with roles gets Mean = the mean of those products' role means plus Margin
// (a maker assembles a little above its parts, capped at 1) and StandardDeviation = the mean of their standard deviations,
// both rounded to 2 dp. It writes only an Assembly that is null, so a value tuned in Studio survives every replay, and it
// never writes a product's Assembly. The derivation reads the products, so it replays last, after every command that adds
// products or factions. Dry run unless apply.
public static class AssemblyFromMakerCommand
{
// The distribution a maker's roled products imply: the mean over every role of every product, so a product with more
// roles weighs more. Null when there is no role to average.
public static (float Mean, float Deviation)? Derive(IEnumerable<FactionProductData> products)
// How far above its part mean a maker's final assembly sits: the tolerance margin of the ceiling.
public const float Margin = .1f;
// The assembly a maker's roled products imply: the mean over every role of every product, so a product with more roles
// weighs more. Null when there is no role to average.
public static AssemblyDistribution Derive(IEnumerable<FactionProductData> products)
{
var roles = products.SelectMany(p => p.Roles ?? new List<ProductRole>()).ToArray();
if (roles.Length == 0) return null;
return ((float) Math.Round(roles.Average(r => r.Mean), 2), (float) Math.Round(roles.Average(r => r.StandardDeviation), 2));
return new AssemblyDistribution
{
Mean = (float) Math.Round(Math.Min(1, roles.Average(r => r.Mean) + Margin), 2),
StandardDeviation = (float) Math.Round(roles.Average(r => r.StandardDeviation), 2)
};
}
public static int Run(CultCache cache, bool apply, TextWriter output)
{
var defaults = new Faction();
var byMaker = cache.GetAll<FactionProductData>()
.GroupBy(p => p.Manufacturer.Key)
.Select(g => (Maker: g.Key, Derived: Derive(g)))
.Where(m => m.Derived != null)
.ToDictionary(m => m.Maker, m => m.Derived.Value);
.ToDictionary(m => m.Maker, m => m.Derived);
var changed = new List<Faction>();
foreach (var faction in cache.GetAll<Faction>().OrderBy(f => f.Name, StringComparer.Ordinal))
{
var (mean, deviation) = byMaker.TryGetValue(cache.RefOf(faction).Key, out var derived)
? derived
: (defaults.AssemblyMean, defaults.AssemblyDeviation);
if (faction.AssemblyMean == mean && faction.AssemblyDeviation == deviation) continue;
output.WriteLine($"{faction.Name}: assembly {faction.AssemblyMean:0.##}±{faction.AssemblyDeviation:0.##} -> {mean:0.##}±{deviation:0.##}");
faction.AssemblyMean = mean;
faction.AssemblyDeviation = deviation;
if (faction.Assembly != null || !byMaker.TryGetValue(cache.RefOf(faction).Key, out var derived)) continue;
output.WriteLine($"{faction.Name}: assembly -> {derived.Mean:0.##}±{derived.StandardDeviation:0.##}");
faction.Assembly = derived;
changed.Add(faction);
}
@ -55,7 +58,8 @@ public static class AssemblyFromMakerCommand
if (!apply)
{
output.WriteLine($"\nDry run. Pass \"apply\" to write {changed.Count} factions.");
output.WriteLine($"
Dry run. Pass \"apply\" to write {changed.Count} factions.");
return 0;
}
@ -64,7 +68,8 @@ public static class AssemblyFromMakerCommand
{
foreach (var faction in changed) batch.Upsert(typeof(Faction), faction, cache.RefOf(faction).Key);
});
output.WriteLine($"\nWrote {changed.Count} factions in Aetheria.cc");
output.WriteLine($"
Wrote {changed.Count} factions in Aetheria.cc");
return 0;
}
}