From 54999a642b1645383551ede3e98df6e65d7b0d0d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 22:09:18 +0200 Subject: [PATCH] Roles backfill: AetherDb command and catalog role tests (CB-R1) AetherDb roles-backfill gives Autocannon, LRMM72, SRMM72, Mine Launcher, Large Drive (and Small Drive, which declared roles no stat read) live roles: each mapped flat stat becomes a 0.8x..1.2x range read through a Quality term naming its role, and every seller authors a ProductRole per role from its maker profile. MineLayerRoles is new; the maker profile table is hoisted so pd-gear and this command share it. ShippedCatalogRolesAreLive and RolesBackfillIsAuthored read the shipped catalog. The catalog blob lands in its own commit. Co-Authored-By: Claude Sonnet 5.5 --- .../Aetheria.Shared.Tests/CatalogRoleTests.cs | 126 +++++++++++++++ tools/AetherDb/Program.cs | 149 ++++++++++++++++-- 2 files changed, 265 insertions(+), 10 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/CatalogRoleTests.cs diff --git a/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs b/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs new file mode 100644 index 000000000..2bab3218d --- /dev/null +++ b/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +// Aetheria release, cut roles-backfill, rule CB-R1: a crafted design declares roles iff a stat varies with quality, +// every declared role is read by a non-flat stat, and every seller authors one ProductRole per role. Both tests read +// the shipped catalog through the real reader. +public sealed class CatalogRoleTests +{ + // Every weapon and every gear of a kind that carries per-part quality stats (the kinds roles-migrate keeps a role + // map for). Cargo bays, docking bays, control modules, tools and hulls carry no quality-reading stat. + private static readonly HardpointType[] QualityGearKinds = + { HardpointType.Radiator, HardpointType.Reactor, HardpointType.Sensors, HardpointType.Thruster }; + + private static IEnumerable StatsOf(EquippableItemData design) => + design.Behaviors.Where(b => b != null) + .SelectMany(b => b.GetType().GetFields().Where(f => f.FieldType == typeof(PerformanceStat)).Select(f => (PerformanceStat)f.GetValue(b))) + .Where(s => s?.Terms != null); + + private static PerformanceStat Stat(EquippableItemData design, string field) => + Assert.Single(design.Behaviors.SelectMany(b => b.GetType().GetFields().Where(f => f.Name == field && f.FieldType == typeof(PerformanceStat)).Select(f => (PerformanceStat)f.GetValue(b)))); + + private static string[] NamedQualityRoles(PerformanceStat stat) => + stat.Terms.Where(t => t.Source == StatSource.Quality && !string.IsNullOrEmpty(t.Role)).Select(t => t.Role).ToArray(); + + [Fact] + public void ShippedCatalogRolesAreLive() + { + var cache = RestoredHullsTests.OpenCatalog(); + var products = cache.GetAll().ToArray(); + var broken = new List(); + foreach (var design in cache.GetAll() + .Where(i => i is WeaponItemData || (i is GearData && QualityGearKinds.Contains(i.HardpointType))) + .OrderBy(i => i.Name, StringComparer.Ordinal)) + { + var varying = StatsOf(design).Where(s => s.Min != s.Max).ToArray(); + var read = varying.SelectMany(NamedQualityRoles).Distinct().OrderBy(r => r, StringComparer.Ordinal).ToArray(); + var declared = (design.Roles ?? new List()).Select(r => r.Name).OrderBy(r => r, StringComparer.Ordinal).ToArray(); + if (varying.Length == 0) broken.Add($"{design.Name}: every stat is flat"); + if (!read.SequenceEqual(declared)) + broken.Add($"{design.Name}: declares [{string.Join(", ", declared)}] but non-flat stats read [{string.Join(", ", read)}]"); + foreach (var product in products.Where(p => p.Design.Key.Equals(cache.RefOf(design).Key))) + { + var authored = (product.Roles ?? new List()).Select(r => r.Role).OrderBy(r => r, StringComparer.Ordinal).ToArray(); + if (!authored.SequenceEqual(declared)) + broken.Add($"{design.Name}: product \"{product.Name}\" authors [{string.Join(", ", authored)}], not each of [{string.Join(", ", declared)}] once"); + } + } + Assert.True(broken.Count == 0, string.Join("\n", broken)); + } + + // value, then the stat's expected Min and Max: a benefit rises with quality (0.8x to 1.2x), a cost falls (1.2x to 0.8x). + private static void AssertRange(EquippableItemData design, string field, float value, bool cost, string role) + { + var stat = Stat(design, field); + Assert.Equal(value * (cost ? 1.2f : .8f), stat.Min, 3); + Assert.Equal(value * (cost ? .8f : 1.2f), stat.Max, 3); + Assert.Contains(role, NamedQualityRoles(stat)); + } + + [Fact] + public void RolesBackfillIsAuthored() + { + var cache = RestoredHullsTests.OpenCatalog(); + EquippableItemData Design(string name) => Assert.Single(cache.GetAll(), i => i.Name == name); + string[] Roles(EquippableItemData d) => d.Roles.Select(r => r.Name).OrderBy(r => r, StringComparer.Ordinal).ToArray(); + + var autocannon = Design("Autocannon"); + Assert.Equal(new[] { "barrel", "feed mechanism" }, Roles(autocannon)); + AssertRange(autocannon, "Damage", 3f, false, "barrel"); + AssertRange(autocannon, "Range", 1000f, false, "barrel"); + + var lrm = Design("LRMM72"); + Assert.Equal(new[] { "guidance system", "thruster", "warhead" }, Roles(lrm)); + AssertRange(lrm, "Damage", 256f, false, "warhead"); + AssertRange(lrm, "Cooldown", 10f, true, "guidance system"); + + var srm = Design("SRMM72"); + Assert.Equal(new[] { "guidance system", "thruster", "warhead" }, Roles(srm)); + AssertRange(srm, "Damage", 128f, false, "warhead"); + AssertRange(srm, "Cooldown", 5f, true, "guidance system"); + + var mine = Design("Mine Launcher"); + Assert.Equal(new[] { "dispenser", "warhead" }, Roles(mine)); + AssertRange(mine, "Damage", 50f, false, "warhead"); + AssertRange(mine, "Cooldown", 2f, true, "dispenser"); + AssertRange(mine, "Spread", 5f, true, "dispenser"); + var layer = Assert.Single(mine.Behaviors.OfType()); + Assert.Equal(2f, layer.ArmingDelay); + Assert.Equal(2f, layer.FuseDelay); + Assert.Equal(30f, layer.Lifetime); + + var drive = Design("Large Drive"); + Assert.Equal(new[] { "injector", "nozzle" }, Roles(drive)); + AssertRange(drive, "Thrust", 250000f, false, "nozzle"); + AssertRange(drive, "Visibility", 100f, true, "nozzle"); + AssertRange(drive, "Heat", 2500f, true, "injector"); + + var smallDrive = Design("Small Drive"); + AssertRange(smallDrive, "Thrust", 75000f, false, "nozzle"); + + // 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), + }; + 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(); + Assert.NotEmpty(sellers); + foreach (var product in sellers) + { + var maker = cache.Get(product.Manufacturer); + var (mean, dev) = profiles[maker.ShortName]; + Assert.Equal(Roles(design), product.Roles.Select(r => r.Role).OrderBy(r => r, StringComparer.Ordinal).ToArray()); + Assert.All(product.Roles, r => + { + Assert.Equal(mean, r.Mean, 3); + Assert.Equal(dev, r.StandardDeviation, 3); + }); + } + } + Assert.Contains(cache.GetAll(), p => p.Design.Key.Equals(cache.RefOf(autocannon).Key) && cache.Get(p.Manufacturer).ShortName == "Pirates"); + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 6caed9ac5..2900ac4f1 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -42,11 +42,12 @@ public static class Program case "field-kinds": return FieldKindsCatalog(args.Contains("apply")); case "pd-gear": return PdGearCatalog(args.Contains("apply")); case "mine-launcher": return MineLauncherCatalog(args.Contains("apply")); + case "roles-backfill": return RolesBackfill(args.Contains("apply")); case "pirates-faction": Console.OutputEncoding = new System.Text.UTF8Encoding(false); return PiratesFaction(args.Contains("apply")); 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], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], pd-gear [apply], pirates-faction [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], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], roles-backfill [apply], pd-gear [apply], pirates-faction [apply]"); return 1; } } @@ -182,6 +183,15 @@ public static class Program ["Thrust"] = "thruster", ["MissileVelocity"] = "thruster", ["Velocity"] = "thruster", }; + // Warhead and dispenser: a mine layer is a launcher without guidance or thrust, so its stats split between the + // charge it lays and the mechanism that lays it. ArmingDelay, FuseDelay and Lifetime are timings, not stats. + private static readonly Dictionary MineLayerRoles = new Dictionary + { + ["Damage"] = "warhead", ["Penetration"] = "warhead", ["DamageSpread"] = "warhead", + ["Range"] = "dispenser", ["Cooldown"] = "dispenser", ["Spread"] = "dispenser", ["Velocity"] = "dispenser", + ["Energy"] = "dispenser", ["Heat"] = "dispenser", ["Visibility"] = "dispenser", + }; + private static readonly Dictionary RadiatorRoles = new Dictionary { ["Emissivity"] = "fins", ["WasteHeat"] = "fins", @@ -231,6 +241,17 @@ 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 + { + ["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), + ["DME"] = (.55f, .20f), ["Adrasteia"] = (.60f, .10f), + }; + + private static readonly (float Mean, float Dev) PirateProfile = (.45f, .22f); + private static string KindOf(EquippableItemData item) => item is HullData hull ? $"Hull/{hull.HullType}" : item is DockingBayData ? "DockingBay" @@ -1527,12 +1548,6 @@ public static class Program flakData.AmmoType = db.Cache.RefOf(ammunition); flakData.MagazineSize = 40; flakData.ReloadTime = 4f; Track(flakData, 20f); - var makers = 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), - ["DME"] = (.55f, .20f), ["Adrasteia"] = (.60f, .10f), - }; var products = new (WeaponItemData Design, string Name, string Maker, string Description)[] { (gun, "Waykeeper", "Lightsail", "Freight first, questions later. Keeps a hauler's paint unscratched through a full salvo."), @@ -1563,7 +1578,7 @@ public static class Program Console.WriteLine("\nNew products:"); foreach (var (design, name, maker, description) in products) { - var (mean, dev) = makers[maker]; + var (mean, dev) = MakerProfiles[maker]; Console.WriteLine($" {design.Name} -> \"{name}\" by {maker}, every role mean {mean:0.##} dev {dev:0.##}: {description}"); } @@ -1580,7 +1595,7 @@ public static class Program foreach (var design in new[] { gun, laser, flak }) keys[design] = batch.Upsert(typeof(WeaponItemData), design); foreach (var (design, name, maker, description) in products) { - var (mean, dev) = makers[maker]; + var (mean, dev) = MakerProfiles[maker]; batch.Upsert(typeof(FactionProductData), new FactionProductData { Name = name, @@ -1706,6 +1721,120 @@ public static class Program return 0; } + // Aetheria release, cut roles-backfill (rule CB-R1): designs that shipped with flat stats and no roles get the + // roles their kind's map names, and every seller authors a ProductRole per role. Each mapped stat becomes a range + // of value x (0.8, 1.2) that rises with quality for a benefit and falls for a cost, read through one Quality term + // naming its role (exponent: the mode of the kind's existing designs on that field, 1 when there is none; an + // existing Quality term keeps its exponent). Zero stats stay flat. Small Drive joined the set because it declares + // roles that no non-flat stat reads. Idempotent: a design with no flat mapped stat is left alone, a product with a + // role is left alone. Dry run unless passed "apply". + private static readonly string[] BackfillDesigns = { "Autocannon", "LRMM72", "SRMM72", "Mine Launcher", "Large Drive", "Small Drive" }; + + private static readonly HashSet CostStats = new HashSet(StringComparer.Ordinal) + { + "Cooldown", "Spread", "Heat", "Visibility", "Energy", "EnergyUsage", "ChargeTime", "ChargeEnergy", "ChargeHeat", + "SensorImpact", "DirectionImpact", "Decay", + }; + + private static Dictionary BackfillMapFor(EquippableItemData item) => + item.Behaviors.OfType().Any() ? MineLayerRoles : RoleMapFor(item, KindOf(item)); + + private static IEnumerable StatsOf(EquippableItemData item) => + (item.Behaviors ?? new List()).Where(b => b != null) + .SelectMany(b => b.GetType().GetFields().Where(f => f.FieldType == typeof(PerformanceStat)).Select(f => f.GetValue(b) as PerformanceStat)) + .Where(s => s?.Terms != null); + + private static int RolesBackfill(bool apply) + { + var db = AetherDb.Open(catalogWritable: apply); + var items = db.Cache.GetAll().ToArray(); + var products = db.Cache.GetAll().ToArray(); + var targets = BackfillDesigns.Select(name => items.Where(i => i.Name == name).ToArray()) + .Select((found, i) => found.Length == 1 ? found[0] : throw new InvalidOperationException( + $"Expected exactly one design named \"{BackfillDesigns[i]}\", found {found.Length}.")).ToArray(); + + float ExponentFor(string kind, string field) + { + var precedent = items.Except(targets).Where(i => KindOf(i) == kind && i.Roles != null && i.Roles.Count > 0) + .SelectMany(i => i.Behaviors ?? new List()).Where(b => b != null) + .SelectMany(b => b.GetType().GetFields().Where(f => f.Name == field && f.FieldType == typeof(PerformanceStat)).Select(f => f.GetValue(b) as PerformanceStat)) + .Where(s => s?.Terms != null && s.Min != s.Max) + .SelectMany(s => s.Terms.Where(t => t.Source == StatSource.Quality && !string.IsNullOrEmpty(t.Role)).Select(t => t.Exponent)) + .GroupBy(e => e).OrderByDescending(g => g.Count()).ThenBy(g => g.Key).Select(g => g.Key).ToArray(); + return precedent.Length > 0 ? precedent[0] : 1f; + } + + var changedDesigns = new List<(EquippableItemData Document, CultRecordKey Key)>(); + foreach (var design in targets) + { + var kind = KindOf(design); + var map = BackfillMapFor(design) ?? throw new InvalidOperationException($"\"{design.Name}\" has no role map."); + var lines = new List(); + foreach (var behavior in design.Behaviors.Where(b => b != null)) + foreach (var field in behavior.GetType().GetFields().Where(f => f.FieldType == typeof(PerformanceStat))) + { + if (!map.TryGetValue(field.Name, out var role) || !(field.GetValue(behavior) is PerformanceStat stat)) continue; + if (stat.Min != stat.Max || stat.Min == 0f || float.IsInfinity(stat.Min)) continue; + var value = stat.Min; + (stat.Min, stat.Max) = CostStats.Contains(field.Name) ? (value * 1.2f, value * .8f) : (value * .8f, value * 1.2f); + stat.Terms ??= new List(); + var quality = stat.Terms.FirstOrDefault(t => t.Source == StatSource.Quality); + if (quality == null) + { + quality = new StatTerm { Source = StatSource.Quality, Exponent = ExponentFor(kind, field.Name) }; + stat.Terms.Add(quality); + } + quality.Role = role; + lines.Add($" {behavior.GetType().Name}.{field.Name,-16} {value:0.####} -> {stat.Min:0.####}..{stat.Max:0.####} {role} ^{quality.Exponent:0.##}"); + } + if (lines.Count == 0) continue; + design.Roles = StatsOf(design).Where(s => s.Min != s.Max) + .SelectMany(s => s.Terms.Where(t => t.Source == StatSource.Quality && !string.IsNullOrEmpty(t.Role)).Select(t => t.Role)) + .Distinct().OrderBy(r => r, StringComparer.Ordinal).Select(r => new ItemRole { Name = r }).ToList(); + Console.WriteLine($" {design.Name} ({kind}) roles [{string.Join(", ", design.Roles.Select(r => r.Name))}]"); + foreach (var line in lines) Console.WriteLine(line); + changedDesigns.Add((design, db.Cache.RefOf(design).Key)); + } + + var changedProducts = new List<(FactionProductData Document, CultRecordKey Key)>(); + foreach (var design in targets) + foreach (var product in products.Where(p => p.Design.Key.Equals(db.Cache.RefOf(design).Key)).OrderBy(p => p.Name, StringComparer.Ordinal)) + { + var maker = db.Cache.Get(product.Manufacturer); + var profile = maker?.Name == "Pirates" ? PirateProfile + : maker != null && MakerProfiles.TryGetValue(maker.ShortName, out var found) ? found + : throw new InvalidOperationException($"No maker profile for the seller of \"{design.Name}\" ({maker?.ShortName ?? "no maker"})."); + product.Roles ??= new List(); + var missing = design.Roles.Select(r => r.Name).Where(r => product.Roles.All(p => p.Role != r)).ToArray(); + if (missing.Length == 0) continue; + foreach (var role in missing) + product.Roles.Add(new ProductRole { Role = role, Mean = profile.Mean, StandardDeviation = profile.Dev }); + Console.WriteLine($" {product.Name,-28} ({design.Name}, {maker.ShortName}) {string.Join(", ", missing)} at {profile.Mean:0.00}/{profile.Dev:0.00}"); + changedProducts.Add((product, db.Cache.RefOf(product).Key)); + } + + if (changedDesigns.Count == 0 && changedProducts.Count == 0) + { + Console.WriteLine("Roles backfill is already authored; nothing to do."); + return 0; + } + Console.WriteLine($"\n{changedDesigns.Count} design(s) and {changedProducts.Count} product(s) to author."); + if (!apply) + { + Console.WriteLine("Dry run. Pass \"apply\" to land them."); + return 0; + } + foreach (var (document, _) in changedDesigns) CultRecordRefs.Validate(document); + foreach (var (document, _) in changedProducts) CultRecordRefs.Validate(document); + db.Cache.Commit(batch => + { + foreach (var (document, key) in changedDesigns) batch.Upsert(document is WeaponItemData ? typeof(WeaponItemData) : typeof(GearData), document, key); + foreach (var (document, key) in changedProducts) batch.Upsert(typeof(FactionProductData), document, key); + }); + Console.WriteLine($"Landed {changedDesigns.Count} design(s) and {changedProducts.Count} product(s) in Aetheria.cc"); + return 0; + } + // Aetheria release, cut pirates-record (rulings pirates-record-content, catalog-grows-generic-designs-branded-products): // the Pirates faction and eight rebrands of other makers' designs, named in emoji. The Pirates own no design: their range // is what they took, so every product points at a design someone else also makes. Each product carries one ProductRole per @@ -1765,7 +1894,7 @@ public static class Program { var design = DesignByName(r.Design); return (Design: design, r.Name, r.Description, - Roles: (design.Roles ?? new List()).Select(role => new ProductRole { Role = role.Name, Mean = .45f, StandardDeviation = .22f }).ToList()); + Roles: (design.Roles ?? new List()).Select(role => new ProductRole { Role = role.Name, Mean = PirateProfile.Mean, StandardDeviation = PirateProfile.Dev }).ToList()); }).ToArray(); Console.WriteLine($"New faction \"{pirates.Name}\": influence {pirates.InfluenceDistance}, names \"{nameFile.Name}\", allegiance 1.0 to {pirates.Allegiance.Count} factions.");