From f73507ff2fa847ac0cd0d0eafcc9d02f32e7f78e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 21:08:10 +0200 Subject: [PATCH] Catalog read refuses a bin with a non-finite Floor, a missing Line or a Line that is a bin; a bin mints from exactly its named line (tested) The runtime no longer trusts the authoring tool for a bin: a NaN Floor weighed NaN and nulled every draw of its design. AetheriaStores.Open names the field and echoes no value. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/AetheriaStores.cs | 19 +++++ .../Aetheria.Shared.Tests/ProductBinTests.cs | 78 +++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 4afb00b1d..525894ac9 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -41,6 +41,7 @@ public static class AetheriaStores foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); VerseGrammarValidation.ValidateAll(cache.GetAll()); + 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()) + { + 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 diff --git a/tests/Aetheria.Shared.Tests/ProductBinTests.cs b/tests/Aetheria.Shared.Tests/ProductBinTests.cs index 03762fdf2..c7b7de1fb 100644 --- a/tests/Aetheria.Shared.Tests/ProductBinTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductBinTests.cs @@ -183,6 +183,84 @@ public sealed class ProductBinTests : IDisposable Assert.Equal("Premiere", product.Name); } + // The line a bin draws from is the one Bin.Line names, not another line of the design: three lines of one design, by three + // makers, with the bin on the middle one by name, so a bin that reads the first or the last line of the design is far off. + [Fact] + public void ABinMintsFromExactlyTheLineItNames() + { + Line("A line", lensMean: .3f, makerMean: .2f); + var middle = Line("M line", lensMean: .6f, makerMean: .5f); + Line("Z line", lensMean: .9f, makerMean: .9f); + var lots = Mint(Bin(middle, "Middle Premiere", 0), 400); + Assert.All(lots, lot => + { + Assert.True(lot.Roles.Single().Quality >= .6f - 1e-5f, "a fill came from a line below the named one"); + Assert.True(lot.Quality >= .5f - 1e-5f, "an assembly came from a maker below the named line's"); + }); + Assert.InRange(Fills(lots).Average(), .64f, .72f); + Assert.InRange(lots.Average(lot => lot.Quality), .55f, .62f); + } + + private const string Canary = "canary-7f3a91"; + + private enum Damage { None, LineRemoved, LineIsBin } + + // A catalog on disk holding one line and a bin of it, damaged as asked, for AetheriaStores.Open to read. + private string DamagedCatalog(float floor, Damage damage) + { + var path = Path.Combine(_root, Guid.NewGuid().ToString("N"), "Aetheria.cc"); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + using var seed = AetheriaStores.Open(path, catalogWritable: true); + seed.Upsert(new VerseGrammar { Revision = 1 }); + var maker = seed.Upsert(new Faction { Name = Canary + " Maker" }); + var gear = seed.Upsert(new GearData { Name = "Lensed", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10, Roles = { new ItemRole { Name = "lens" } } }); + var design = new CultRecordRef(gear.Key); + var line = seed.Upsert(new FactionProductData { Name = Canary + " Line", Design = design, Manufacturer = maker, Roles = { new ProductRole { Role = "lens" } } }); + var bin = seed.Upsert(new FactionProductData { Name = Canary, Design = design, Manufacturer = maker, Bin = new ProductBin { Line = line, Floor = floor } }); + if (damage == Damage.LineIsBin) + seed.Upsert(new FactionProductData { Name = Canary + " Nested", Design = design, Manufacturer = maker, Bin = new ProductBin { Line = bin, Floor = 0 } }); + if (damage == Damage.LineRemoved) seed.Remove(line.Key); + seed.FlushAsync().Wait(); + return path; + } + + // The runtime does not trust authoring for a bin: a NaN floor would weigh NaN and null every draw of its design. + [Theory] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + [InlineData(float.NegativeInfinity)] + public void ACatalogWhoseBinFloorIsNotFiniteIsRefusedWhereItIsRead(float floor) + { + var refusal = Assert.Throws(() => AetheriaStores.Open(DamagedCatalog(floor, Damage.None))); + Assert.Contains("Floor", refusal.Message); + Assert.DoesNotContain(Canary, refusal.Message); + Assert.DoesNotContain(floor.ToString(), refusal.Message); + } + + [Fact] + public void ACatalogWhoseBinNamesNoLineIsRefusedWhereItIsRead() + { + var refusal = Assert.Throws(() => AetheriaStores.Open(DamagedCatalog(.67f, Damage.LineRemoved))); + Assert.Contains("Line", refusal.Message); + Assert.DoesNotContain(Canary, refusal.Message); + } + + [Fact] + public void ACatalogWhoseBinNamesAnotherBinIsRefusedWhereItIsRead() + { + var refusal = Assert.Throws(() => AetheriaStores.Open(DamagedCatalog(.67f, Damage.LineIsBin))); + Assert.Contains("Line", refusal.Message); + Assert.DoesNotContain(Canary, refusal.Message); + } + + // The refusals are of damage, not of bins: a sound bin on a sound line reads. + [Fact] + public void ACatalogWithASoundBinIsRead() + { + using var cache = AetheriaStores.Open(DamagedCatalog(.67f, Damage.None)); + Assert.Equal(.67f, cache.GetAll().Single(product => product.Name == Canary).Bin.Floor); + } + // 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()