Aetheria/tests/Aetheria.Shared.Tests/AssemblyFromMakerTests.cs
soul cc0c164574 determinism-ledger: every ItemManager states its seed; scatter on the zone's dice
- ItemManager(uint seed, ...) refuses seed 0 and seeds Random from it.
- RunStart.ItemSeed(galaxy, step) is public (formula unchanged), refuses a
  zero result; named steps ArenaStep, StageStep, ContinueStep and NoRunSeed.
- Continue seeds the item stream from the saved run's seed (ContinueStep);
  a scene with no galaxy uses NoRunSeed.
- Zone's unpack scatter draws SimulationDice.For(CombatSeed, ScatterStream,
  1-based pack ordinal), never the item stream.
- Loadout's scratch manager and AetherDb's catalog command take fixed seeds;
  every test and Editor construction gains 1u (mechanical rewrite).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:01:41 +02:00

233 lines
12 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using GameCult.Caching;
using Xunit;
// Assembly is the maker's (aetheria-release, cut assembly-from-maker; rulings quality-is-assembly-over-supply-chain,
// quality-lean-assembly-hard-ceiling): a branded lot's own quality is drawn from its manufacturer's assembly distribution,
// and every role a stat reads is min(part, assembly). Lots here come from CreateLot(product), the production mint.
public sealed class AssemblyFromMakerTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-assembly-" + Guid.NewGuid().ToString("N"));
private readonly CultCache _cache;
private readonly ItemManager _items;
private readonly CultRecordRef<CraftedItemData> _lens;
public AssemblyFromMakerTests()
{
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<CraftedItemData>(gear.Key);
_items = new ItemManager(1u, _cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
}
public void Dispose()
{
_cache.Dispose();
Directory.Delete(_root, true);
}
private FactionProductData Product(string name, Faction maker, float partMean, float partDeviation = 0)
{
var makerRef = maker == null ? default : _cache.Upsert(maker);
_cache.Upsert(new FactionProductData
{
Name = name, Design = _lens, Manufacturer = makerRef,
Roles = { new ProductRole { Role = "lens", Mean = partMean, StandardDeviation = partDeviation } }
});
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();
// Two makers, assembly .3 and .8: each lot's own quality follows its manufacturer, not a product-blind roll.
[Fact]
public void LotQualityFollowsMaker()
{
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);
Assert.InRange(highs.Average(), .77f, .83f);
Assert.True(lows.Max() < highs.Min(), "a lot read the other maker's band");
}
// Quality control is the maker's too: the same mean, a wide and a tight deviation, and the spread of their lots follows.
[Fact]
public void LotSpreadFollowsMakerDeviation()
{
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, and a maker whose Assembly is not authored, assemble at AssemblyDistribution's defaults.
[Fact]
public void UnauthoredAssemblyAssemblesAtDefaults()
{
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);
}
// A drawn assembly stays a quality: a maker authored past either end mints lots at .01 or 1.
[Fact]
public void DrawnAssemblyIsClamped()
{
Assert.All(Mint(Product("Over", new Faction { Name = "Over Maker", Assembly = A(1.5f, 0) }, .5f), 20), q => Assert.Equal(1f, q, 3));
Assert.All(Mint(Product("Under", new Faction { Name = "Under Maker", Assembly = A(-.5f, 0) }, .5f), 20), q => Assert.Equal(.01f, q, 3));
}
// A staged fit's slot is built by Loadouts.Resolve: a product at the stated quality, a design no maker sells at the stated
// quality instead of UnbrandedQuality, and both at their own draws without one.
[Fact]
public void ResolveMintsAtTheStatedQuality()
{
var failures = new List<string>();
Lot Built(CultRecordRef<FactionProductData> product, float? quality) =>
_items.GetLot(Loadouts.Resolve(_items, _lens, product, _ => null, "lens", failures, quality)());
var unbranded = Built(default, .6f);
Assert.Equal(.6f, unbranded.Quality, 3);
Assert.Equal(Loadouts.UnbrandedQuality, Built(default, null).Quality, 3);
var made = Product("Made", new Faction { Name = "Made Maker", Assembly = A(.3f, .2f) }, .5f, .2f);
var branded = Built(_cache.RefOf(made), .6f);
Assert.Equal(.6f, branded.Quality, 3);
Assert.Equal(.6f, branded.Roles.Single().Quality, 3);
Assert.Empty(failures);
}
// 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", 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);
Assert.Equal(.4f, over.QualityForRole("lens"), 3);
Assert.Equal(.2f, under.QualityForRole("lens"), 3);
// The raw part is kept for disassembly (named-components-after-demo).
Assert.Equal(.9f, over.Roles.Single().Quality, 3);
}
// The ceiling is the assembly itself at every height and to the last digit: a poor assembler (below any floor) still caps a
// good part at its own quality, a part just above the assembly reads the assembly and not its own, and a part at or below it
// keeps its own.
[Fact]
public void RoleCeilingHasNoFloorAndNoToleranceBand()
{
float Role(float assembly, float part) =>
_items.Lots[_items.CreateLot(Product($"A{assembly}P{part}", new Faction { Name = $"Maker {assembly}/{part}", Assembly = A(assembly, 0) }, part))].QualityForRole("lens");
Assert.Equal(.1f, Role(.1f, .9f), 3);
Assert.Equal(.02f, Role(.02f, .5f), 3);
Assert.Equal(.4f, Role(.4f, .41f), 3);
Assert.Equal(.4f, Role(.4f, .43f), 3);
Assert.Equal(.4f, Role(.4f, .4f), 3);
Assert.Equal(.39f, Role(.4f, .39f), 3);
}
// The line's own final test replaces the maker's whole distribution, spread included: a maker of no spread and a product
// of a wide one mint a wide spread, and the reverse mints none.
[Fact]
public void ProductAssemblyOverridesMakersSpreadToo()
{
var steady = new Faction { Name = "Steady", Assembly = A(.5f, 0) };
var wild = Product("Wild", steady, .5f);
wild.Assembly = A(.5f, .2f);
var wildLots = Mint(wild, 400);
Assert.InRange(Math.Sqrt(wildLots.Select(q => Math.Pow(q - wildLots.Average(), 2)).Average()), .15, .25);
var loose = new Faction { Name = "Loose", Assembly = A(.5f, .2f) };
var tight = Product("Tight", loose, .5f);
tight.Assembly = A(.5f, 0);
Assert.All(Mint(tight, 50), q => Assert.Equal(.5f, q, 3));
}
// The fallbacks read the item's effective quality: an unnamed role and an unfilled role on a lot whose fills sit below and
// above its assembly (lens .3 -> .3, frame .9 -> .6, mean .45), and a lot with no roles reading its assembly. A found
// fill still reads its own capped part.
[Fact]
public void FallbacksReadEffectiveQuality()
{
var lot = new Lot
{
Quality = .6f,
Roles = new List<RoleFill> { new RoleFill { Role = "lens", Quality = .3f }, new RoleFill { Role = "frame", Quality = .9f } }
};
Assert.Equal(.45f, lot.QualityForRole(null), 3);
Assert.Equal(.45f, lot.QualityForRole(""), 3);
Assert.Equal(.45f, lot.QualityForRole("ghost"), 3);
Assert.Equal(.3f, lot.QualityForRole("lens"), 3);
Assert.Equal(.4f, new Lot { Quality = .4f }.QualityForRole("lens"), 3);
Assert.Equal(.4f, new Lot { Quality = .4f, Roles = new List<RoleFill>() }.QualityForRole("lens"), 3);
}
// The effective quality is the mean over the fills of min(part, assembly): assembly .6 with parts .9, .3, .5 reads
// (.6 + .3 + .5) / 3, not the raw mean .5667, the min .3, the assembly .6 or the max. A lot with no fills reads its assembly.
[Fact]
public void EffectiveQualityIsTheMeanOfCappedFills()
{
var lot = new Lot
{
Quality = .6f,
Roles = new List<RoleFill>
{
new RoleFill { Role = "a", Quality = .9f }, new RoleFill { Role = "b", Quality = .3f }, new RoleFill { Role = "c", Quality = .5f }
}
};
Assert.Equal(.4667f, lot.EffectiveQuality(), 3);
Assert.Equal(.6f, new Lot { Quality = .6f }.EffectiveQuality(), 3);
Assert.Equal(.6f, new Lot { Quality = .6f, Roles = new List<RoleFill>() }.EffectiveQuality(), 3);
}
// A Quality term on a role with part .9 and assembly .4 evaluates as if quality were .4, on a real minted instance.
[Fact]
public void QualityTermReadsCeiling()
{
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);
}
}