From d267a535d294c7af483e4ae35dd767e5734b2e82 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 18:46:09 +0200 Subject: [PATCH 01/23] Delete NoMakerSellsTwoProductsOfOneDesign (ruling maker-design-tiers-allowed) Its justification, Brand finding a product by (maker, design), went with lot-product. A maker may sell several products of one design at different performance. All commits in this pass build unless noted. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PiratesRecordTests.cs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PiratesRecordTests.cs b/tests/Aetheria.Shared.Tests/PiratesRecordTests.cs index 67ae08d22..637b2e65e 100644 --- a/tests/Aetheria.Shared.Tests/PiratesRecordTests.cs +++ b/tests/Aetheria.Shared.Tests/PiratesRecordTests.cs @@ -53,18 +53,6 @@ public sealed partial class RunStartTests } } - // Brand finds a lot's product by (maker, design), so a maker may sell one product per design. - [Fact] - public void NoMakerSellsTwoProductsOfOneDesign() - { - var twice = _cache.GetAll() - .GroupBy(product => (Maker: product.Manufacturer.Key.Value, Design: product.Design.Key.Value)) - .Where(group => group.Count() > 1) - .Select(group => string.Join(", ", group.Select(product => product.Name))) - .ToArray(); - Assert.Empty(twice); - } - // A unit minted from a Pirates rebrand titles and brands as that rebrand: the lot records its product, so the // emoji name shows although the same design is also sold by its original maker. [Fact] From fa3c2ab4d3c22c515ecd53b355c41a35b73dec14 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 18:46:12 +0200 Subject: [PATCH 02/23] AetherDb: product-lines command, with the weapons table One command file owns the twenty second-maker weapon products; roles and quality come from Program.MakerProfiles, now internal. A maker already selling the design is not refused. Co-Authored-By: Claude Opus 5.5 --- tools/AetherDb/ProductLinesCommand.cs | 130 ++++++++++++++++++++++++++ tools/AetherDb/Program.cs | 7 +- 2 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 tools/AetherDb/ProductLinesCommand.cs diff --git a/tools/AetherDb/ProductLinesCommand.cs b/tools/AetherDb/ProductLinesCommand.cs new file mode 100644 index 000000000..aa17159d6 --- /dev/null +++ b/tools/AetherDb/ProductLinesCommand.cs @@ -0,0 +1,130 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.Linq; +using GameCult.Caching; + +// product-lines [apply] (aetheria-release, cut product-lines-weapons; rulings catalog-grows-generic-designs-branded-products, +// maker-design-tiers-allowed, miss-terris-belongs-to-emily-r3, pirates-record-content): authors the branded products of one +// product line into GameData/Aetheria.cc. A line is a table of (design, product, maker, text); each row becomes one +// FactionProductData with one ProductRole per role of its design, at the maker's profile in Program.MakerProfiles, so the table +// carries no quality numbers. A row already in the catalog (same name, design and maker) is skipped, so the same command is the +// first apply, the second apply and the replay onto a merged catalog. A maker that already sells the design is not refused: a +// tier is allowed. Dry run unless passed "apply". +public static class ProductLinesCommand +{ + internal sealed record Row(string Design, string Name, string Maker, string Description); + + private static readonly Row[] Weapons = + { + new("Light Machine Gun", "Rattler", "AU", "Cheap, loud, and there when the rocks start moving."), + new("Slug Rifle", "SM-6 Marksman", "R&D", "Standardised, certified, guaranteed to hit the target.*"), + new("Long Slug Rifle", "Longshot", "Finch", "Light, quiet and dead accurate, when it feels like it."), + new("Autocannon", "Workhorse", "AU", "Feeds from anything, fires at anything."), + new("Cluster Cannon", "everything at once", "DME", "why aim/ when you can be everywhere"), + new("Charged Scattergun", "Grapeshot", "Zhestokost", "The old answer to boarders, sized for the void."), + new("Light Laser", "Sparrow", "Finch", "Barely registers on the scale, or on their sensors."), + new("Light Laser", "Spotlight", "Lucent", "Make sure they know who got them."), + new("Cell Blaster", "Strobe", "Lucent", "Fast, bright, and very hard to ignore."), + new("Long Laser", "Merlin", "Finch", "Hovers at the edge of range, then stoops."), + new("Heavy Beam Laser", "Afterglow", "NiteLife", "Keeps glowing long after the fight is over."), + new("Charged Blaster", "Encore", "Lucent", "One more, for the fans."), + new("Charged Scatter Blaster", "nobody leaves early", "DME", "doors are locked/ lights are low/ stay for the finale"), + new("Lightning Projector", "Static Shock", "NiteLife", "A jolt for every ship in reach."), + new("Guided Missile Launcher", "Homer", "Finch", "Finds its way, mostly."), + new("Long-Range Missile Rack", "Long Haul", "AU", "Delivers to the far side of the zone."), + new("Short-Range Missile Rack", "Short Fuse", "AU", "For problems already too close."), + new("Swarm Rocket Pod", "Confetti", "Lucent", "Every launch is a celebration."), + new("Incendiary Rocket Pod", "Trebuchet", "Zhestokost", "Siege fire for a column that does not stop."), + new("Mine Launcher", "Tripwire", "Zhestokost", "Closes the road behind the column."), + }; + + // Every line the command knows, by the word that follows "product-lines". + private static readonly Dictionary Lines = new() { ["weapons"] = Weapons }; + + // A line's rows, for the catalog tests that check the shipped records against the table the command authors from. + internal static IReadOnlyList Table(string line) => Lines[line]; + + public static int Run(string[] args, string root = null) + { + var apply = args.Contains("apply"); + var line = args.FirstOrDefault(a => a != "apply"); + if (line == null || !Lines.TryGetValue(line, out var rows)) + { + Console.WriteLine($"usage: product-lines {string.Join("|", Lines.Keys)} [apply]"); + return 1; + } + + var db = AetherDb.Open(catalogWritable: apply, root: root); + var designs = db.Cache.GetAll().ToArray(); + var factions = db.Cache.GetAll().ToArray(); + var products = db.Cache.GetAll().ToArray(); + + // Resolve every row before anything is written: a missing or ambiguous design or maker, a name that belongs to another + // product or to a design, and a design with no roles each stop the command with the catalog untouched. + var resolved = new List<(Row Row, CraftedItemData Design, Faction Maker, bool Present)>(); + foreach (var row in rows) + { + var design = designs.Where(d => d.Name == row.Design).ToArray(); + if (design.Length != 1) throw new InvalidOperationException($"Expected exactly one design named \"{row.Design}\", found {design.Length}."); + var maker = factions.Where(f => f.ShortName == row.Maker).ToArray(); + if (maker.Length != 1) throw new InvalidOperationException($"Expected exactly one faction with short name \"{row.Maker}\", found {maker.Length}."); + if (!Program.MakerProfiles.ContainsKey(row.Maker)) throw new InvalidOperationException($"No maker profile for \"{row.Maker}\"."); + if (design[0].Roles.Count == 0) throw new InvalidOperationException($"The design \"{row.Design}\" has no roles."); + + var designKey = db.Cache.RefOf(design[0]).Key; + var makerKey = db.Cache.RefOf(maker[0]).Key; + var named = products.Where(p => string.Equals(p.Name, row.Name, StringComparison.OrdinalIgnoreCase)).ToArray(); + var present = named.Length == 1 && named[0].Name == row.Name && named[0].Design.Key.Equals(designKey) && named[0].Manufacturer.Key.Equals(makerKey); + if (named.Length != 0 && !present) throw new InvalidOperationException($"The product name \"{row.Name}\" already belongs to another product."); + if (!present && designs.Any(d => string.Equals(d.Name, row.Name, StringComparison.OrdinalIgnoreCase))) + throw new InvalidOperationException($"The product name \"{row.Name}\" is the name of a design."); + resolved.Add((row, design[0], maker[0], present)); + } + + var missing = resolved.Where(r => !r.Present).ToArray(); + if (missing.Length == 0) + { + Console.WriteLine($"The {line} product line is already authored; nothing to do."); + return 0; + } + + Console.WriteLine($"Product line {line}:"); + foreach (var (row, design, _, present) in resolved) + { + var (mean, dev) = Program.MakerProfiles[row.Maker]; + Console.WriteLine($" {(present ? "present" : "new ")} {row.Design} -> \"{row.Name}\" by {row.Maker}, mean {mean:0.##} dev {dev:0.##} on [{string.Join(", ", design.Roles.Select(r => r.Name))}]: {row.Description}"); + } + + if (!apply) + { + Console.WriteLine($"\nDry run. Pass \"apply\" to author {missing.Length} products."); + return 0; + } + + // ExpectUnchanged lands the batch onto the file as it is: every other record keeps the bytes it has, where a plain + // commit would re-encode the whole catalog through today's types. + if (!db.Cache.Commit(batch => + { + batch.ExpectUnchanged(); + foreach (var (row, design, maker, _) in missing) + { + var (mean, dev) = Program.MakerProfiles[row.Maker]; + batch.Upsert(typeof(FactionProductData), new FactionProductData + { + Name = row.Name, + Description = row.Description, + Design = db.Cache.RefOf(design), + Manufacturer = db.Cache.RefOf(maker), + Roles = design.Roles.Select(r => new ProductRole { Role = r.Name, Mean = mean, StandardDeviation = dev }).ToList(), + }); + } + })) + throw new InvalidOperationException("The catalog changed while the product line was being authored; nothing was written. Run it again."); + Console.WriteLine($"\nAuthored {missing.Length} products in Aetheria.cc"); + return 0; + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index a99c7526c..b2af11686 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -55,8 +55,9 @@ public static class Program case "sensor-stat-set": return SensorStatSet(args.Contains("apply")); case "generic-designs": return GenericDesigns(args.Contains("apply")); case "inspection-scanner": return InspectionScannerCatalog(args.Contains("apply")); + case "product-lines": return ProductLinesCommand.Run(args.Skip(1).ToArray()); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], product-lines weapons [apply]"); return 1; } } @@ -250,8 +251,8 @@ public static class Program }; // One Mean / StandardDeviation per maker, applied to every role of a design the maker sells (catalog-growth - // addenda, "Maker profiles"); the Pirates are middling and inconsistent (pirates-record). - private static readonly Dictionary MakerProfiles = new Dictionary + // addenda, "Maker profiles"); the Pirates are middling and inconsistent (pirates-record). Read by ProductLinesCommand too. + internal static readonly Dictionary MakerProfiles = new Dictionary { ["Zhestokost"] = (.50f, .08f), ["AU"] = (.50f, .12f), ["Lightsail"] = (.55f, .10f), ["R&D"] = (.60f, .15f), ["Lucent"] = (.60f, .15f), ["NiteLife"] = (.55f, .12f), ["Finch"] = (.65f, .20f), ["Alakrita"] = (.60f, .14f), From 6667b4c6d3a737a5efb3c92800d29c61479b5e50 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 18:47:30 +0200 Subject: [PATCH 03/23] Tests for the weapon product lines and the product-lines command Catalog tests read the shipped records against the command's own table; command tests run it on a scratch catalog (dry run, apply, replay, untouched bytes, partial line, name conflict, tier accepted). AetherDb shows its internals to the test project. These tests fail until the catalog commit that follows; this commit builds. Co-Authored-By: Claude Opus 5.5 --- .../ProductLinesCatalogTests.cs | 67 +++++++ .../ProductLinesCommandTests.cs | 169 ++++++++++++++++++ tools/AetherDb/AetherDb.csproj | 1 + 3 files changed, 237 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs create mode 100644 tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs diff --git a/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs new file mode 100644 index 000000000..a4cfa7ac0 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs @@ -0,0 +1,67 @@ +using System; +using System.Linq; +using GameCult.Caching; +using Xunit; + +// The weapon product lines the shipped catalog holds (campaign aetheria-release, cut product-lines-weapons; rulings +// catalog-grows-generic-designs-branded-products, maker-design-tiers-allowed): a sold weapon design has several brands, a +// brand name is one product's, and every row of the command's table is in the catalog with the quality its maker's profile gives. +// The rows come from the command's own table and the profiles from its own maker-profile table, so a row edited in one place +// and not shipped fails here. +public sealed class ProductLinesCatalogTests +{ + // A design with any product is sold; the Smoke test designs have none and stay exempt. + [Fact] + public void EveryWeaponDesignWithAProductHasTwoMakers() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var products = cache.GetAll().ToArray(); + var sold = 0; + foreach (var design in cache.GetAll()) + { + var key = cache.RefOf(design).Key; + var makers = products.Where(p => p.Design.Key.Equals(key)).Select(p => p.Manufacturer.Key).Distinct().Count(); + if (makers == 0) continue; + sold++; + Assert.True(makers >= 2, $"the weapon design \"{design.Name}\" is sold by {makers} maker"); + } + Assert.True(sold >= 20, "the catalog should sell many weapon designs"); + } + + [Fact] + public void NoTwoProductsShareAName() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var shared = cache.GetAll() + .GroupBy(p => p.Name, StringComparer.OrdinalIgnoreCase) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToArray(); + Assert.Empty(shared); + } + + [Fact] + public void WeaponLineRowsAreShipped() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var table = ProductLinesCommand.Table("weapons"); + Assert.Equal(20, table.Count); + foreach (var row in table) + { + var product = Assert.Single(cache.GetAll(), p => p.Name == row.Name); + var design = cache.Get(product.Design); + Assert.Equal(row.Design, design.Name); + Assert.Equal(row.Maker, cache.Get(product.Manufacturer).ShortName); + Assert.Equal(row.Description, product.Description); + var (mean, dev) = Program.MakerProfiles[row.Maker]; + Assert.Equal( + design.Roles.Select(r => r.Name).OrderBy(n => n, StringComparer.Ordinal), + product.Roles.Select(r => r.Role).OrderBy(n => n, StringComparer.Ordinal)); + foreach (var role in product.Roles) + { + Assert.Equal(mean, role.Mean, 4); + Assert.Equal(dev, role.StandardDeviation, 4); + } + } + } +} diff --git a/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs b/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs new file mode 100644 index 000000000..ffa69d59e --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; + +// AetherDb's product-lines command (cut product-lines-weapons), run on a scratch copy of the shipped catalog with the weapon +// line's twenty products removed from it. Apply must write what the shipped catalog holds, only its own records, finish a +// catalog that holds part of the line, refuse a name that is another product's, and accept a maker that already sells the design. +public sealed class ProductLinesCommandTests : IDisposable +{ + private static readonly string[] Names = ProductLinesCommand.Table("weapons").Select(r => r.Name).ToArray(); + private static readonly string[] Line = { "weapons" }; + private static readonly string[] LineApply = { "weapons", "apply" }; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "product-lines-command-" + Guid.NewGuid().ToString("N")); + private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc"); + + // The rows as the shipped catalog holds them, taken before they are removed from the scratch copy. + private readonly string[] _shipped; + + private static string Describe(CultCache cache, FactionProductData product) => + $"{cache.Get(product.Design).Name} | {product.Name} | {cache.Get(product.Manufacturer).ShortName} | {product.Description} | " + + string.Join(",", product.Roles.OrderBy(r => r.Role, StringComparer.Ordinal).Select(r => $"{r.Role}:{r.Mean}:{r.StandardDeviation}")); + + public ProductLinesCommandTests() + { + Directory.CreateDirectory(Path.Combine(_root, "GameData")); + Directory.CreateDirectory(Path.Combine(_root, "Assets", "Resources")); + var shipped = AetherDb.FindRoot(); + File.Copy(Path.Combine(shipped, "GameData", "Aetheria.cc"), CatalogPath); + File.Copy(Path.Combine(shipped, "Assets", "Resources", "Settings.asset"), Path.Combine(_root, "Assets", "Resources", "Settings.asset")); + using var cache = Open(); + var products = cache.GetAll().Where(p => Names.Contains(p.Name)).ToArray(); + Assert.Equal(Names.Length, products.Length); + _shipped = products.Select(p => Describe(cache, p)).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + foreach (var product in products) Assert.True(cache.Remove(cache.RefOf(product).Key)); + cache.FlushAsync().Wait(); + } + + public void Dispose() => Directory.Delete(_root, true); + + private CultCache Open() + { + var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes); + return cache; + } + + private string[] Line20() + { + using var cache = Open(); + return cache.GetAll().Where(p => Names.Contains(p.Name)).Select(p => Describe(cache, p)).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + } + + [Fact] + public void A_dry_run_writes_nothing_and_apply_lands_the_shipped_rows_once() + { + var before = File.ReadAllBytes(CatalogPath); + Assert.Empty(Line20()); + + Assert.Equal(0, ProductLinesCommand.Run(Line, _root)); + Assert.Equal(before, File.ReadAllBytes(CatalogPath)); + Assert.Empty(Line20()); + + Assert.Equal(0, ProductLinesCommand.Run(LineApply, _root)); + var landed = File.ReadAllBytes(CatalogPath); + Assert.NotEqual(before, landed); + // Record keys are CultCache's to mint and differ run to run; the rows do not. + Assert.Equal(_shipped, Line20()); + + // A second apply finds everything authored and writes nothing; so does a dry run. + Assert.Equal(0, ProductLinesCommand.Run(LineApply, _root)); + Assert.Equal(landed, File.ReadAllBytes(CatalogPath)); + Assert.Equal(0, ProductLinesCommand.Run(Line, _root)); + Assert.Equal(landed, File.ReadAllBytes(CatalogPath)); + } + + // Re-spell every record's payload in a longer, equally valid MessagePack form (an array16 header for the fixarray one). A + // write that re-encodes records it did not author shows as changed bytes; one that lands onto the file as it is leaves them. + private int WidenEveryPayload() + { + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(CatalogPath)); + var widened = 0; + foreach (var record in snapshot.Records) + { + var payload = record.Payload; + if (payload.Length == 0 || (payload[0] & 0xF0) != 0x90) continue; + record.Payload = new byte[] { 0xDC, 0x00, (byte)(payload[0] & 0x0F) }.Concat(payload.Skip(1)).ToArray(); + widened++; + } + File.WriteAllBytes(CatalogPath, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + return widened; + } + + private Dictionary Records() => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(CatalogPath)).Records.ToDictionary(r => r.Key, StringComparer.Ordinal); + + [Fact] + public void Apply_writes_only_the_records_it_authors() + { + Assert.True(WidenEveryPayload() > 100, "the scratch catalog should hold many records in the widened spelling"); + var before = Records(); + + Assert.Equal(0, ProductLinesCommand.Run(LineApply, _root)); + + var after = Records(); + Assert.Equal(before.Count + Names.Length, after.Count); + foreach (var (key, record) in before) + { + Assert.True(after.TryGetValue(key, out var kept), "a record the command did not author is gone"); + Assert.Equal(record.SchemaId, kept.SchemaId); + Assert.Equal(record.StoredAt, kept.StoredAt); + Assert.True(record.Payload.AsSpan().SequenceEqual(kept.Payload), "a record the command did not author has changed bytes"); + } + } + + [Fact] + public void Apply_finishes_a_catalog_that_holds_part_of_the_line() + { + Assert.Equal(0, ProductLinesCommand.Run(LineApply, _root)); + using (var cache = Open()) + { + foreach (var product in cache.GetAll().Where(p => p.Name == "Merlin" || p.Name == "Tripwire").ToArray()) + Assert.True(cache.Remove(cache.RefOf(product).Key)); + cache.FlushAsync().Wait(); + } + Assert.Equal(Names.Length - 2, Line20().Length); + + Assert.Equal(0, ProductLinesCommand.Run(LineApply, _root)); + Assert.Equal(_shipped, Line20()); + } + + [Fact] + public void Apply_refuses_a_row_name_that_is_another_products_and_writes_nothing() + { + using (var cache = Open()) + { + var other = cache.GetAll().First(w => w.Name == "Light Machine Gun"); + var maker = cache.GetAll().First(f => f.ShortName == "Zhestokost"); + cache.Upsert(new FactionProductData { Name = "Sparrow", Design = new CultRecordRef(cache.RefOf(other).Key), Manufacturer = cache.RefOf(maker) }); + cache.FlushAsync().Wait(); + } + var conflicted = File.ReadAllBytes(CatalogPath); + + Assert.Throws(() => ProductLinesCommand.Run(LineApply, _root)); + Assert.Equal(conflicted, File.ReadAllBytes(CatalogPath)); + } + + // Ruling maker-design-tiers-allowed: a maker that already sells a design may sell another product of it. + [Fact] + public void Apply_accepts_a_maker_that_already_sells_the_design() + { + using (var cache = Open()) + { + var laser = cache.GetAll().First(w => w.Name == "Light Laser"); + var lucent = cache.GetAll().First(f => f.ShortName == "Lucent"); + cache.Upsert(new FactionProductData { Name = "Scratch tier", Design = new CultRecordRef(cache.RefOf(laser).Key), Manufacturer = cache.RefOf(lucent) }); + cache.FlushAsync().Wait(); + } + + Assert.Equal(0, ProductLinesCommand.Run(LineApply, _root)); + Assert.Equal(_shipped, Line20()); + using var after = Open(); + Assert.Contains(after.GetAll(), p => p.Name == "Scratch tier"); + } +} diff --git a/tools/AetherDb/AetherDb.csproj b/tools/AetherDb/AetherDb.csproj index f2b488b9b..d0be0585a 100644 --- a/tools/AetherDb/AetherDb.csproj +++ b/tools/AetherDb/AetherDb.csproj @@ -14,5 +14,6 @@ + From 58b78f81de2a6bb95f6d9b25c754181bfa73ea07 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 19:08:08 +0200 Subject: [PATCH 04/23] GameData/Aetheria.cc: apply AetherDb product-lines weapons (twenty products) Output of 'dotnet run --project tools/AetherDb -- product-lines weapons apply' on master's catalog, run in the ygg-verify container; a second apply says nothing to do. Replay the command at merge; do not merge this blob. The catalog's five HullData records were still on the schema before orbital-layering deleted HullData.GridOffset, so no ExpectUnchanged commit could land on it. This blob also carries those five records re-encoded under today's HullData (plain upsert of the five, nothing else changed in bytes), then the twenty products on top. Co-Authored-By: Claude Opus 5.5 --- GameData/Aetheria.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 321476801..eb3dc41f6 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ed0500b462cb0a1dc93377e009c818e3f97c9612fd8f6fd02e17bfd6ea433350 -size 12738595 +oid sha256:a38517ed07f8c058c3ab20241860caec08961e9c6aab803af4c61cd55f364ae0 +size 12744957 From 05b262f1399acb208da8d8510606a8dd4569cc40 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 19:28:00 +0200 Subject: [PATCH 05/23] Product lines: records get keys derived from the product name; two catalog tests stop hard-coding the old seller sets The command's records now land with a key derived from the product name, so a replay at a merge reproduces the same records. CatalogRoleTests.RenamedDesignsKeepTheirKeysAndProducts asserts the renamed products are among a design's sellers, not all of them; RolesBackfillIsAuthored reads maker profiles from Program.MakerProfiles (plus the Pirates) instead of its own copy, which lacked AU. Catalog commit before this one is stale: the next regenerates it. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/CatalogRoleTests.cs | 8 +++----- .../ProductLinesCommandTests.cs | 12 +++++++++++- tools/AetherDb/ProductLinesCommand.cs | 9 ++++++++- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs b/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs index c93b38d57..d7364b15f 100644 --- a/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs +++ b/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs @@ -172,7 +172,8 @@ public sealed class CatalogRoleTests Assert.Equal(generic, design.Name); Assert.Equal(Program.Renames.Single(r => r.Generic == generic).Functional, design.Description); var products = cache.GetAll().Where(p => p.Design.Key.Equals(cache.RefOf(design).Key)).ToArray(); - Assert.Equal(sold.Select(p => p.Key).OrderBy(k => k, StringComparer.Ordinal), products.Select(p => cache.RefOf(p).Key.Value).OrderBy(k => k, StringComparer.Ordinal)); + // Later product lines add sellers to these designs; the renamed designs keep the products they were renamed with. + Assert.All(sold, product => Assert.Contains(products, p => cache.RefOf(p).Key.Value == product.Key)); foreach (var (key, name, maker) in sold) { var product = products.Single(p => cache.RefOf(p).Key.Value == key); @@ -260,10 +261,7 @@ public sealed class CatalogRoleTests AssertNamed(Design("Muon Fusion Station Reactor"), "Modifier", "core"); // Each seller's means come from its maker's profile; the Pirates are middling and inconsistent. - var profiles = new Dictionary - { - ["Zhestokost"] = (.50f, .08f), ["R&D"] = (.60f, .15f), ["DME"] = (.55f, .20f), ["Lightsail"] = (.55f, .10f), ["Pirates"] = (.45f, .22f), - }; + var profiles = new Dictionary(Program.MakerProfiles) { ["Pirates"] = (.45f, .22f) }; foreach (var design in new[] { autocannon, lrm, srm, mine, drive }) { var sellers = cache.GetAll().Where(p => p.Design.Key.Equals(cache.RefOf(design).Key)).ToArray(); diff --git a/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs b/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs index ffa69d59e..4c582062b 100644 --- a/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs @@ -20,6 +20,7 @@ public sealed class ProductLinesCommandTests : IDisposable // The rows as the shipped catalog holds them, taken before they are removed from the scratch copy. private readonly string[] _shipped; + private readonly string[] _shippedKeys; private static string Describe(CultCache cache, FactionProductData product) => $"{cache.Get(product.Design).Name} | {product.Name} | {cache.Get(product.Manufacturer).ShortName} | {product.Description} | " + @@ -36,6 +37,7 @@ public sealed class ProductLinesCommandTests : IDisposable var products = cache.GetAll().Where(p => Names.Contains(p.Name)).ToArray(); Assert.Equal(Names.Length, products.Length); _shipped = products.Select(p => Describe(cache, p)).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + _shippedKeys = products.Select(p => cache.RefOf(p).Key.Value).OrderBy(x => x, StringComparer.Ordinal).ToArray(); foreach (var product in products) Assert.True(cache.Remove(cache.RefOf(product).Key)); cache.FlushAsync().Wait(); } @@ -55,6 +57,12 @@ public sealed class ProductLinesCommandTests : IDisposable return cache.GetAll().Where(p => Names.Contains(p.Name)).Select(p => Describe(cache, p)).OrderBy(x => x, StringComparer.Ordinal).ToArray(); } + private string[] Line20Keys() + { + using var cache = Open(); + return cache.GetAll().Where(p => Names.Contains(p.Name)).Select(p => cache.RefOf(p).Key.Value).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + } + [Fact] public void A_dry_run_writes_nothing_and_apply_lands_the_shipped_rows_once() { @@ -68,8 +76,10 @@ public sealed class ProductLinesCommandTests : IDisposable Assert.Equal(0, ProductLinesCommand.Run(LineApply, _root)); var landed = File.ReadAllBytes(CatalogPath); Assert.NotEqual(before, landed); - // Record keys are CultCache's to mint and differ run to run; the rows do not. + // The command derives each record's key from the product's name, so applying the table again lands the keys the + // shipped catalog holds. Assert.Equal(_shipped, Line20()); + Assert.Equal(_shippedKeys, Line20Keys()); // A second apply finds everything authored and writes nothing; so does a dry run. Assert.Equal(0, ProductLinesCommand.Run(LineApply, _root)); diff --git a/tools/AetherDb/ProductLinesCommand.cs b/tools/AetherDb/ProductLinesCommand.cs index aa17159d6..89e44f26a 100644 --- a/tools/AetherDb/ProductLinesCommand.cs +++ b/tools/AetherDb/ProductLinesCommand.cs @@ -5,6 +5,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Security.Cryptography; +using System.Text; using GameCult.Caching; // product-lines [apply] (aetheria-release, cut product-lines-weapons; rulings catalog-grows-generic-designs-branded-products, @@ -48,6 +50,11 @@ public static class ProductLinesCommand // A line's rows, for the catalog tests that check the shipped records against the table the command authors from. internal static IReadOnlyList Table(string line) => Lines[line]; + // A product's record key is derived from its name, so the same table lands the same records every time it is applied: the + // replay at a merge reproduces the branch's blob, and nothing that orders products by key reads a different order after it. + private static CultRecordKey KeyOf(Row row) => + new(new Guid(SHA256.HashData(Encoding.UTF8.GetBytes("product-line:" + row.Name)).AsSpan(0, 16)).ToString("N")); + public static int Run(string[] args, string root = null) { var apply = args.Contains("apply"); @@ -120,7 +127,7 @@ public static class ProductLinesCommand Design = db.Cache.RefOf(design), Manufacturer = db.Cache.RefOf(maker), Roles = design.Roles.Select(r => new ProductRole { Role = r.Name, Mean = mean, StandardDeviation = dev }).ToList(), - }); + }, KeyOf(row)); } })) throw new InvalidOperationException("The catalog changed while the product line was being authored; nothing was written. Run it again."); From 1dd362750064ceb21ccee2211b6c7a961384f66a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 19:29:13 +0200 Subject: [PATCH 06/23] GameData/Aetheria.cc: apply AetherDb product-lines weapons again, with name-derived record keys Regenerated from master's catalog (cee8cd6e) in the ygg-verify container; a second apply says nothing to do. Replay the command at merge; do not merge this blob. The five HullData records still on the schema before orbital-layering deleted GridOffset were re-encoded first (no ExpectUnchanged commit lands on a catalog with a stale record schema), so this blob carries those five hulls re-encoded plus the twenty products. Co-Authored-By: Claude Opus 5.5 --- GameData/Aetheria.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index eb3dc41f6..acb8dc161 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a38517ed07f8c058c3ab20241860caec08961e9c6aab803af4c61cd55f364ae0 +oid sha256:c7495c5e028772f78a9fd15f9ccd43644e566c9a30bd29c9a8f40804ab4d6e4a size 12744957 From e5962d960d5a7d21f3f4221deae07802b3ce95e3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 19:30:55 +0200 Subject: [PATCH 07/23] Crossing: the Lightning Projector's settled Range follows the product a design's first key gives The staged fit names designs, and the first available product of the design by key builds it. With the Static Shock line in the catalog that product is NiteLife's, whose settled Range is 948.8 m where Lucent's DragOnBreath gave 1216.4; the waiting Djinni sits at 80% of it. Compiles; CrossingConditions passes with the catalog commit before this one. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Scenarios/Crossing.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/Scenarios/Crossing.cs b/Assets/Scripts/ServerShared/Scenarios/Crossing.cs index f0e91be00..5ab8c3739 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Crossing.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Crossing.cs @@ -22,7 +22,7 @@ public sealed class Crossing : Scenario // The waiting Djinni sits inside the Lightning Projector's arc at 80% of its Range. public static readonly CultMath.float2 Origin = float2(724, 124); public static readonly CultMath.float2 CrosserAt = Origin + float2(-128, 45), CrosserFacing = float2(1, 0), CrosserVelocity = float2(50, 0); - public const float ProjectorRange = 1216f; // the Lightning Projector's Range in metres, 1216.4 as the fit stands settled at rest + public const float ProjectorRange = 948f; // the Lightning Projector's Range in metres, 948.8 as the fit stands settled at rest (the first product of the design by key, NiteLife's Static Shock) public static readonly CultMath.float2 Waiting = Origin + float2(0, ProjectorRange * .8f); public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(DemoTerminus.Cast); From c23390c76e68a7634aa0e94448dc8b05bedfae54 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 20:01:52 +0200 Subject: [PATCH 08/23] CatalogRoleTests: the mine launcher's renamed brand is found by name among its sellers Tripwire is a second seller of the Mine Launcher; the test asserted the design had a single product. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/CatalogRoleTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs b/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs index d7364b15f..0a452491f 100644 --- a/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs +++ b/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs @@ -191,7 +191,7 @@ public sealed class CatalogRoleTests var mine = Assert.Single(cache.GetAll(), d => cache.RefOf(d).Key.Value == "1b4a2bb358eb432eb1cfc0ccf3317303"); Assert.Equal("Mine Launcher", mine.Name); Assert.Equal("Lays a drifting proximity mine that arms after two seconds.", mine.Description); - var brand = Assert.Single(cache.GetAll(), p => p.Design.Key.Equals(cache.RefOf(mine).Key)); + var brand = Assert.Single(cache.GetAll(), p => p.Design.Key.Equals(cache.RefOf(mine).Key) && p.Name == "parting gift"); Assert.Equal("parting gift", brand.Name); Assert.Equal("DME", cache.Get(brand.Manufacturer).ShortName); } From ceb1a08edb3be731186d452197d9735bd40d40c0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 20:50:47 +0200 Subject: [PATCH 09/23] product-lines apply lands on a catalog with older-schema records; refusal axes pinned; one maker-profile table ExpectUnchanged compares the file's schema ids with the loaded ones, so master's blob (hulls written before HullData lost GridOffset) made apply throw and write nothing. The batch now expects only that the keys it authors are absent and lands onto the file as it is. Tests: older-schema replay, the design and maker match, case-insensitive name conflict, a row name that is a design's, the dispatch word, and the sold-weapon floor replaced by "only Smoke designs are unsold". LaunchSuppressorCatalogTests reads Program.MakerProfiles instead of spelling six profiles. Not built or run yet; the catalog blob is regenerated in the next commit. Co-Authored-By: Claude Opus 5.5 --- .../LaunchSuppressorCatalogTests.cs | 7 +- .../ProductLinesCatalogTests.cs | 13 ++- .../ProductLinesCommandTests.cs | 83 +++++++++++++++++++ tools/AetherDb/ProductLinesCommand.cs | 8 +- 4 files changed, 98 insertions(+), 13 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs b/tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs index 2f5a2a99b..096845380 100644 --- a/tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs @@ -67,11 +67,6 @@ public sealed class LaunchSuppressorCatalogTests } // Exactly these six products and these makers, each authoring both roles at its maker's own mean and deviation. - var profiles = new Dictionary - { - ["NiteLife"] = (.55f, .12f), ["Finch"] = (.65f, .20f), ["DME"] = (.55f, .20f), ["R&D"] = (.60f, .15f), - ["Lightsail"] = (.55f, .10f), ["Alakrita"] = (.60f, .14f), - }; foreach (var (name, makers) in new[] { (StrongName, new[] { "DME", "Finch", "NiteLife", "R&D" }), @@ -84,7 +79,7 @@ public sealed class LaunchSuppressorCatalogTests Assert.Equal(products.Length, products.Select(p => p.Name).Distinct().Count()); Assert.All(products, product => { - var (mean, dev) = profiles[cache.Get(product.Manufacturer).ShortName]; + var (mean, dev) = Program.MakerProfiles[cache.Get(product.Manufacturer).ShortName]; Assert.Equal(new[] { "baffles", "heat sink" }, product.Roles.Select(r => r.Role).OrderBy(r => r, StringComparer.Ordinal).ToArray()); Assert.All(product.Roles, r => { diff --git a/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs index a4cfa7ac0..756e22355 100644 --- a/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using GameCult.Caching; using Xunit; @@ -16,16 +17,20 @@ public sealed class ProductLinesCatalogTests { using var cache = RestoredHullsTests.OpenCatalog(); var products = cache.GetAll().ToArray(); - var sold = 0; + var unsold = new List(); foreach (var design in cache.GetAll()) { var key = cache.RefOf(design).Key; var makers = products.Where(p => p.Design.Key.Equals(key)).Select(p => p.Manufacturer.Key).Distinct().Count(); - if (makers == 0) continue; - sold++; + if (makers == 0) + { + unsold.Add(design.Name); + continue; + } Assert.True(makers >= 2, $"the weapon design \"{design.Name}\" is sold by {makers} maker"); } - Assert.True(sold >= 20, "the catalog should sell many weapon designs"); + // The designs no product sells are the Smoke test designs and no others: a design that lost all its products is a failure. + Assert.All(unsold, name => Assert.StartsWith("Smoke ", name)); } [Fact] diff --git a/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs b/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs index 4c582062b..9aa136e16 100644 --- a/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs @@ -176,4 +176,87 @@ public sealed class ProductLinesCommandTests : IDisposable using var after = Open(); Assert.Contains(after.GetAll(), p => p.Name == "Scratch tier"); } + + // Master's committed catalog holds records written on a schema older than today's (HullData lost a member after the last + // catalog write). The scratch copy gets that shape: the hull records point at an earlier schema id the loader resolves to + // the current type. + private string MakeHullsStale() + { + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(CatalogPath)); + var current = snapshot.SchemaCatalog.Single(e => e.SchemaName == "aetheria.hulldata"); + var stale = new CultSchemaCatalogEntry + { + SchemaId = "sha256:earlier-hulldata", SchemaName = current.SchemaName, SchemaVersion = current.SchemaVersion, + ContentHash = "sha256:earlier-hulldata", CanonicalSchemaJson = current.CanonicalSchemaJson, Members = current.Members, + }; + snapshot.SchemaCatalog = snapshot.SchemaCatalog.Append(stale).ToArray(); + var hulls = snapshot.Records.Where(r => r.SchemaId == current.SchemaId).ToArray(); + Assert.NotEmpty(hulls); + foreach (var hull in hulls) hull.SchemaId = stale.SchemaId; + File.WriteAllBytes(CatalogPath, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + return stale.SchemaId; + } + + [Fact] + public void Apply_lands_on_a_catalog_that_holds_records_of_an_older_schema_and_replays_to_the_same_bytes() + { + var stale = MakeHullsStale(); + var before = Records(); + + Assert.Equal(0, ProductLinesCommand.Run(LineApply, _root)); + var landed = File.ReadAllBytes(CatalogPath); + Assert.Equal(_shipped, Line20()); + var after = Records(); + foreach (var (key, record) in before) + Assert.True(record.Payload.AsSpan().SequenceEqual(after[key].Payload) && record.SchemaId == after[key].SchemaId, "a record the command did not author changed"); + Assert.Contains(after.Values, r => r.SchemaId == stale); + + Assert.Equal(0, ProductLinesCommand.Run(LineApply, _root)); + Assert.Equal(landed, File.ReadAllBytes(CatalogPath)); + } + + private void AddProduct(string name, string design, string maker) + { + using var cache = Open(); + var designRecord = cache.GetAll().First(w => w.Name == design); + var makerRecord = cache.GetAll().First(f => f.ShortName == maker); + cache.Upsert(new FactionProductData { Name = name, Design = new CultRecordRef(cache.RefOf(designRecord).Key), Manufacturer = cache.RefOf(makerRecord) }); + cache.FlushAsync().Wait(); + } + + // Sparrow is the Light Laser by Finch. A product of that name is the row only when design and maker both match. + [Theory] + [InlineData("Sparrow", "Light Machine Gun", "Finch")] + [InlineData("Sparrow", "Light Laser", "Zhestokost")] + [InlineData("sparrow", "Light Laser", "Finch")] + public void Apply_refuses_a_name_unless_design_and_maker_both_match_it_ignoring_case(string name, string design, string maker) + { + AddProduct(name, design, maker); + var conflicted = File.ReadAllBytes(CatalogPath); + + Assert.Throws(() => ProductLinesCommand.Run(LineApply, _root)); + Assert.Equal(conflicted, File.ReadAllBytes(CatalogPath)); + } + + [Fact] + public void Apply_refuses_a_row_name_that_is_a_designs_and_writes_nothing() + { + using (var cache = Open()) + { + cache.Upsert(new WeaponItemData { Name = "Tripwire" }); + cache.FlushAsync().Wait(); + } + var conflicted = File.ReadAllBytes(CatalogPath); + + Assert.Throws(() => ProductLinesCommand.Run(LineApply, _root)); + Assert.Equal(conflicted, File.ReadAllBytes(CatalogPath)); + } + + // The command is reached as AetherDb's product-lines word, with the line after it; the shipped catalog holds the line. + [Fact] + public void The_product_lines_word_runs_the_named_line_and_refuses_an_unknown_one() + { + Assert.Equal(0, Program.Main(new[] { "product-lines", "weapons" })); + Assert.Equal(1, Program.Main(new[] { "product-lines", "nonesuch" })); + } } diff --git a/tools/AetherDb/ProductLinesCommand.cs b/tools/AetherDb/ProductLinesCommand.cs index 89e44f26a..d0fd7ce41 100644 --- a/tools/AetherDb/ProductLinesCommand.cs +++ b/tools/AetherDb/ProductLinesCommand.cs @@ -112,11 +112,13 @@ public static class ProductLinesCommand return 0; } - // ExpectUnchanged lands the batch onto the file as it is: every other record keeps the bytes it has, where a plain - // commit would re-encode the whole catalog through today's types. + // The batch lands onto the file as it is: every other record keeps the bytes it has, where a plain commit would + // re-encode the whole catalog through today's types. What it expects is that each key it authors is still absent, the + // one condition the command's own validation rests on. ExpectUnchanged would compare the file's schema ids with the + // loaded ones and refuse any catalog that holds a record written on an older schema, whatever the batch is. if (!db.Cache.Commit(batch => { - batch.ExpectUnchanged(); + foreach (var (row, _, _, _) in missing) batch.Expect(KeyOf(row), null); foreach (var (row, design, maker, _) in missing) { var (mean, dev) = Program.MakerProfiles[row.Maker]; From ae290654aacb67633f8696344082ad0468d8bdd5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 20:54:41 +0200 Subject: [PATCH 10/23] GameData/Aetheria.cc: product-lines weapons apply onto master's catalog (df154ffb), replayed Master's blob (oid ed0500b4) with the twenty products and nothing else: applied once, then again with no change (second apply: nothing to do, bytes identical). Replaces the blob whose five hulls a probe had re-encoded. Co-Authored-By: Claude Opus 5.5 --- GameData/Aetheria.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index acb8dc161..7cc7c9d58 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c7495c5e028772f78a9fd15f9ccd43644e566c9a30bd29c9a8f40804ab4d6e4a -size 12744957 +oid sha256:ba78312744521762069315f8b8c491f7619f0410249abcfa90291a11b9e29d7b +size 12745154 From 40e2ada37eda9cb37cda8bbffcac88da3e57cf6d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 22:10:03 +0200 Subject: [PATCH 11/23] Loadout: slots and hull may name a product; one Resolve; Capture records the brand; ProductsInKeyOrder deleted Does not build alone: Materialize's callers move in later commits. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Loadout.cs | 91 ++++++++++++++++---------- 1 file changed, 55 insertions(+), 36 deletions(-) diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index 0e60a544e..7effbfe76 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -6,9 +6,10 @@ using GameCult.Caching; using MessagePack; using CultMath; -// A ship preset (a variant): item designs only, the hull's design, and per hull cell a design and its rotation. A design -// names no manufacturer; which product builds a slot is a fact of the galaxy the preset is materialized in, which keeps -// a preset portable across galaxies. Authored catalog data: written in CultCache Studio or by the editor capture +// A ship preset (a variant): the hull's design, and per hull cell a design and its rotation. A slot names a design and +// may name a product of it; a design-only slot is open on purpose, and whoever materializes the preset chooses its +// product from the galaxy it is built in (ruling loadout-product-optional-context-chooses), which keeps a preset +// portable across galaxies. A preset that needs a brand names it. Authored catalog data: written in CultCache Studio or by the editor capture // command, read at runtime. [CultDocument("aetheria.loadout", "1"), MessagePackObject] public class Loadout @@ -17,6 +18,7 @@ public class Loadout [Key(1)] public CultRecordRef Hull; [Key(2)] public List Slots = new List(); [Key(3)] public int[][] WeaponGroups; // indices into Slots + [Key(4)] public CultRecordRef HullProduct; // unset: the hull's design alone, chosen at materialization } [MessagePackObject] @@ -25,14 +27,15 @@ public class LoadoutSlot [Key(0)] public int2 Position; // hull cell passed to Entity.TryEquip(item, int2) [Key(1)] public ItemRotation Rotation; [Key(2)] public CultRecordRef Design; + [Key(3)] public CultRecordRef Product; // unset: design-only, chosen at materialization } // The owner of loadouts: capture from a live ship, the only writer, and the only builder of an entity from a loadout. public static class Loadouts { // Records the hull design and one slot per equipped item (gear, then cargo bays, then docking bays) with its cell, - // rotation and design. Entity.Equipment also holds the hull's own unit; that is Hull, not a slot. Nothing about the - // units themselves: no product, quality, ingredients, cargo or state. + // rotation, design and product (unset for an unbranded unit). Entity.Equipment also holds the hull's own unit; that is + // Hull, not a slot. Nothing else about the units: no quality, ingredients, cargo or state. public static Loadout Capture(ItemManager itemManager, Entity entity, string name) { var cache = itemManager.ItemData; @@ -46,11 +49,13 @@ public static class Loadouts { Name = name, Hull = cache.RefOf((HullData) itemManager.GetData(entity.Hull)), + HullProduct = ProductRef(itemManager, entity.Hull), Slots = items.Select(item => new LoadoutSlot { Position = item.Position, Rotation = item.EquippableItem.Rotation, - Design = cache.RefOf(itemManager.GetData(item.EquippableItem)) + Design = cache.RefOf(itemManager.GetData(item.EquippableItem)), + Product = ProductRef(itemManager, item.EquippableItem) }).ToList(), WeaponGroups = entity.WeaponGroups .Select(group => group.items.Select(item => items.IndexOf(item)).ToArray()) @@ -58,6 +63,9 @@ public static class Loadouts }; } + private static CultRecordRef ProductRef(ItemManager itemManager, CraftedItemInstance unit) => + itemManager.Brand(unit).Product is { } product ? itemManager.ItemData.RefOf(product) : default; + // A preset's key derives from its name, so capturing the same name again addresses the same record. public static CultRecordKey KeyOf(string name) => new CultRecordKey("loadout:" + name); @@ -95,24 +103,23 @@ public static class Loadouts // All-or-nothing: returns an entity only when every design resolved and fitted, the loadout has at most // WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure is listed; on any failure // nothing is returned and the zone and cache are unchanged, but a failed fit has already drawn from - // itemManager.Random. How each design is built is Resolve's rule. A game spawner is to pass - // LoadoutGenerator.IsAvailable as isAvailable, with no fallback to any manufacturer; RunStart does. The hull's type + // itemManager.Random. How each design is built is Resolve's rule. A design-only slot is chosen by choose; a game + // spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does. The hull's type // picks the entity: a ship hull builds a Ship, a turret hull an OrbitalEntity with no orbit, which stays where it is // put; a station hull is refused. The entity always gets exactly WeaponGroupCount groups; a loadout with fewer is // padded with empty groups. A failed materialization leaves any minted lots in the live ledger; they are not roots, // so the next commit drops them. public static Entity Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout, - Predicate isAvailable, List failures) + Func choose, List failures) { var cache = itemManager.ItemData; - var products = ProductsInKeyOrder(cache); var reported = failures.Count; - var hullBuild = Resolve(itemManager, products, loadout.Hull, isAvailable, "hull", failures); + var hullBuild = Resolve(itemManager, loadout.Hull, loadout.HullProduct, choose, "hull", failures); var hullData = cache.Get(loadout.Hull); if (hullData is { HullType: HullType.Station }) failures.Add($"hull: {hullData.Name} is a station hull; a preset builds a ship or a turret"); - var slotBuilds = loadout.Slots.Select(slot => Resolve(itemManager, products, slot.Design, isAvailable, Cell(slot), failures)).ToArray(); + var slotBuilds = loadout.Slots.Select(slot => Resolve(itemManager, slot.Design, slot.Product, choose, Cell(slot), failures)).ToArray(); var groups = loadout.WeaponGroups ?? new int[0][]; var groupCount = itemManager.GameplaySettings.WeaponGroupCount; if (groups.Length > groupCount) failures.Add($"weapon groups: {groups.Length} groups, at most {groupCount}"); @@ -124,7 +131,7 @@ public static class Loadouts } if (failures.Count > reported) return null; - var hull = hullBuild(); + var hull = (EquippableItem) hullBuild(); var settings = itemManager.GameplaySettings.DefaultEntitySettings; Entity entity = hullData.HullType == HullType.Turret ? new OrbitalEntity(itemManager, liveZone, hull, default, settings) @@ -133,7 +140,7 @@ public static class Loadouts for (var i = 0; i < loadout.Slots.Count; i++) { var slot = loadout.Slots[i]; - var unit = slotBuilds[i](); + var unit = (EquippableItem) slotBuilds[i](); unit.Rotation = slot.Rotation; if (!entity.TryEquip(unit, slot.Position)) { @@ -159,39 +166,51 @@ public static class Loadouts } // How one design is built, or null with the failure listed; nothing is minted until the returned builder runs. A - // design is built by its first available product in record-key order. A design with no product at all is outside - // the economy (docs/scenarios-cut.md, Q3: generation, stock and sale all go through products) and is built - // unbranded, with no maker, at UnbrandedQuality. A design whose products are all unavailable is not built. - public static Func Resolve(ItemManager itemManager, CultRecordRef design, - Predicate isAvailable, string where, List failures) where T : EquippableItemData => - Resolve(itemManager, ProductsInKeyOrder(itemManager.ItemData), design, isAvailable, where, failures); - - private static Func Resolve(ItemManager itemManager, FactionProductData[] products, - CultRecordRef design, Predicate isAvailable, string where, List failures) - where T : EquippableItemData + // pinned product is built as named, whatever choose says; it must be a product of the design. A design with no + // product at all is outside the economy (docs/scenarios-cut.md, Q3: generation, stock and sale all go through + // products) and is built unbranded, with no maker, at UnbrandedQuality. Otherwise choose names the product, and + // null (nothing available) or a product of another design is a failure. + public static Func Resolve(ItemManager itemManager, CultRecordRef design, + CultRecordRef product, Func choose, string where, + List failures) where T : CraftedItemData { - var data = itemManager.ItemData.Get(design); + var cache = itemManager.ItemData; + var data = cache.Get(design); if (data == null) { failures.Add($"{where}: design {design.Key} is not in the catalog"); return null; } - var made = products.Where(p => p.Design.Key.Equals(design.Key)).ToArray(); - if (made.Length == 0) - return () => (EquippableItem) itemManager.CreateInstance(itemManager.CreateLot(data, default, UnbrandedQuality)); - - var product = made.FirstOrDefault(p => isAvailable(p)); - if (product == null) + FactionProductData made; + if (product.IsSet()) { - failures.Add($"{where}: no available product of {data.Name}"); + made = cache.Get(product); + if (made == null) + { + failures.Add($"{where}: product {product.Key} is not in the catalog"); + return null; + } + } + else + { + if (!cache.GetAll().Any(p => p.Design.Key.Equals(design.Key))) + return () => itemManager.CreateInstance(itemManager.CreateLot(data, default, UnbrandedQuality)); + made = choose(data); + if (made == null) + { + failures.Add($"{where}: no available product of {data.Name}"); + return null; + } + } + + if (!made.Design.Key.Equals(design.Key)) + { + failures.Add($"{where}: product {made.Name} is not a product of {data.Name}"); return null; } - return () => (EquippableItem) itemManager.CreateInstance(product); + return () => itemManager.CreateInstance(made); } - internal static FactionProductData[] ProductsInKeyOrder(CultCache cache) => - cache.GetAll().OrderBy(p => cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray(); - private static string Cell(LoadoutSlot slot) => $"slot {slot.Position.x},{slot.Position.y}"; } From 3a081160b4065a833cd9b504cd1e9c3c8fa537d5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 22:10:13 +0200 Subject: [PATCH 12/23] LoadoutGenerator: ChooseProduct (weighted seeded draw, name-ordered) and ProductWeight; generation shares the weight Builds with commit 1 only once the callers move (commit 3). Co-Authored-By: Claude Opus 5.5 --- .../Scripts/ServerShared/LoadoutGenerator.cs | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 46bd5bc33..55eea27f8 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -177,7 +177,7 @@ public class LoadoutGenerator { var (available, preferManufacturers) = AvailableProducts(filter, required); return available.WeightedRandomElements(ref Random, entry => - (preferManufacturers ? ManufacturerPreference(entry.product.Manufacturer) : 1) * + (preferManufacturers ? ProductWeight(entry.product) : 1) * pow(entry.design.Shape.Coordinates.Length, sizeExponent) / // Prioritize larger items pow(entry.design.Price, PriceExponent), // Penalize item price to a controllable degree count); @@ -243,8 +243,8 @@ public class LoadoutGenerator // No galaxy, or no faction, means no allegiance to filter by: every product is on offer. A fixture generates // loadouts that way, so a test can exercise placement and products without standing up a whole galaxy; a scenario - // stage has no faction and reaches every maker. Loadouts.Materialize takes availability as a predicate; RunStart, - // the game's one preset spawner, passes this, so presets and generation share one availability rule. A faction + // stage has no faction and reaches every maker. Loadouts.Materialize takes a chooser and ChooseProduct is the game's, + // so presets and generation share this one availability rule. A faction // always reaches its own manufacturer's gear, and otherwise gear made by a manufacturer its allegiance names // (operator, 2026-09-30: allegiance lists only other factions). Whether that manufacturer is present in the galaxy // does not matter (operator, 2026-10-06: a faction need not be present for its gear to be available). @@ -252,6 +252,25 @@ 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); + + // 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 + // enumeration, and so its result, never depends on record keys. All of the policy is this body. + public FactionProductData ChooseProduct(CraftedItemData design) + { + var key = ItemManager.ItemData.RefOf(design).Key; + var candidates = ItemManager.ItemData.GetAll() + .Where(product => product.Design.Key.Equals(key) && IsAvailable(product)) + .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]; + } + // Prioritize products from the zone faction and its allies, penalizing distance to the manufacturer's headquarters. // A manufacturer with no home zone in this galaxy weighs as if its headquarters lay one jump beyond the farthest // zone this zone reaches: reachable through allegiance, least preferred. From 7bbaa26675e5d0f5c09ec61619ba5c8c1c7aa018 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 22:10:58 +0200 Subject: [PATCH 13/23] ScenarioStage: names are designs or products, one Resolve path with the generator's chooser; rigs pin today's brands; Crossing back to 1216 Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ScenarioStage.cs | 70 ++++++++++--------- .../Scenarios/AiFusedDiscipline.cs | 4 +- Assets/Scripts/ServerShared/Scenarios/Arcs.cs | 24 +++---- Assets/Scripts/ServerShared/Scenarios/Belt.cs | 12 ++-- .../ServerShared/Scenarios/Crossing.cs | 14 ++-- Assets/Scripts/ServerShared/Scenarios/Duel.cs | 16 ++--- .../ServerShared/Scenarios/FusedRounds.cs | 6 +- .../ServerShared/Scenarios/LauncherAngles.cs | 12 ++-- .../Scripts/ServerShared/Scenarios/LockOn.cs | 14 ++-- .../Scripts/ServerShared/Scenarios/Pursuit.cs | 30 ++++---- .../ServerShared/Scenarios/RefusedRounds.cs | 6 +- .../ServerShared/Scenarios/StarvedReactor.cs | 8 +-- .../ServerShared/Scenarios/WeaponFeel.cs | 10 +-- 13 files changed, 114 insertions(+), 112 deletions(-) diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs index 159504292..b32a4993c 100644 --- a/Assets/Scripts/ServerShared/ScenarioStage.cs +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -9,11 +9,11 @@ using float3 = CultMath.float3; // The run half of a scenario's vocabulary (docs/scenarios-cut.md, R.4). A scenario's Stage builds its fits and places // them here; nothing joins the arena until Stage returns with no failure, and then RunStart admits it all at once. // Every verb names what it could not do instead of throwing, so one bad name in a script lists every bad name. -// Designs and hulls are named as the catalog names them. Only RunStart builds a stage. +// Designs, hulls and products are named as the catalog names them. Only RunStart builds a stage. public sealed class ScenarioStage { private readonly ItemManager _items; - private readonly Predicate _available; + private readonly Func _choose; private readonly List _failures = new List(); private readonly List _placed = new List(); private int _placements; @@ -25,8 +25,9 @@ public sealed class ScenarioStage StartingHull = startingHull; TutorialGenerationSettings = tutorialGenerationSettings; Credits = items.GameplaySettings.StartingCredits; - // A fit names designs, not manufacturers, so what the galaxy offers decides which product builds each one. - _available = new LoadoutGenerator(ref items.Random, items, arena.Galaxy, arena.GalaxyZone, null, 2).IsAvailable; + // A design named without a product is chosen by the generator's ChooseProduct. The stage outfits with no faction, + // so every maker is reachable and the run's seed draws. + _choose = new LoadoutGenerator(ref items.Random, items, arena.Galaxy, arena.GalaxyZone, null, 2).ChooseProduct; } public Galaxy Galaxy => Arena.Galaxy; @@ -49,18 +50,24 @@ public sealed class ScenarioStage public bool Piloted; } - // A hull fitted slot by slot: each slot a design, the hull cell it goes in, and its rotation. + // A hull fitted slot by slot: each slot a design or a product, the hull cell it goes in, and its rotation. A name is + // a design (open: the stage chooses its product) or a product (pinned: that brand). public ScenarioFit Fit(string hull, params (string design, int2 cell, ItemRotation rotation)[] slots) { var where = $"fit {hull}"; - var hullData = Named(hull, where); + var (hullData, hullProduct) = NamedOrProduct(hull, where); var loadout = new Loadout { Name = hull, WeaponGroups = new int[0][] }; if (hullData != null) loadout.Hull = _items.ItemData.RefOf(hullData); + if (hullProduct != null) loadout.HullProduct = _items.ItemData.RefOf(hullProduct); foreach (var (design, cell, rotation) in slots) { - var data = Named(design, where); + var (data, product) = NamedOrProduct(design, where); if (data == null) continue; - loadout.Slots.Add(new LoadoutSlot { Design = _items.ItemData.RefOf(data), Position = cell, Rotation = rotation }); + loadout.Slots.Add(new LoadoutSlot + { + Design = _items.ItemData.RefOf(data), Position = cell, Rotation = rotation, + Product = product == null ? default : _items.ItemData.RefOf(product) + }); } return hullData != null && loadout.Slots.Count == slots.Length ? new ScenarioFit(loadout) : null; } @@ -119,38 +126,22 @@ public sealed class ScenarioStage return entity; } - // Items in the entity's first cargo bay, one per design named. A name is an equippable design (built as a fit - // builds it) or a consumable design (built from its first available product in key order, as a generated hold is - // stocked); a name that is neither is a failure. - public void Cargo(Entity entity, params string[] designs) + // Items in the entity's first cargo bay, one per name. A name is an equippable or consumable design (open: the stage + // chooses its product) or a product of one (pinned); a name that is neither is a failure. + public void Cargo(Entity entity, params string[] names) { if (entity == null) return; var where = $"cargo of {entity.Name}"; var bay = entity.CargoBays.FirstOrDefault(); - foreach (var design in designs) + foreach (var name in names) { - var data = Named(design, where, found => found is EquippableItemData || found is ConsumableItemData); + var (data, product) = NamedOrProduct(name, where, found => found is EquippableItemData || found is ConsumableItemData); if (data == null) continue; - Func build; - if (data is ConsumableItemData) - { - var key = _items.ItemData.RefOf(data).Key; - var product = Loadouts.ProductsInKeyOrder(_items.ItemData).Where(p => p.Design.Key.Equals(key)).FirstOrDefault(p => _available(p)); - if (product == null) - { - _failures.Add($"{where}: no product offers {design}"); - continue; - } - build = () => _items.CreateInstance(product); - } - else - { - var equippable = Loadouts.Resolve(_items, _items.ItemData.RefOf((EquippableItemData) data), _available, where, _failures); - if (equippable == null) continue; - build = equippable; - } + var build = Loadouts.Resolve(_items, _items.ItemData.RefOf(data), + product == null ? default : _items.ItemData.RefOf(product), _choose, where, _failures); + if (build == null) continue; if (bay == null || !bay.TryStore(build())) - _failures.Add($"{where}: no room for {design}"); + _failures.Add($"{where}: no room for {name}"); } } @@ -163,7 +154,7 @@ public sealed class ScenarioStage else { var failures = new List(); - entity = Loadouts.Materialize(_items, Arena, fit.Loadout, _available, failures); + entity = Loadouts.Materialize(_items, Arena, fit.Loadout, _choose, failures); _failures.AddRange(failures.Select(failure => $"{where} ({fit.Loadout.Name}): {failure}")); if (entity == null) return null; } @@ -173,6 +164,17 @@ public sealed class ScenarioStage return entity; } + // A name is a design, or else exactly one product (names are unique) whose design is a T passing among: the design + // and that product. Neither is Named's failure. + private (T design, FactionProductData product) NamedOrProduct(string name, string where, Func among = null) where T : CraftedItemData + { + var cache = _items.ItemData; + var products = cache.GetAll().Where(p => p.Name == name && cache.Get(p.Design) is T design && (among == null || among(design))).Take(2).ToArray(); + if (products.Length == 1 && !cache.GetAll().Any(data => data.Name == name && (among == null || among(data)))) + return ((T) cache.Get(products[0].Design), products[0]); + return (Named(name, where, among), null); + } + private T Named(string name, string where, Func among = null) where T : CraftedItemData { var named = _items.ItemData.GetAll().Where(data => data.Name == name && (among == null || among(data))).Take(2).ToArray(); diff --git a/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs b/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs index eb8ecab18..df33f5640 100644 --- a/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs +++ b/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs @@ -26,8 +26,8 @@ public sealed class AiFusedDiscipline : Scenario (weapon, int2(1, 8), None), ("Radiator", int2(2, 2), CounterClockwise), ("Radiator", int2(3, 2), Clockwise), - ("Compact Sensor", int2(3, 10), None), - ("Fire Control Array", int2(1, 6), Clockwise)); + ("not if i see you first", int2(3, 10), None), // Compact Sensor + ("Panopticon Prime", int2(1, 6), Clockwise)); // Fire Control Array stage.Player(stage.Generated("Djinni"), float2(0, 0)); stage.Place(Armed("Smoke Refused"), Hostile, facing: float2(0, -1), stance: ScenarioStance.Hostile, piloted: true); diff --git a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs index da2983267..b91fbc359 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs @@ -30,25 +30,25 @@ public sealed class Arcs : Scenario ("Rugged Drive", int2(4, 14), CounterClockwise), ("Rugged Drive", int2(5, 14), Clockwise), ("Compact Reactor", int2(4, 4), None), - ("Guided Missile Launcher", int2(2, 5), None), - ("Guided Missile Launcher", int2(7, 5), None), - ("Cell Blaster", int2(3, 8), None), - ("Cell Blaster", int2(6, 8), None), + ("GT 3K", int2(2, 5), None), // Guided Missile Launcher + ("GT 3K", int2(7, 5), None), // Guided Missile Launcher + ("FastBlast+-", int2(3, 8), None), // Cell Blaster + ("FastBlast+-", int2(6, 8), None), // Cell Blaster ("Radiator", int2(4, 2), CounterClockwise), ("Radiator", int2(5, 2), Clockwise), - ("Compact Sensor", int2(5, 10), None), - ("Fire Control Array", int2(3, 6), Clockwise), - ("Autocannon", int2(8, 4), Clockwise), - ("Charged Scatter Blaster", int2(0, 4), CounterClockwise)); + ("not if i see you first", int2(5, 10), None), // Compact Sensor + ("Panopticon Prime", int2(3, 6), Clockwise), // Fire Control Array + ("🫳🔫", int2(8, 4), Clockwise), // Autocannon + ("ChargeBlast SG", int2(0, 4), CounterClockwise)); // Charged Scatter Blaster var turret = stage.Fit("Turret", - ("Light Machine Gun", int2(1, 2), None), - ("Light Machine Gun", int2(5, 2), None), + ("ClearPath", int2(1, 2), None), // Light Machine Gun + ("ClearPath", int2(5, 2), None), // Light Machine Gun ("Long Radiator", int2(0, 2), CounterClockwise), ("Long Radiator", int2(7, 2), Clockwise), ("Compact Reactor", int2(3, 3), None), ("Turret Control Module", int2(3, 5), None), - ("Compact Sensor", int2(4, 5), None), - ("Fire Control Array", int2(3, 6), None)); + ("not if i see you first", int2(4, 5), None), // Compact Sensor + ("Panopticon Prime", int2(3, 6), None)); // Fire Control Array stage.Player(fighter, float2(0, 0), facing: float2(0, 1)); stage.Place(stage.Bare("Longinus"), Bow); diff --git a/Assets/Scripts/ServerShared/Scenarios/Belt.cs b/Assets/Scripts/ServerShared/Scenarios/Belt.cs index efab34db7..cf0150ff8 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Belt.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Belt.cs @@ -26,14 +26,14 @@ public sealed class Belt : Scenario ("Rugged Drive", int2(2, 14), CounterClockwise), ("Rugged Drive", int2(3, 14), Clockwise), ("Compact Reactor", int2(2, 4), None), - ("Guided Missile Launcher", int2(0, 5), None), - ("Guided Missile Launcher", int2(5, 5), None), - ("Lightning Projector", int2(1, 8), None), - ("Cell Blaster", int2(4, 8), None), + ("GT 3K", int2(0, 5), None), // Guided Missile Launcher + ("GT 3K", int2(5, 5), None), // Guided Missile Launcher + ("DragOnBreath", int2(1, 8), None), // Lightning Projector + ("FastBlast+-", int2(4, 8), None), // Cell Blaster ("Radiator", int2(2, 2), CounterClockwise), ("Radiator", int2(3, 2), Clockwise), - ("Compact Sensor", int2(3, 10), None), - ("Fire Control Array", int2(1, 6), Clockwise)); + ("not if i see you first", int2(3, 10), None), // Compact Sensor + ("Panopticon Prime", int2(1, 6), Clockwise)); // Fire Control Array // The belt is a ring about its parent's position at the belt orbit's distance. The player stands 100 m outside // it, at the point nearest the origin, facing along the ring. diff --git a/Assets/Scripts/ServerShared/Scenarios/Crossing.cs b/Assets/Scripts/ServerShared/Scenarios/Crossing.cs index 5ab8c3739..c90859439 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Crossing.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Crossing.cs @@ -22,7 +22,7 @@ public sealed class Crossing : Scenario // The waiting Djinni sits inside the Lightning Projector's arc at 80% of its Range. public static readonly CultMath.float2 Origin = float2(724, 124); public static readonly CultMath.float2 CrosserAt = Origin + float2(-128, 45), CrosserFacing = float2(1, 0), CrosserVelocity = float2(50, 0); - public const float ProjectorRange = 948f; // the Lightning Projector's Range in metres, 948.8 as the fit stands settled at rest (the first product of the design by key, NiteLife's Static Shock) + public const float ProjectorRange = 1216f; // the Lightning Projector's Range in metres, 1216.4 as the fit stands settled at rest public static readonly CultMath.float2 Waiting = Origin + float2(0, ProjectorRange * .8f); public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(DemoTerminus.Cast); @@ -36,14 +36,14 @@ public sealed class Crossing : Scenario ("Rugged Drive", int2(2, 14), CounterClockwise), ("Rugged Drive", int2(3, 14), Clockwise), ("Compact Reactor", int2(2, 4), None), - ("Guided Missile Launcher", int2(0, 5), None), - ("Guided Missile Launcher", int2(5, 5), None), - ("Lightning Projector", int2(1, 8), None), - ("Cell Blaster", int2(4, 8), None), + ("GT 3K", int2(0, 5), None), // Guided Missile Launcher + ("GT 3K", int2(5, 5), None), // Guided Missile Launcher + ("DragOnBreath", int2(1, 8), None), // Lightning Projector + ("FastBlast+-", int2(4, 8), None), // Cell Blaster ("Radiator", int2(2, 2), CounterClockwise), ("Radiator", int2(3, 2), Clockwise), - ("Compact Sensor", int2(3, 10), None), - ("Fire Control Array", int2(1, 6), Clockwise)); + ("not if i see you first", int2(3, 10), None), // Compact Sensor + ("Panopticon Prime", int2(1, 6), Clockwise)); // Fire Control Array stage.Player(fighter, Origin, facing: float2(0, 1)); stage.Place(stage.Generated("Djinni"), CrosserAt, facing: CrosserFacing, stance: ScenarioStance.Hostile, velocity: CrosserVelocity); diff --git a/Assets/Scripts/ServerShared/Scenarios/Duel.cs b/Assets/Scripts/ServerShared/Scenarios/Duel.cs index 510d60147..62dff34ae 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Duel.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Duel.cs @@ -20,19 +20,19 @@ public sealed class Duel : Scenario ("Rugged Drive", int2(2, 14), CounterClockwise), ("Rugged Drive", int2(3, 14), Clockwise), ("Compact Reactor", int2(2, 4), None), - ("Guided Missile Launcher", int2(0, 5), None), - ("Guided Missile Launcher", int2(5, 5), None), - ("Cell Blaster", int2(1, 8), None), - ("Cell Blaster", int2(4, 8), None), + ("GT 3K", int2(0, 5), None), // Guided Missile Launcher + ("GT 3K", int2(5, 5), None), // Guided Missile Launcher + ("FastBlast+-", int2(1, 8), None), // Cell Blaster + ("FastBlast+-", int2(4, 8), None), // Cell Blaster ("Radiator", int2(2, 2), CounterClockwise), ("Radiator", int2(3, 2), Clockwise), - ("Compact Sensor", int2(3, 10), None), + ("not if i see you first", int2(3, 10), None), // Compact Sensor ("Capacitor", int2(1, 5), None), - ("Fire Control Array", int2(1, 6), Clockwise), - ("Cargo Bay", int2(2, 8), None)); + ("Panopticon Prime", int2(1, 6), Clockwise), // Fire Control Array + ("Store-All Plus", int2(2, 8), None)); // Cargo Bay stage.Player(fighter, float2(0, 0)); var rival = stage.Place(fighter, float2(0, 900), facing: float2(0, -1), stance: ScenarioStance.Hostile, piloted: true); - stage.Cargo(rival, "Light Laser"); + stage.Cargo(rival, "Spectra"); } } diff --git a/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs b/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs index ca9011bee..db2e18b65 100644 --- a/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs +++ b/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs @@ -35,11 +35,11 @@ public sealed class FusedRounds : Scenario ("Compact Reactor", int2(4, 4), None), ("Smoke Proximity", int2(3, 8), None), ("Smoke Delayed", int2(6, 8), None), - ("Cluster Cannon", int2(5, 17), None), + ("DeathCluster", int2(5, 17), None), // Cluster Cannon ("Radiator", int2(4, 2), CounterClockwise), ("Radiator", int2(5, 2), Clockwise), - ("Compact Sensor", int2(5, 10), None), - ("Fire Control Array", int2(3, 6), Clockwise)); + ("not if i see you first", int2(5, 10), None), // Compact Sensor + ("Panopticon Prime", int2(3, 6), Clockwise)); // Fire Control Array stage.Player(shooter, float2(0, 0), facing: float2(0, 1)); stage.Place(stage.Bare("Longinus"), Mid, stance: ScenarioStance.Hostile); diff --git a/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs b/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs index 7c5fc669c..62258b9cb 100644 --- a/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs +++ b/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs @@ -20,14 +20,14 @@ public sealed class LauncherAngles : Scenario ("Rugged Drive", int2(2, 14), CounterClockwise), ("Rugged Drive", int2(3, 14), Clockwise), ("Compact Reactor", int2(2, 4), None), - ("Guided Missile Launcher", int2(0, 5), None), - ("Swarm Rocket Pod", int2(5, 5), None), - ("Cell Blaster", int2(1, 8), None), - ("Cell Blaster", int2(4, 8), None), + ("GT 3K", int2(0, 5), None), // Guided Missile Launcher + ("Leonid", int2(5, 5), None), // Swarm Rocket Pod + ("FastBlast+-", int2(1, 8), None), // Cell Blaster + ("FastBlast+-", int2(4, 8), None), // Cell Blaster ("Radiator", int2(2, 2), CounterClockwise), ("Radiator", int2(3, 2), Clockwise), - ("Compact Sensor", int2(3, 10), None), - ("Fire Control Array", int2(1, 6), Clockwise)); + ("not if i see you first", int2(3, 10), None), // Compact Sensor + ("Panopticon Prime", int2(1, 6), Clockwise)); // Fire Control Array stage.Player(launcher, float2(0, 0)); stage.Place(stage.Bare("Longinus"), float2(0, 900), facing: float2(1, 0), stance: ScenarioStance.Hostile); diff --git a/Assets/Scripts/ServerShared/Scenarios/LockOn.cs b/Assets/Scripts/ServerShared/Scenarios/LockOn.cs index 87e5de829..b848065e7 100644 --- a/Assets/Scripts/ServerShared/Scenarios/LockOn.cs +++ b/Assets/Scripts/ServerShared/Scenarios/LockOn.cs @@ -29,16 +29,16 @@ public sealed class LockOn : Scenario ("Rugged Drive", int2(2, 14), CounterClockwise), ("Rugged Drive", int2(3, 14), Clockwise), ("Compact Reactor", int2(2, 4), None), - ("Guided Missile Launcher", int2(0, 5), None), - ("Guided Missile Launcher", int2(5, 5), None), - ("Cell Blaster", int2(1, 8), None), - ("Cell Blaster", int2(4, 8), None), + ("GT 3K", int2(0, 5), None), // Guided Missile Launcher + ("GT 3K", int2(5, 5), None), // Guided Missile Launcher + ("FastBlast+-", int2(1, 8), None), // Cell Blaster + ("FastBlast+-", int2(4, 8), None), // Cell Blaster ("Radiator", int2(2, 2), CounterClockwise), ("Radiator", int2(3, 2), Clockwise), - ("Compact Sensor", int2(3, 10), None), + ("not if i see you first", int2(3, 10), None), // Compact Sensor ("Capacitor", int2(1, 5), None), - ("Fire Control Array", int2(1, 6), Clockwise), - ("Cargo Bay", int2(2, 8), None)); + ("Panopticon Prime", int2(1, 6), Clockwise), // Fire Control Array + ("Store-All Plus", int2(2, 8), None)); // Cargo Bay stage.Player(fighter, float2(0, 0), facing: float2(0, 1)); stage.Place(fighter, HostileAt, facing: normalize(-HostileAt), stance: ScenarioStance.Hostile, piloted: true); diff --git a/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs b/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs index 75a803b18..bd067b96d 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs @@ -26,15 +26,15 @@ public sealed class Pursuit : Scenario ("Rugged Drive", int2(2, 14), CounterClockwise), ("Rugged Drive", int2(3, 14), Clockwise), ("Compact Reactor", int2(2, 4), None), - ("Mine Launcher", int2(0, 5), None), - ("Swarm Rocket Pod", int2(5, 5), None), - ("Cell Blaster", int2(1, 8), None), - ("Cell Blaster", int2(4, 8), None), + ("parting gift", int2(0, 5), None), // Mine Launcher + ("Leonid", int2(5, 5), None), // Swarm Rocket Pod + ("FastBlast+-", int2(1, 8), None), // Cell Blaster + ("FastBlast+-", int2(4, 8), None), // Cell Blaster ("Radiator", int2(2, 2), CounterClockwise), ("Radiator", int2(3, 2), Clockwise), - ("Compact Sensor", int2(3, 10), None), - ("Fire Control Array", int2(1, 6), Clockwise), - ("Cargo Bay", int2(2, 8), None)); + ("not if i see you first", int2(3, 10), None), // Compact Sensor + ("Panopticon Prime", int2(1, 6), Clockwise), // Fire Control Array + ("Store-All Plus", int2(2, 8), None)); // Cargo Bay var pursuer = stage.Fit("Longinus", ("Cockpit 2x2", int2(2, 6), None), ("Large Drive", int2(1, 0), Reversed), @@ -42,19 +42,19 @@ public sealed class Pursuit : Scenario ("Rugged Drive", int2(2, 14), CounterClockwise), ("Rugged Drive", int2(3, 14), Clockwise), ("Compact Reactor", int2(2, 4), None), - ("Guided Missile Launcher", int2(0, 5), None), - ("Guided Missile Launcher", int2(5, 5), None), - ("Cell Blaster", int2(1, 8), None), - ("Cell Blaster", int2(4, 8), None), + ("GT 3K", int2(0, 5), None), // Guided Missile Launcher + ("GT 3K", int2(5, 5), None), // Guided Missile Launcher + ("FastBlast+-", int2(1, 8), None), // Cell Blaster + ("FastBlast+-", int2(4, 8), None), // Cell Blaster ("Radiator", int2(2, 2), CounterClockwise), ("Radiator", int2(3, 2), Clockwise), - ("Compact Sensor", int2(3, 10), None), + ("not if i see you first", int2(3, 10), None), // Compact Sensor ("Capacitor", int2(1, 5), None), - ("Fire Control Array", int2(1, 6), Clockwise), - ("Cargo Bay", int2(2, 8), None)); + ("Panopticon Prime", int2(1, 6), Clockwise), // Fire Control Array + ("Store-All Plus", int2(2, 8), None)); // Cargo Bay var player = stage.Player(layer, float2(0, 0), facing: float2(0, 1)); - stage.Cargo(player, "Coolant Vent", "Coolant Vent", "Coolant Vent", "Thruster Overdrive", "Thruster Overdrive"); + stage.Cargo(player, "Morning Fog", "Morning Fog", "Morning Fog", "send it", "send it"); stage.Place(pursuer, float2(0, -Astern), facing: float2(0, 1), stance: ScenarioStance.Hostile, piloted: true); } } diff --git a/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs b/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs index 557812197..21208adf1 100644 --- a/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs +++ b/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs @@ -28,11 +28,11 @@ public sealed class RefusedRounds : Scenario ("Smoke Refused", int2(3, 8), None), ("Smoke Refused Beam", int2(6, 8), None), ("Smoke Refused Charge", int2(3, 17), None), - ("Autocannon", int2(5, 17), None), + ("🫳🔫", int2(5, 17), None), // Autocannon ("Radiator", int2(4, 2), CounterClockwise), ("Radiator", int2(5, 2), Clockwise), - ("Compact Sensor", int2(5, 10), None), - ("Fire Control Array", int2(3, 6), Clockwise)); + ("not if i see you first", int2(5, 10), None), // Compact Sensor + ("Panopticon Prime", int2(3, 6), Clockwise)); // Fire Control Array stage.Player(shooter, float2(0, 0), facing: float2(0, 1)); stage.Place(stage.Bare("Longinus"), Target); diff --git a/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs b/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs index 3a23b164a..b04f260fc 100644 --- a/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs +++ b/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs @@ -20,12 +20,12 @@ public sealed class StarvedReactor : Scenario ("Rugged Drive", int2(2, 14), CounterClockwise), ("Rugged Drive", int2(3, 14), Clockwise), ("Muon Fusion Reactor", int2(2, 4), None), - ("Light Laser", int2(1, 8), None), - ("Light Laser", int2(4, 8), None), + ("Spectra", int2(1, 8), None), // Light Laser + ("Spectra", int2(4, 8), None), // Light Laser ("Radiator", int2(2, 2), CounterClockwise), ("Radiator", int2(3, 2), Clockwise), - ("Compact Sensor", int2(3, 10), None), - ("Fire Control Array", int2(1, 6), Clockwise)); + ("not if i see you first", int2(3, 10), None), // Compact Sensor + ("Panopticon Prime", int2(1, 6), Clockwise)); // Fire Control Array stage.Player(starved, float2(0, 0)); stage.Place(stage.Bare("Longinus"), float2(0, 400)); } diff --git a/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs b/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs index 43d4097ae..329d0671e 100644 --- a/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs +++ b/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs @@ -24,13 +24,13 @@ public sealed class WeaponFeel : Scenario ("Rugged Drive", int2(4, 14), CounterClockwise), ("Rugged Drive", int2(5, 14), Clockwise), ("Compact Reactor", int2(4, 4), None), - ("Charged Scatter Blaster", int2(3, 17), None), - ("Autocannon", int2(5, 17), None), - ("Cell Blaster", int2(3, 8), None), + ("ChargeBlast SG", int2(3, 17), None), // Charged Scatter Blaster + ("🫳🔫", int2(5, 17), None), // Autocannon + ("FastBlast+-", int2(3, 8), None), // Cell Blaster ("Radiator", int2(4, 2), CounterClockwise), ("Radiator", int2(5, 2), Clockwise), - ("Compact Sensor", int2(5, 10), None), - ("Fire Control Array", int2(3, 6), Clockwise)); + ("not if i see you first", int2(5, 10), None), // Compact Sensor + ("Panopticon Prime", int2(3, 6), Clockwise)); // Fire Control Array stage.Player(shooter, float2(0, 0), facing: float2(0, 1)); stage.Place(stage.Bare("Longinus"), Target); From 4609f148a2aa49737f026e4684c87500c9404611 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 22:16:15 +0200 Subject: [PATCH 14/23] Tests: product choice (outfitter, seed, pin, key shuffle, capture, wrong-design chooser, wire keys); rigs keep brands; key-order test removed Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/LoadoutTests.cs | 46 +--- .../ProductChoiceTests.cs | 207 ++++++++++++++++++ tests/Aetheria.Shared.Tests/RigBrandTests.cs | 31 +++ tests/Aetheria.Shared.Tests/RunStartTests.cs | 4 +- 4 files changed, 251 insertions(+), 37 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/ProductChoiceTests.cs create mode 100644 tests/Aetheria.Shared.Tests/RigBrandTests.cs diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index f95d67047..acb54ac95 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -312,38 +312,6 @@ public sealed class LoadoutTests : IDisposable Assert.Contains("slot 0,0: Crate does not fit", failure); } - // Two new makers, each with one Lamp product, upserted into the live cache so its enumeration order (z before a) - // disagrees with record-key order; a reopened snapshot could already hand them back sorted. Each gets its own - // maker rather than sharing "Maker". - [Fact] - public void FirstAvailableProductInKeyOrderBuildsTheSlot() - { - using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); - var lamp = new CultRecordRef(cache.RefOf(cache.GetByName("Lamp")).Key); - var zMaker = cache.Upsert(new Faction { Name = "Zeta", ShortName = "ZET" }); - var aMaker = cache.Upsert(new Faction { Name = "Alpha", ShortName = "ALP" }); - cache.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData { Name = "lamp-z", Design = lamp, Manufacturer = zMaker }, new CultRecordKey("lamp-z"))); - cache.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData { Name = "lamp-a", Design = lamp, Manufacturer = aMaker }, new CultRecordKey("lamp-a"))); - Assert.Equal(new[] { "lamp-z", "lamp-a" }, cache.GetAll().Select(p => p.Name).Where(name => name.StartsWith("lamp-"))); - - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); - var hand = HandBuilt(cache); - string LampProduct(Predicate available, List failures) - { - var ship = Loadouts.Materialize(items, null, hand, available, failures); - if (ship == null) return null; - var unit = ship.Equipment.Single(item => item.EquippableItem != ship.Hull && item.Position.Equals(HardpointCell)).EquippableItem; - return items.Brand(unit).Product.Name; - } - - var failures = new List(); - Assert.Equal("lamp-a", LampProduct(p => p.Name != "Lamp by Maker", failures)); - Assert.Equal("lamp-z", LampProduct(p => p.Name != "Lamp by Maker" && p.Name != "lamp-a", failures)); - Assert.Empty(failures); - Assert.Null(LampProduct(p => !p.Name.StartsWith("Lamp") && !p.Name.StartsWith("lamp"), failures)); - Assert.Contains("slot 0,0: no available product of Lamp", Assert.Single(failures)); - } - // Scenarios Q3 (docs/scenarios-cut.md): a design no product makes is outside the economy, and a preset builds it // unbranded -- no maker -- at the one named quality. "Orphan" has no product in this catalog. [Fact] @@ -368,7 +336,7 @@ public sealed class LoadoutTests : IDisposable using var cache = Open(); var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var failures = new List(); - Assert.Null(Loadouts.Materialize(items, null, HandBuilt(cache), p => p.Name != "Lamp by Maker", failures)); + Assert.Null(Loadouts.Materialize(items, null, HandBuilt(cache), _ => null, failures)); Assert.Equal("slot 0,0: no available product of Lamp", Assert.Single(failures)); } @@ -918,9 +886,17 @@ public sealed class LoadoutTests : IDisposable Assert.Equal(originalThreshold, template.ShutdownPerformance); } - // Everything available: for tests about placement and failure lists. + // The deterministic chooser for tests about placement and failure lists: the design's only product, and nothing (so a + // failure) when it has several. + internal static Func SoleProduct(CultCache cache) => design => + { + var key = cache.RefOf(design).Key; + var made = cache.GetAll().Where(p => p.Design.Key.Equals(key)).Take(2).ToArray(); + return made.Length == 1 ? made[0] : null; + }; + private static Entity Build(ItemManager items, Loadout loadout, List failures) => - Loadouts.Materialize(items, null, loadout, _ => true, failures); + Loadouts.Materialize(items, null, loadout, SoleProduct(items.ItemData), failures); private static (int2, ItemRotation, CultRecordKey) Describe(LoadoutSlot slot) => (slot.Position, slot.Rotation, slot.Design.Key); diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs new file mode 100644 index 000000000..95659cd54 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using Xunit; +using Random = CultMath.Random; + +// Ruling loadout-product-optional-context-chooses: a slot or fit may name a product; a design-only slot is chosen by +// the outfitter (LoadoutGenerator.ChooseProduct), never by record-key order. +public sealed class ProductChoiceTests : IDisposable +{ + private static readonly int2 HardpointCell = new int2(0, 0); + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-productchoice-" + Guid.NewGuid().ToString("N")); + private readonly List _caches = new List(); + private int _catalogs; + + public ProductChoiceTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + foreach (var cache in _caches) cache.Dispose(); + Directory.Delete(_root, true); + } + + // One Skiff (made by Alpha only) and one Lamp with a product from each of Alpha and Zeta. Two outfitting factions: + // OutA's allegiance names only Alpha, OutZ's only Zeta. lampKeys are the Lamp products' record keys in insertion + // order [lamp-z, lamp-a], so a permutation shuffles both keys and enumeration. + private sealed class Rig + { + public CultCache Cache; + public ItemManager Items; + public Galaxy Galaxy; + public Faction OutA, OutZ; + + public LoadoutGenerator Outfitter(Faction faction, uint seed) + { + var random = new Random(seed); + return new LoadoutGenerator(ref random, Items, faction == null ? null : Galaxy, null, faction, .5f); + } + + public FactionProductData Product(string name) => Cache.GetAll().Single(p => p.Name == name); + public CultRecordRef ProductRef(string name) => Cache.RefOf(Product(name)); + + public Loadout Fit(CultRecordRef lamp = default) => new Loadout + { + Name = "Lamp skiff", + Hull = Cache.RefOf(Cache.GetByName("Skiff")), + HullProduct = ProductRef("Skiff by Alpha"), + Slots = { new LoadoutSlot { Position = HardpointCell, Design = Cache.RefOf(Cache.GetByName("Lamp")), Product = lamp } }, + WeaponGroups = new int[0][] + }; + + public string LampBrand(Entity ship) + { + var unit = ship.Equipment.Single(item => item.EquippableItem != ship.Hull && item.Position.Equals(HardpointCell)).EquippableItem; + return Items.Brand(unit).Product.Name; + } + } + + private Rig Build(params string[] lampOrder) + { + var root = Path.Combine(_root, "rig" + _catalogs++); + Directory.CreateDirectory(root); + var catalog = Path.Combine(root, "Aetheria.cc"); + using (var seed = AetheriaStores.Open(catalog, catalogWritable: true)) + { + seed.Upsert(new VerseGrammar { Revision = 1 }); + var alpha = seed.Upsert(new Faction { Name = "Alpha", ShortName = "ALP" }); + var zeta = seed.Upsert(new Faction { Name = "Zeta", ShortName = "ZET" }); + var mu = seed.Upsert(new Faction { Name = "Mu", ShortName = "MU" }); + seed.Upsert(new Faction { Name = "OutA", ShortName = "OTA", Allegiance = { { alpha, 1f } } }); + seed.Upsert(new Faction { Name = "OutZ", ShortName = "OTZ", Allegiance = { { zeta, 1f } } }); + var shape = new Shape(3, 3); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + var skiff = seed.Upsert(new HullData + { + Name = "Skiff", HullType = HullType.Ship, Shape = shape, Price = 100, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } } + }); + var lamp = seed.Upsert(new GearData { Name = "Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10 }); + void Sell(string name, CultRecordKey design, CultRecordRef maker, string key) => + seed.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData + { Name = name, Design = new CultRecordRef(design), Manufacturer = maker }, new CultRecordKey(key))); + Sell("Skiff by Alpha", skiff.Key, alpha, "skiff-a"); + var makers = new Dictionary> { ["lamp-a"] = alpha, ["lamp-z"] = zeta, ["lamp-m"] = mu }; + // lampOrder holds product names; the record key of the i-th inserted is "k" + i, so keys and insertion order + // both follow the permutation. + for (var i = 0; i < lampOrder.Length; i++) Sell(lampOrder[i], lamp.Key, makers[lampOrder[i]], "k" + i); + seed.FlushAsync().Wait(); + } + + var cache = AetheriaStores.Open(catalog, Path.Combine(root, "run.cc"), Path.Combine(root, "player.cc")); + _caches.Add(cache); + var outA = cache.GetByName("OutA"); + var outZ = cache.GetByName("OutZ"); + var game = new SavedGame + { + Factions = new[] { cache.RefOf(outA), cache.RefOf(outZ) }, + Relationships = new[] { FactionRelationship.Neutral, FactionRelationship.Neutral }, + HomeZones = new Dictionary { { 0, 0 }, { 1, 0 } }, + BossZones = new Dictionary(), + DiscoveredZones = new[] { 0 }, + ActionBarBindings = new SavedActionBarBinding[0], + Exit = -1 + }; + var zones = new[] + { + new SavedZone { Name = "Zone 0", Position = new float2(0, 0), AdjacentZones = Array.Empty(), Factions = new[] { 0, 1 }, Owner = 0, Contents = null } + }; + RunSave.Commit(cache, game, zones, new ProvenanceLedger()); + return new Rig + { + Cache = cache, OutA = outA, OutZ = outZ, + Items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }), + Galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }) + }; + } + + private static Entity Materialize(Rig rig, Loadout loadout, Func choose, List failures) => + Loadouts.Materialize(rig.Items, null, loadout, choose, failures); + + [Fact] + public void ADifferentOutfitterChoosesADifferentProduct() + { + var rig = Build("lamp-z", "lamp-a"); + var failures = new List(); + Assert.Equal("lamp-a", rig.LampBrand(Materialize(rig, rig.Fit(), rig.Outfitter(rig.OutA, 1).ChooseProduct, failures))); + Assert.Equal("lamp-z", rig.LampBrand(Materialize(rig, rig.Fit(), rig.Outfitter(rig.OutZ, 1).ChooseProduct, failures))); + Assert.Empty(failures); + } + + [Fact] + public void ANullFactionDrawIsSeededAndReachesEveryMaker() + { + var rig = Build("lamp-z", "lamp-a"); + var lamp = rig.Cache.GetByName("Lamp"); + Assert.Equal(rig.Outfitter(null, 7).ChooseProduct(lamp).Name, rig.Outfitter(null, 7).ChooseProduct(lamp).Name); + var seen = Enumerable.Range(1, 64).Select(seed => rig.Outfitter(null, (uint) seed).ChooseProduct(lamp).Name).Distinct().OrderBy(name => name).ToArray(); + Assert.Equal(new[] { "lamp-a", "lamp-z" }, seen); + } + + [Fact] + public void APinnedProductIsBuiltInEveryContext() + { + var rig = Build("lamp-z", "lamp-a"); + var failures = new List(); + var pinned = rig.Fit(rig.ProductRef("lamp-z")); + Assert.Equal("lamp-z", rig.LampBrand(Materialize(rig, pinned, rig.Outfitter(rig.OutA, 1).ChooseProduct, failures))); + Assert.Equal("lamp-z", rig.LampBrand(Materialize(rig, pinned, _ => null, failures))); + Assert.Empty(failures); + + var foreign = rig.Fit(rig.ProductRef("Skiff by Alpha")); + Assert.Null(Materialize(rig, foreign, rig.Outfitter(null, 1).ChooseProduct, failures)); + Assert.Equal("slot 0,0: product Skiff by Alpha is not a product of Lamp", Assert.Single(failures)); + } + + [Fact] + public void ShuffledKeysDoNotChangeTheChoice() + { + var orders = new[] { new[] { "lamp-a", "lamp-m", "lamp-z" }, new[] { "lamp-z", "lamp-a", "lamp-m" }, new[] { "lamp-m", "lamp-z", "lamp-a" } }; + var rigs = orders.Select(order => Build(order)).ToArray(); + foreach (var faction in new Func[] { _ => null, rig => rig.OutA }) + for (uint seed = 1; seed <= 16; seed++) + { + var chosen = rigs.Select(rig => rig.Outfitter(faction(rig), seed).ChooseProduct(rig.Cache.GetByName("Lamp"))?.Name).Distinct().ToArray(); + Assert.Single(chosen); + } + } + + [Fact] + public void CaptureKeepsTheLotsProduct() + { + var rig = Build("lamp-z", "lamp-a"); + var failures = new List(); + var ship = Materialize(rig, rig.Fit(rig.ProductRef("lamp-z")), rig.Outfitter(rig.OutA, 1).ChooseProduct, failures); + var captured = Loadouts.Capture(rig.Items, ship, "captured"); + Assert.Equal(rig.ProductRef("lamp-z").Key, captured.Slots.Single().Product.Key); + Assert.Equal(rig.ProductRef("Skiff by Alpha").Key, captured.HullProduct.Key); + + var rebuilt = Materialize(rig, captured, _ => null, failures); + Assert.Empty(failures); + Assert.Equal("lamp-z", rig.LampBrand(rebuilt)); + Assert.Equal("Skiff by Alpha", rig.Items.Brand(rebuilt.Hull).Product.Name); + } + + [Fact] + public void AChooserOfAnotherDesignIsRefused() + { + var rig = Build("lamp-z", "lamp-a"); + var failures = new List(); + Assert.Null(Materialize(rig, rig.Fit(), _ => rig.Product("Skiff by Alpha"), failures)); + Assert.Equal("slot 0,0: product Skiff by Alpha is not a product of Lamp", Assert.Single(failures)); + } + + // The wire positions of the new fields: a preset written by one build is read by the next. + [Fact] + public void TheProductRefsSitAtTheirWirePositions() + { + Assert.Equal(4, typeof(Loadout).GetField(nameof(Loadout.HullProduct)).GetCustomAttribute().IntKey); + Assert.Equal(3, typeof(LoadoutSlot).GetField(nameof(LoadoutSlot.Product)).GetCustomAttribute().IntKey); + } +} diff --git a/tests/Aetheria.Shared.Tests/RigBrandTests.cs b/tests/Aetheria.Shared.Tests/RigBrandTests.cs new file mode 100644 index 000000000..fe063107f --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RigBrandTests.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Linq; +using Xunit; + +// Ruling loadout-product-optional-context-chooses: a rig whose test reads stats names the brand it was measured on, and +// staging builds exactly that brand whatever the stage's seed draws for open designs. The fixture is RunStartTests'. +public sealed partial class RunStartTests +{ + private string ProductOf(EquippableItem item) => _items.Brand(item).Product.Name; + + private IEnumerable FittedBrands(Entity entity, string design) => + entity.Equipment.Where(item => _items.GetData(item.EquippableItem).Name == design).Select(item => ProductOf(item.EquippableItem)); + + [Fact] + public void TheRigsKeepTheirBrands() + { + var pursuit = Launch(new Pursuit(), Inputs(() => GalaxySeed)); + Assert.Empty(pursuit.failures); + Assert.Equal(new[] { "parting gift" }, FittedBrands(pursuit.staged.Player, "Mine Launcher")); + Assert.Equal(new[] { "Leonid" }, FittedBrands(pursuit.staged.Player, "Swarm Rocket Pod")); + + var duel = Launch(new Duel(), Inputs(() => GalaxySeed)); + Assert.Empty(duel.failures); + var rival = Assert.Single(duel.staged.Entities); + Assert.Equal(new[] { "Spectra" }, Held(rival).Select(item => _items.Brand(item).Product.Name)); + + var arcs = Launch(new Arcs(), Inputs(() => GalaxySeed)); + Assert.Empty(arcs.failures); + Assert.Equal(new[] { "🫳🔫" }, FittedBrands(arcs.staged.Player, "Autocannon")); + } +} diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 5ad89c639..18286649d 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -662,7 +662,7 @@ public sealed partial class RunStartTests : IDisposable foreach (var hull in new[] { Hull("Djinni"), Hull("Longinus"), TurretHull() }) { var failures = new List(); - var entity = Loadouts.Materialize(_items, null, BareLoadout(hull), available.IsAvailable, failures); + var entity = Loadouts.Materialize(_items, null, BareLoadout(hull), available.ChooseProduct, failures); Assert.Empty(failures); Assert.NotNull(_items.Brand(entity.Hull).Maker); } @@ -681,7 +681,7 @@ public sealed partial class RunStartTests : IDisposable Assert.True(ships.Count > 1); foreach (var ship in ships) Assert.Equal(ship.IsPlayerShip ? 0 : 1, AgentsOf(zone, ship)); - var arriving = (Ship) Loadouts.Materialize(_items, zone, BareLoadout(Hull("Djinni")), _ => true, new List()); + var arriving = (Ship) Loadouts.Materialize(_items, zone, BareLoadout(Hull("Djinni")), new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, null, 2).ChooseProduct, new List()); var agents = zone.Agents.Count; zone.Admit(arriving, piloted: false); SafeAssert.In(zone, arriving, "the arrival is admitted"); From 94ab2d3f5087d1d22f29fd247e0e9a1ad3292d03 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 22:17:35 +0200 Subject: [PATCH 15/23] ProductChoiceTests: the draw follows the outfitter's preference Co-Authored-By: Claude Opus 5.5 --- .../ProductChoiceTests.cs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs index 95659cd54..d044b2048 100644 --- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs @@ -35,7 +35,7 @@ public sealed class ProductChoiceTests : IDisposable public CultCache Cache; public ItemManager Items; public Galaxy Galaxy; - public Faction OutA, OutZ; + public Faction OutA, OutZ, OutBoth; public LoadoutGenerator Outfitter(Faction faction, uint seed) { @@ -75,6 +75,7 @@ public sealed class ProductChoiceTests : IDisposable var mu = seed.Upsert(new Faction { Name = "Mu", ShortName = "MU" }); seed.Upsert(new Faction { Name = "OutA", ShortName = "OTA", Allegiance = { { alpha, 1f } } }); seed.Upsert(new Faction { Name = "OutZ", ShortName = "OTZ", Allegiance = { { zeta, 1f } } }); + seed.Upsert(new Faction { Name = "OutBoth", ShortName = "OTB", Allegiance = { { alpha, 1f }, { zeta, .1f } } }); var shape = new Shape(3, 3); foreach (var cell in shape.AllCoordinates) shape[cell] = true; var skiff = seed.Upsert(new HullData @@ -115,7 +116,7 @@ public sealed class ProductChoiceTests : IDisposable RunSave.Commit(cache, game, zones, new ProvenanceLedger()); return new Rig { - Cache = cache, OutA = outA, OutZ = outZ, + Cache = cache, OutA = outA, OutZ = outZ, OutBoth = cache.GetByName("OutBoth"), Items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }), Galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }) }; @@ -134,6 +135,19 @@ public sealed class ProductChoiceTests : IDisposable Assert.Empty(failures); } + // An outfitter that reaches both makers favors the one its allegiance weighs ten times heavier: the draw follows + // ManufacturerPreference rather than splitting evenly. + [Fact] + public void TheDrawFollowsTheOutfittersPreference() + { + var rig = Build("lamp-z", "lamp-a"); + var lamp = rig.Cache.GetByName("Lamp"); + var drawn = Enumerable.Range(1, 200).Select(seed => rig.Outfitter(rig.OutBoth, (uint) seed).ChooseProduct(lamp).Name).ToArray(); + var alpha = drawn.Count(name => name == "lamp-a"); + Assert.InRange(alpha, 160, 199); + Assert.Contains("lamp-z", drawn); + } + [Fact] public void ANullFactionDrawIsSeededAndReachesEveryMaker() { From 00956ce329b78102935b5f2da66552146ddd4aa7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 22:34:34 +0200 Subject: [PATCH 16/23] Tests: a loadout may reference products; the unavailable-product test fails one design; distribution tests spread their seeds Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/LoadoutTests.cs | 6 ++++-- tests/Aetheria.Shared.Tests/ProductChoiceTests.cs | 7 +++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index acb54ac95..f1ae47b5d 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -252,7 +252,8 @@ public sealed class LoadoutTests : IDisposable Assert.DoesNotContain(forbidden, bad => bad.IsAssignableFrom(type)); if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>)) { - Assert.True(typeof(EquippableItemData).IsAssignableFrom(type.GetGenericArguments()[0]), $"{type} names a non-design record"); + var named = type.GetGenericArguments()[0]; + Assert.True(typeof(EquippableItemData).IsAssignableFrom(named) || named == typeof(FactionProductData), $"{type} names a record that is neither a design nor a product"); continue; } if (type.IsArray) pending.Enqueue(type.GetElementType()); @@ -336,7 +337,8 @@ public sealed class LoadoutTests : IDisposable using var cache = Open(); var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var failures = new List(); - Assert.Null(Loadouts.Materialize(items, null, HandBuilt(cache), _ => null, failures)); + var soleProduct = SoleProduct(cache); + Assert.Null(Loadouts.Materialize(items, null, HandBuilt(cache), design => design.Name == "Lamp" ? null : soleProduct(design), failures)); Assert.Equal("slot 0,0: no available product of Lamp", Assert.Single(failures)); } diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs index d044b2048..5f234d6ef 100644 --- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs @@ -122,6 +122,9 @@ public sealed class ProductChoiceTests : IDisposable }; } + // Small seeds give correlated first draws, so the seeds a distribution test runs over are spread across the range. + private static uint Seed(int i) => unchecked((uint) (i * 2654435761L)); + private static Entity Materialize(Rig rig, Loadout loadout, Func choose, List failures) => Loadouts.Materialize(rig.Items, null, loadout, choose, failures); @@ -142,7 +145,7 @@ public sealed class ProductChoiceTests : IDisposable { var rig = Build("lamp-z", "lamp-a"); var lamp = rig.Cache.GetByName("Lamp"); - var drawn = Enumerable.Range(1, 200).Select(seed => rig.Outfitter(rig.OutBoth, (uint) seed).ChooseProduct(lamp).Name).ToArray(); + var drawn = Enumerable.Range(1, 200).Select(i => rig.Outfitter(rig.OutBoth, Seed(i)).ChooseProduct(lamp).Name).ToArray(); var alpha = drawn.Count(name => name == "lamp-a"); Assert.InRange(alpha, 160, 199); Assert.Contains("lamp-z", drawn); @@ -154,7 +157,7 @@ public sealed class ProductChoiceTests : IDisposable var rig = Build("lamp-z", "lamp-a"); var lamp = rig.Cache.GetByName("Lamp"); Assert.Equal(rig.Outfitter(null, 7).ChooseProduct(lamp).Name, rig.Outfitter(null, 7).ChooseProduct(lamp).Name); - var seen = Enumerable.Range(1, 64).Select(seed => rig.Outfitter(null, (uint) seed).ChooseProduct(lamp).Name).Distinct().OrderBy(name => name).ToArray(); + var seen = Enumerable.Range(1, 64).Select(i => rig.Outfitter(null, Seed(i)).ChooseProduct(lamp).Name).Distinct().OrderBy(name => name).ToArray(); Assert.Equal(new[] { "lamp-a", "lamp-z" }, seen); } From 66aee3cf0ec3c9905cd8a7c5c9aaa17d1cd93e36 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 00:04:35 +0200 Subject: [PATCH 17/23] ProductChoiceTests: an outfitter that reaches no maker gets no product Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/ProductChoiceTests.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs index 5f234d6ef..fd2f19be8 100644 --- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs @@ -35,7 +35,7 @@ public sealed class ProductChoiceTests : IDisposable public CultCache Cache; public ItemManager Items; public Galaxy Galaxy; - public Faction OutA, OutZ, OutBoth; + public Faction OutA, OutZ, OutBoth, OutNone; public LoadoutGenerator Outfitter(Faction faction, uint seed) { @@ -76,6 +76,7 @@ public sealed class ProductChoiceTests : IDisposable seed.Upsert(new Faction { Name = "OutA", ShortName = "OTA", Allegiance = { { alpha, 1f } } }); seed.Upsert(new Faction { Name = "OutZ", ShortName = "OTZ", Allegiance = { { zeta, 1f } } }); seed.Upsert(new Faction { Name = "OutBoth", ShortName = "OTB", Allegiance = { { alpha, 1f }, { zeta, .1f } } }); + seed.Upsert(new Faction { Name = "OutNone", ShortName = "OTN" }); var shape = new Shape(3, 3); foreach (var cell in shape.AllCoordinates) shape[cell] = true; var skiff = seed.Upsert(new HullData @@ -116,7 +117,7 @@ public sealed class ProductChoiceTests : IDisposable RunSave.Commit(cache, game, zones, new ProvenanceLedger()); return new Rig { - Cache = cache, OutA = outA, OutZ = outZ, OutBoth = cache.GetByName("OutBoth"), + Cache = cache, OutA = outA, OutZ = outZ, OutBoth = cache.GetByName("OutBoth"), OutNone = cache.GetByName("OutNone"), Items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }), Galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }) }; @@ -151,6 +152,18 @@ public sealed class ProductChoiceTests : IDisposable Assert.Contains("lamp-z", drawn); } + // An outfitter that reaches no maker of the design gets nothing, however the weights fall: the slot fails. + [Fact] + public void AnOutfitterThatReachesNoMakerGetsNoProduct() + { + var rig = Build("lamp-z", "lamp-a"); + var generator = rig.Outfitter(rig.OutNone, 1); + Assert.Null(generator.ChooseProduct(rig.Cache.GetByName("Lamp"))); + var failures = new List(); + Assert.Null(Materialize(rig, rig.Fit(), generator.ChooseProduct, failures)); + Assert.Equal("slot 0,0: no available product of Lamp", Assert.Single(failures)); + } + [Fact] public void ANullFactionDrawIsSeededAndReachesEveryMaker() { From 98337213a494138b16245940e147bb8465530707 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 00:28:00 +0200 Subject: [PATCH 18/23] RigBrandTests: each rig is staged under eight seeds, so a pin is told from a lucky draw Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/RigBrandTests.cs | 27 ++++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RigBrandTests.cs b/tests/Aetheria.Shared.Tests/RigBrandTests.cs index fe063107f..7f2369cb4 100644 --- a/tests/Aetheria.Shared.Tests/RigBrandTests.cs +++ b/tests/Aetheria.Shared.Tests/RigBrandTests.cs @@ -11,21 +11,26 @@ public sealed partial class RunStartTests private IEnumerable FittedBrands(Entity entity, string design) => entity.Equipment.Where(item => _items.GetData(item.EquippableItem).Name == design).Select(item => ProductOf(item.EquippableItem)); + // Each rig is staged under eight seeds: an open design would draw its brand from the seed, so a pin that holds under + // all of them is a pin, not a draw that happened to land on the same product. [Fact] public void TheRigsKeepTheirBrands() { - var pursuit = Launch(new Pursuit(), Inputs(() => GalaxySeed)); - Assert.Empty(pursuit.failures); - Assert.Equal(new[] { "parting gift" }, FittedBrands(pursuit.staged.Player, "Mine Launcher")); - Assert.Equal(new[] { "Leonid" }, FittedBrands(pursuit.staged.Player, "Swarm Rocket Pod")); + for (uint seed = 1; seed <= 8; seed++) + { + var pursuit = Launch(new Scripted(false, new Pursuit().Stage, seed * 7919)); + Assert.Empty(pursuit.failures); + Assert.Equal(new[] { "parting gift" }, FittedBrands(pursuit.staged.Player, "Mine Launcher")); + Assert.Equal(new[] { "Leonid" }, FittedBrands(pursuit.staged.Player, "Swarm Rocket Pod")); - var duel = Launch(new Duel(), Inputs(() => GalaxySeed)); - Assert.Empty(duel.failures); - var rival = Assert.Single(duel.staged.Entities); - Assert.Equal(new[] { "Spectra" }, Held(rival).Select(item => _items.Brand(item).Product.Name)); + var duel = Launch(new Scripted(false, new Duel().Stage, seed * 7919)); + Assert.Empty(duel.failures); + var rival = Assert.Single(duel.staged.Entities); + Assert.Equal(new[] { "Spectra" }, Held(rival).Select(item => _items.Brand(item).Product.Name)); - var arcs = Launch(new Arcs(), Inputs(() => GalaxySeed)); - Assert.Empty(arcs.failures); - Assert.Equal(new[] { "🫳🔫" }, FittedBrands(arcs.staged.Player, "Autocannon")); + var arcs = Launch(new Scripted(false, new Arcs().Stage, seed * 7919)); + Assert.Empty(arcs.failures); + Assert.Equal(new[] { "🫳🔫" }, FittedBrands(arcs.staged.Player, "Autocannon")); + } } } From 94570ca0be8765e4da2eaf173e497addbe17ee25 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 02:41:44 +0200 Subject: [PATCH 19/23] Tests: zone-weighted and zero-weight product draws pinned; every scenario brand pin guarded under eight seeds ProductChoiceTests adopts Soul's probes: a nearer maker is favored (non-null zone), an allegiance weighs the draw, and an all-zero weight draws uniformly. RigBrandTests becomes a theory over every scenario whose fits name products: each pinned design must come out as exactly its brand on every fitted ship under eight stage seeds. Not yet run: the commit is unverified. Co-Authored-By: Claude Opus 5.5 --- .../ProductChoiceTests.cs | 57 ++++++++++++++--- tests/Aetheria.Shared.Tests/RigBrandTests.cs | 62 ++++++++++++++----- 2 files changed, 94 insertions(+), 25 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs index fd2f19be8..5546a58a5 100644 --- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs @@ -35,12 +35,13 @@ public sealed class ProductChoiceTests : IDisposable public CultCache Cache; public ItemManager Items; public Galaxy Galaxy; - public Faction OutA, OutZ, OutBoth, OutNone; + public Faction OutA, OutZ, OutBoth, OutEven, OutNone; - public LoadoutGenerator Outfitter(Faction faction, uint seed) + // zone is the index of the zone the outfitter stands in; -1 stands in none, so distance does not weigh. + public LoadoutGenerator Outfitter(Faction faction, uint seed, int zone = -1) { var random = new Random(seed); - return new LoadoutGenerator(ref random, Items, faction == null ? null : Galaxy, null, faction, .5f); + return new LoadoutGenerator(ref random, Items, faction == null ? null : Galaxy, faction == null || zone < 0 ? null : Galaxy.Zones[zone], faction, .5f); } public FactionProductData Product(string name) => Cache.GetAll().Single(p => p.Name == name); @@ -76,6 +77,9 @@ public sealed class ProductChoiceTests : IDisposable seed.Upsert(new Faction { Name = "OutA", ShortName = "OTA", Allegiance = { { alpha, 1f } } }); seed.Upsert(new Faction { Name = "OutZ", ShortName = "OTZ", Allegiance = { { zeta, 1f } } }); seed.Upsert(new Faction { Name = "OutBoth", ShortName = "OTB", Allegiance = { { alpha, 1f }, { zeta, .1f } } }); + seed.Upsert(new Faction { Name = "OutEven", ShortName = "OTE", Allegiance = { { alpha, 1f }, { zeta, 1f } } }); + seed.Upsert(new Faction { Name = "OutLoose", ShortName = "OTL", Allegiance = { { alpha, 1f }, { zeta, .25f } } }); + seed.Upsert(new Faction { Name = "OutZero", ShortName = "OTO", Allegiance = { { alpha, 0f }, { zeta, 0f } } }); seed.Upsert(new Faction { Name = "OutNone", ShortName = "OTN" }); var shape = new Shape(3, 3); foreach (var cell in shape.AllCoordinates) shape[cell] = true; @@ -102,9 +106,10 @@ public sealed class ProductChoiceTests : IDisposable var outZ = cache.GetByName("OutZ"); var game = new SavedGame { - Factions = new[] { cache.RefOf(outA), cache.RefOf(outZ) }, - Relationships = new[] { FactionRelationship.Neutral, FactionRelationship.Neutral }, - HomeZones = new Dictionary { { 0, 0 }, { 1, 0 } }, + // Alpha's headquarters are in zone 0 and Zeta's in zone 1. + Factions = new[] { cache.RefOf(outA), cache.RefOf(outZ), cache.RefOf(cache.GetByName("Alpha")), cache.RefOf(cache.GetByName("Zeta")) }, + Relationships = new[] { FactionRelationship.Neutral, FactionRelationship.Neutral, FactionRelationship.Neutral, FactionRelationship.Neutral }, + HomeZones = new Dictionary { { 0, 0 }, { 1, 0 }, { 2, 0 }, { 3, 1 } }, BossZones = new Dictionary(), DiscoveredZones = new[] { 0 }, ActionBarBindings = new SavedActionBarBinding[0], @@ -112,12 +117,13 @@ public sealed class ProductChoiceTests : IDisposable }; var zones = new[] { - new SavedZone { Name = "Zone 0", Position = new float2(0, 0), AdjacentZones = Array.Empty(), Factions = new[] { 0, 1 }, Owner = 0, Contents = null } + new SavedZone { Name = "Zone 0", Position = new float2(0, 0), AdjacentZones = new[] { 1 }, Factions = new[] { 0, 1 }, Owner = 0, Contents = null }, + new SavedZone { Name = "Zone 1", Position = new float2(1, 0), AdjacentZones = new[] { 0 }, Factions = new[] { 0, 1 }, Owner = 0, Contents = null } }; RunSave.Commit(cache, game, zones, new ProvenanceLedger()); return new Rig { - Cache = cache, OutA = outA, OutZ = outZ, OutBoth = cache.GetByName("OutBoth"), OutNone = cache.GetByName("OutNone"), + Cache = cache, OutA = outA, OutZ = outZ, OutBoth = cache.GetByName("OutBoth"), OutEven = cache.GetByName("OutEven"), OutNone = cache.GetByName("OutNone"), Items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }), Galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }) }; @@ -227,6 +233,41 @@ public sealed class ProductChoiceTests : IDisposable Assert.Equal("slot 0,0: product Skiff by Alpha is not a product of Lamp", Assert.Single(failures)); } + private static int MakerDraws(Rig rig, Faction faction, int zone, string maker, int n) + { + var lamp = rig.Cache.GetByName("Lamp"); + return Enumerable.Range(1, n).Count(i => rig.Outfitter(faction, Seed(i), zone).ChooseProduct(lamp).Name == maker); + } + + // Two makers an even allegiance weighs the same differ only by distance: standing in a zone, the maker whose + // headquarters is nearer comes up about twice as often (1 against 1/2). + [Fact] + public void ANearerMakerIsFavored() + { + var rig = Build("lamp-z", "lamp-a"); + Assert.InRange(MakerDraws(rig, rig.OutEven, 0, "lamp-a", 300), 165, 235); + Assert.InRange(MakerDraws(rig, rig.OutEven, 1, "lamp-a", 300), 65, 135); + } + + // 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] + public void AnAlliedMakerWeighsByItsAllegiance() + { + var rig = Build("lamp-z", "lamp-a"); + Assert.InRange(MakerDraws(rig, rig.Cache.GetByName("OutLoose"), 0, "lamp-a", 300), 245, 290); + } + + // When every reachable maker weighs 0 the draw is uniform over them, not a pick of the first or last. + [Fact] + public void WhenEveryWeightIsZeroTheDrawIsUniform() + { + var rig = Build("lamp-z", "lamp-a"); + var zero = rig.Cache.GetByName("OutZero"); + Assert.InRange(MakerDraws(rig, zero, 0, "lamp-a", 300), 100, 200); + Assert.InRange(MakerDraws(rig, zero, 0, "lamp-z", 300), 100, 200); + } + // The wire positions of the new fields: a preset written by one build is read by the next. [Fact] public void TheProductRefsSitAtTheirWirePositions() diff --git a/tests/Aetheria.Shared.Tests/RigBrandTests.cs b/tests/Aetheria.Shared.Tests/RigBrandTests.cs index 7f2369cb4..17e05a29a 100644 --- a/tests/Aetheria.Shared.Tests/RigBrandTests.cs +++ b/tests/Aetheria.Shared.Tests/RigBrandTests.cs @@ -11,26 +11,54 @@ public sealed partial class RunStartTests private IEnumerable FittedBrands(Entity entity, string design) => entity.Equipment.Where(item => _items.GetData(item.EquippableItem).Name == design).Select(item => ProductOf(item.EquippableItem)); - // Each rig is staged under eight seeds: an open design would draw its brand from the seed, so a pin that holds under - // all of them is a pin, not a draw that happened to land on the same product. - [Fact] - public void TheRigsKeepTheirBrands() + private const string Sensor = "Compact Sensor", Fca = "Fire Control Array", Missile = "Guided Missile Launcher", Cell = "Cell Blaster", Cargo = "Cargo Bay"; + + // Every product a scenario's fits name, as (design, brand). A rig whose fits name a design where a brand stood would + // draw its brand from the stage's seed; so each scenario is staged under eight seeds, and every pinned design must + // come out as exactly its brand on every ship the scenario fits. A hull that is not a fit's (a generated or bare ship) + // is left out: its gear is not pinned. + public static IEnumerable PinnedRigs() => new[] + { + Rig(new AiFusedDiscipline(), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new Arcs(), (Missile, "GT 3K"), (Cell, "FastBlast+-"), ("Autocannon", "🫳🔫"), ("Charged Scatter Blaster", "ChargeBlast SG"), ("Light Machine Gun", "ClearPath"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new Belt(), (Missile, "GT 3K"), ("Lightning Projector", "DragOnBreath"), (Cell, "FastBlast+-"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new Crossing(), (Missile, "GT 3K"), ("Lightning Projector", "DragOnBreath"), (Cell, "FastBlast+-"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new Duel(), (Missile, "GT 3K"), (Cell, "FastBlast+-"), (Cargo, "Store-All Plus"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new FusedRounds(), ("Cluster Cannon", "DeathCluster"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new LauncherAngles(), (Missile, "GT 3K"), ("Swarm Rocket Pod", "Leonid"), (Cell, "FastBlast+-"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new LockOn(), (Missile, "GT 3K"), (Cell, "FastBlast+-"), (Cargo, "Store-All Plus"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new Pursuit(), ("Mine Launcher", "parting gift"), ("Swarm Rocket Pod", "Leonid"), (Missile, "GT 3K"), (Cell, "FastBlast+-"), (Cargo, "Store-All Plus"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new RefusedRounds(), ("Autocannon", "🫳🔫"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new StarvedReactor(), ("Light Laser", "Spectra"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new WeaponFeel(), ("Charged Scatter Blaster", "ChargeBlast SG"), ("Autocannon", "🫳🔫"), (Cell, "FastBlast+-"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + }; + + private static object[] Rig(Scenario scenario, params (string design, string brand)[] pins) => new object[] { scenario, pins }; + + [Theory] + [MemberData(nameof(PinnedRigs))] + public void TheRigsKeepTheirBrands(Scenario scenario, (string design, string brand)[] pins) { for (uint seed = 1; seed <= 8; seed++) { - var pursuit = Launch(new Scripted(false, new Pursuit().Stage, seed * 7919)); - Assert.Empty(pursuit.failures); - Assert.Equal(new[] { "parting gift" }, FittedBrands(pursuit.staged.Player, "Mine Launcher")); - Assert.Equal(new[] { "Leonid" }, FittedBrands(pursuit.staged.Player, "Swarm Rocket Pod")); - - var duel = Launch(new Scripted(false, new Duel().Stage, seed * 7919)); - Assert.Empty(duel.failures); - var rival = Assert.Single(duel.staged.Entities); - Assert.Equal(new[] { "Spectra" }, Held(rival).Select(item => _items.Brand(item).Product.Name)); - - var arcs = Launch(new Scripted(false, new Arcs().Stage, seed * 7919)); - Assert.Empty(arcs.failures); - Assert.Equal(new[] { "🫳🔫" }, FittedBrands(arcs.staged.Player, "Autocannon")); + var (_, _, staged, failures) = Launch(new Scripted(false, scenario.Stage, seed * 7919, scenario.Generate)); + Assert.Empty(failures); + var ships = staged.Entities.Append(staged.Player).Where(ship => _items.GetData(ship.Hull).Name is "Longinus" or "Smoke Test Hull"); + foreach (var (design, brand) in pins) + { + var brands = ships.SelectMany(ship => FittedBrands(ship, design)).Distinct().ToArray(); + Assert.True(brands.SequenceEqual(new[] { brand }), $"{scenario.Name} seed {seed}: {design} is built as [{string.Join(", ", brands)}], not {brand}"); + } } } + + // Duel's rival holds Spectra in its cargo. + [Fact] + public void TheDuelRivalHoldsItsBrand() + { + var duel = Launch(new Scripted(false, new Duel().Stage, 7919)); + Assert.Empty(duel.failures); + var rival = Assert.Single(duel.staged.Entities); + Assert.Equal(new[] { "Spectra" }, Held(rival).Select(item => _items.Brand(item).Product.Name)); + } } From 86a498c566bf3dcf5e253bcfb1cf3b20cc778d61 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 02:48:47 +0200 Subject: [PATCH 20/23] RigBrandTests: a pin names its brand only; the design is the product's own Not yet run on its own; the previous commit's table named designs by hand and Cargo Bay did not match. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/RigBrandTests.cs | 44 ++++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RigBrandTests.cs b/tests/Aetheria.Shared.Tests/RigBrandTests.cs index 17e05a29a..901afaf71 100644 --- a/tests/Aetheria.Shared.Tests/RigBrandTests.cs +++ b/tests/Aetheria.Shared.Tests/RigBrandTests.cs @@ -11,43 +11,43 @@ public sealed partial class RunStartTests private IEnumerable FittedBrands(Entity entity, string design) => entity.Equipment.Where(item => _items.GetData(item.EquippableItem).Name == design).Select(item => ProductOf(item.EquippableItem)); - private const string Sensor = "Compact Sensor", Fca = "Fire Control Array", Missile = "Guided Missile Launcher", Cell = "Cell Blaster", Cargo = "Cargo Bay"; - - // Every product a scenario's fits name, as (design, brand). A rig whose fits name a design where a brand stood would - // draw its brand from the stage's seed; so each scenario is staged under eight seeds, and every pinned design must - // come out as exactly its brand on every ship the scenario fits. A hull that is not a fit's (a generated or bare ship) + // Every product a scenario's fits name. A rig whose fits name a design where a brand stood would draw its brand from + // the stage's seed; so each scenario is staged under eight seeds, and the design of every pinned product must come out + // as exactly that product on every ship the scenario fits. A hull that is not a fit's (a generated or bare ship) // is left out: its gear is not pinned. public static IEnumerable PinnedRigs() => new[] { - Rig(new AiFusedDiscipline(), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), - Rig(new Arcs(), (Missile, "GT 3K"), (Cell, "FastBlast+-"), ("Autocannon", "🫳🔫"), ("Charged Scatter Blaster", "ChargeBlast SG"), ("Light Machine Gun", "ClearPath"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), - Rig(new Belt(), (Missile, "GT 3K"), ("Lightning Projector", "DragOnBreath"), (Cell, "FastBlast+-"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), - Rig(new Crossing(), (Missile, "GT 3K"), ("Lightning Projector", "DragOnBreath"), (Cell, "FastBlast+-"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), - Rig(new Duel(), (Missile, "GT 3K"), (Cell, "FastBlast+-"), (Cargo, "Store-All Plus"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), - Rig(new FusedRounds(), ("Cluster Cannon", "DeathCluster"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), - Rig(new LauncherAngles(), (Missile, "GT 3K"), ("Swarm Rocket Pod", "Leonid"), (Cell, "FastBlast+-"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), - Rig(new LockOn(), (Missile, "GT 3K"), (Cell, "FastBlast+-"), (Cargo, "Store-All Plus"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), - Rig(new Pursuit(), ("Mine Launcher", "parting gift"), ("Swarm Rocket Pod", "Leonid"), (Missile, "GT 3K"), (Cell, "FastBlast+-"), (Cargo, "Store-All Plus"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), - Rig(new RefusedRounds(), ("Autocannon", "🫳🔫"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), - Rig(new StarvedReactor(), ("Light Laser", "Spectra"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), - Rig(new WeaponFeel(), ("Charged Scatter Blaster", "ChargeBlast SG"), ("Autocannon", "🫳🔫"), (Cell, "FastBlast+-"), (Sensor, "not if i see you first"), (Fca, "Panopticon Prime")), + Rig(new AiFusedDiscipline(), "not if i see you first", "Panopticon Prime"), + Rig(new Arcs(), "GT 3K", "FastBlast+-", "🫳🔫", "ChargeBlast SG", "ClearPath", "not if i see you first", "Panopticon Prime"), + Rig(new Belt(), "GT 3K", "DragOnBreath", "FastBlast+-", "not if i see you first", "Panopticon Prime"), + Rig(new Crossing(), "GT 3K", "DragOnBreath", "FastBlast+-", "not if i see you first", "Panopticon Prime"), + Rig(new Duel(), "GT 3K", "FastBlast+-", "Store-All Plus", "not if i see you first", "Panopticon Prime"), + Rig(new FusedRounds(), "DeathCluster", "not if i see you first", "Panopticon Prime"), + Rig(new LauncherAngles(), "GT 3K", "Leonid", "FastBlast+-", "not if i see you first", "Panopticon Prime"), + Rig(new LockOn(), "GT 3K", "FastBlast+-", "Store-All Plus", "not if i see you first", "Panopticon Prime"), + Rig(new Pursuit(), "parting gift", "Leonid", "GT 3K", "FastBlast+-", "Store-All Plus", "not if i see you first", "Panopticon Prime"), + Rig(new RefusedRounds(), "🫳🔫", "not if i see you first", "Panopticon Prime"), + Rig(new StarvedReactor(), "Spectra", "not if i see you first", "Panopticon Prime"), + Rig(new WeaponFeel(), "ChargeBlast SG", "🫳🔫", "FastBlast+-", "not if i see you first", "Panopticon Prime"), }; - private static object[] Rig(Scenario scenario, params (string design, string brand)[] pins) => new object[] { scenario, pins }; + private static object[] Rig(Scenario scenario, params string[] brands) => new object[] { scenario, brands }; [Theory] [MemberData(nameof(PinnedRigs))] - public void TheRigsKeepTheirBrands(Scenario scenario, (string design, string brand)[] pins) + public void TheRigsKeepTheirBrands(Scenario scenario, string[] brands) { for (uint seed = 1; seed <= 8; seed++) { var (_, _, staged, failures) = Launch(new Scripted(false, scenario.Stage, seed * 7919, scenario.Generate)); Assert.Empty(failures); var ships = staged.Entities.Append(staged.Player).Where(ship => _items.GetData(ship.Hull).Name is "Longinus" or "Smoke Test Hull"); - foreach (var (design, brand) in pins) + foreach (var brand in brands) { - var brands = ships.SelectMany(ship => FittedBrands(ship, design)).Distinct().ToArray(); - Assert.True(brands.SequenceEqual(new[] { brand }), $"{scenario.Name} seed {seed}: {design} is built as [{string.Join(", ", brands)}], not {brand}"); + var product = _items.ItemData.GetAll().Single(candidate => candidate.Name == brand); + var design = _items.ItemData.Get(product.Design).Name; + var built = ships.SelectMany(ship => FittedBrands(ship, design)).Distinct().ToArray(); + Assert.True(built.SequenceEqual(new[] { brand }), $"{scenario.Name} seed {seed}: {design} is built as [{string.Join(", ", built)}], not {brand}"); } } } From d4cf8c82442f6a24f806ca141650029a3701325e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 02:51:08 +0200 Subject: [PATCH 21/23] RigBrandTests: a ship's cargo bays count as fitted gear Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/RigBrandTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/RigBrandTests.cs b/tests/Aetheria.Shared.Tests/RigBrandTests.cs index 901afaf71..17bb39c6b 100644 --- a/tests/Aetheria.Shared.Tests/RigBrandTests.cs +++ b/tests/Aetheria.Shared.Tests/RigBrandTests.cs @@ -9,7 +9,7 @@ public sealed partial class RunStartTests private string ProductOf(EquippableItem item) => _items.Brand(item).Product.Name; private IEnumerable FittedBrands(Entity entity, string design) => - entity.Equipment.Where(item => _items.GetData(item.EquippableItem).Name == design).Select(item => ProductOf(item.EquippableItem)); + entity.Equipment.Concat(entity.CargoBays).Where(item => _items.GetData(item.EquippableItem).Name == design).Select(item => ProductOf(item.EquippableItem)); // Every product a scenario's fits name. A rig whose fits name a design where a brand stood would draw its brand from // the stage's seed; so each scenario is staged under eight seeds, and the design of every pinned product must come out From 617e602208c7790957a335e6e3a6ac89ee6aac48 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 02:53:34 +0200 Subject: [PATCH 22/23] RigBrandTests: Arcs' turret is a fitted hull Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/RigBrandTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/RigBrandTests.cs b/tests/Aetheria.Shared.Tests/RigBrandTests.cs index 17bb39c6b..5c364fcd3 100644 --- a/tests/Aetheria.Shared.Tests/RigBrandTests.cs +++ b/tests/Aetheria.Shared.Tests/RigBrandTests.cs @@ -41,7 +41,7 @@ public sealed partial class RunStartTests { var (_, _, staged, failures) = Launch(new Scripted(false, scenario.Stage, seed * 7919, scenario.Generate)); Assert.Empty(failures); - var ships = staged.Entities.Append(staged.Player).Where(ship => _items.GetData(ship.Hull).Name is "Longinus" or "Smoke Test Hull"); + var ships = staged.Entities.Append(staged.Player).Where(ship => _items.GetData(ship.Hull).Name is "Longinus" or "Smoke Test Hull" or "Turret"); foreach (var brand in brands) { var product = _items.ItemData.GetAll().Single(candidate => candidate.Name == brand); From 7444f4a0c9e69ee44d78a07e7d549113b2b2c134 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 03:30:50 +0200 Subject: [PATCH 23/23] ProductChoiceTests: the nearer-maker bounds tight enough to fail a doubled distance penalty Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ProductChoiceTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs index 5546a58a5..888634f8e 100644 --- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs @@ -245,8 +245,8 @@ public sealed class ProductChoiceTests : IDisposable public void ANearerMakerIsFavored() { var rig = Build("lamp-z", "lamp-a"); - Assert.InRange(MakerDraws(rig, rig.OutEven, 0, "lamp-a", 300), 165, 235); - Assert.InRange(MakerDraws(rig, rig.OutEven, 1, "lamp-a", 300), 65, 135); + Assert.InRange(MakerDraws(rig, rig.OutEven, 0, "lamp-a", 300), 180, 220); + Assert.InRange(MakerDraws(rig, rig.OutEven, 1, "lamp-a", 300), 80, 120); } // A weight that read the allegiance alone would give 1 : .25 here, an 80% draw; the zone and allegiance together