diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 200c93e22..675434aba 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2aa79c0adca4e50b608f1c7154ad847777d62af83b918b6de19eb881e8fdd7e9 -size 12698108 +oid sha256:56b4875145c5dc2552a94318a593d11bbcf35db4fabdd7cbfda71552bcedd834 +size 12698071 diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index d55bc6df6..5811508d3 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -2584,7 +2584,8 @@ public sealed class FireControlCut124Tests : IDisposable } // Every WeaponItemData in the shipped catalog has a null BlastRadius and a null Fuse, and the weapon - // schema's own migration report is CompatibleDrift with only slot 32 defaulted and no other slots touched. + // schema's own migration report is Exact. (Until the scenarios product-gap pass rewrote the catalog on + // 2026-09-30 it was CompatibleDrift with only slot 32 defaulted: the file predated BlastRadius and Fuse.) [Fact] public void ShippedCatalogIsNeutralOnBlastRadiusAndFuse() { @@ -2604,8 +2605,8 @@ public sealed class FireControlCut124Tests : IDisposable Assert.NotEmpty(reports); foreach (var report in reports) { - Assert.Equal(CultSchemaMigrationKind.CompatibleDrift, report.Kind); - Assert.Equal(new[] { 32 }, report.DefaultedMissingSlots); + Assert.Equal(CultSchemaMigrationKind.Exact, report.Kind); + Assert.Empty(report.DefaultedMissingSlots); Assert.Empty(report.IgnoredExtraSlots); } } diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 46aae9575..4635800a6 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -36,8 +36,9 @@ public static class Program case "targeting-catalog": return TargetingCatalog(args.Contains("apply")); case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply")); case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); + case "product-gap": return ProductGap(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]"); + 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], product-gap [apply]"); return 1; } } @@ -1474,4 +1475,84 @@ public static class Program Console.WriteLine($"\nLanded {precisionChanges.Length} Precision changes in Aetheria.cc"); return 0; } + + // Scenarios map, operator rulings 2026-09-30 ("the ten product-less designs"): after this, a design with no + // product is a Q3 scenario test design and nothing else. The five RTS-era industrial placeholders are deleted + // (nothing references them). The four weapons are a content gap and get a product each, from the maker of their + // nearest analogue: same hardpoint and firing behaviour, then nearest size. The Industrial Thermostatic Heater is + // generic gear every generated station carries; it gets a product from the maker of the cockpit whose thermostat + // it shares (Thermotoggle, EnergyDraw, Heat in one group), and the price and durability without which generation + // cannot offer it (RandomProducts skips a zero price) and it can never come online (zero durability). + private static int ProductGap(bool apply) + { + var db = AetherDb.Open(catalogWritable: apply); + var cache = db.Cache; + + T Design(string name) where T : EquippableItemData => + cache.GetAll().SingleOrDefault(d => d.Name == name) ?? throw new InvalidOperationException($"No design named \"{name}\"."); + Faction MakerOf(string productName) => + cache.Get(cache.GetAll().Single(p => p.Name == productName).Manufacturer); + Faction Named(string factionName) => cache.GetAll().Single(f => f.Name == factionName); + bool HasProduct(EquippableItemData design) => + cache.GetAll().Any(p => p.Design.Key.Equals(cache.RefOf(design).Key)); + + var industrial = new[] { "Assembly Line", "Deep Ore Extractor", "Surface Ore Extractor", "Refinery", "Shipyard" } + .Select(Design).ToArray(); + foreach (var design in industrial) + if (HasProduct(design)) throw new InvalidOperationException($"{design.Name} has a product; it is not a placeholder."); + + var heater = Design("Industrial Thermostatic Heater"); + var products = new (EquippableItemData Design, string Name, string Description, Faction Maker, string Analogue)[] + { + (Design("Autocannon"), "Autocannon", "", MakerOf("DeathCluster"), + "DeathCluster: Ballistic, 4 cells, AutoWeapon"), + (Design("LRMM72"), "LRMM72", "", MakerOf("GT 3K"), + "GT 3K: the only other unguided LauncherData"), + (Design("SRMM72"), "SRMM72", "", MakerOf("scorched void policy"), + "scorched void policy: GuidedWeapon launcher, nearest in size and price"), + (Design("Tractor Beam"), "Space Tractor", "It's a tractor. In space.", Named("Ewan Hart Inc"), + "none (no behaviours); Ewan Hart's wishlist Space Tractor, docs/content-batch-one.md section 10"), + (heater, "Industrial Thermostatic Heater", "", MakerOf("Cockpit 2x2"), + "Cockpit 2x2: the same thermostat behaviours"), + }; + foreach (var (design, _, _, _, _) in products) + if (HasProduct(design)) throw new InvalidOperationException($"{design.Name} already has a product."); + const int heaterPrice = 100000; // between Cockpit 2x2 (50,000) and Fire Control Array (150,000) + const float heaterDurability = 100; // Cockpit 2x2's + + Console.WriteLine("Delete (no product, no reference):"); + foreach (var design in industrial) Console.WriteLine($" {design.Name}"); + Console.WriteLine("Products:"); + foreach (var (design, name, _, maker, analogue) in products) + Console.WriteLine($" {design.Name,-32} -> {name,-32} by {maker.Name,-24} ({analogue})"); + Console.WriteLine($"Heater design: Price {heater.Price} -> {heaterPrice}, Durability {heater.Durability} -> {heaterDurability}"); + + if (!apply) + { + Console.WriteLine("\nDry run. Pass \"apply\" to land it."); + return 0; + } + + heater.Price = heaterPrice; + heater.Durability = heaterDurability; + CultRecordRefs.Validate(heater); + var industrialKeys = industrial.Select(design => cache.RefOf(design).Key).ToArray(); + var heaterKey = cache.RefOf(heater).Key; + var made = products.Select(entry => new FactionProductData + { + Name = entry.Name, + Description = entry.Description, + Design = new CultRecordRef(cache.RefOf(entry.Design).Key), + Manufacturer = cache.RefOf(entry.Maker), + Roles = (entry.Design.Roles ?? new List()).Select(role => new ProductRole { Role = role.Name }).ToList() + }).ToArray(); + cache.Commit(batch => + { + foreach (var key in industrialKeys) batch.Remove(key); + batch.Upsert(typeof(GearData), heater, heaterKey); + foreach (var product in made) batch.Upsert(typeof(FactionProductData), product, new CultRecordKey(Guid.NewGuid().ToString("N"))); + }); + Console.WriteLine($"\nLanded {industrial.Length} deletions, {made.Length} products and the heater's price and durability in Aetheria.cc"); + return 0; + } }