From 4c6dcfea739f231acbbb914011b262f85afebb1b Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 12:43:37 +0000 Subject: [PATCH] AetherDb station-reactor-spread [apply]: station reactor products regain their sources' quality spread f458206c7 authored the five per-manufacturer station reactor products with bare ProductRole entries (the .5 / .15 default). The command copies each source product's Mean and StandardDeviation per role to a role still at the default, and was applied to the catalog. TwoAiDigest repinned for the catalog move. Co-Authored-By: Claude Sonnet 5.5 --- GameData/Aetheria.cc | 2 +- .../DeterminismLedgerTests.cs | 5 +- .../StationReactorSpreadTests.cs | 99 +++++++++++++++++++ tools/AetherDb/Program.cs | 1 + tools/AetherDb/StationReactorSpreadCommand.cs | 82 +++++++++++++++ 5 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/StationReactorSpreadTests.cs create mode 100644 tools/AetherDb/StationReactorSpreadCommand.cs diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index da60f2dd3..f4cfdc4a3 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:319c14830b174e4bd58fe849b1300f38d7724d91027e47a03a5c121741d38e51 +oid sha256:9edc8434524f43f751c89416479cf07fba03b424f266cb8bfc1870bd46eef6a7 size 220537 diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index 31b71dba0..c7b5cb79a 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -54,7 +54,10 @@ public sealed partial class RunStartTests // moved the digest and returned it to 52fbb10d, which master already measured before this cut. The authored-cargo and // ammunition-data blob of this cut moved nothing (52fbb10d measured with it merged in). Fixtures run ThrustScale=1 while play // uses 0.1, so this digest pins the fight's logic, not the shipped feel. - private const string TwoAiPinnedDigest = "52fbb10df2559829a11f3a38aae0783f3f1858b0499f6f46794a267a747adacb"; + // Repinned a ninth time, once (scenarios-batch4-minors): the catalog restores the five station-reactor products' quality + // spread (station-reactor-spread), and that blob alone moved the digest (the same tree with the old catalog measured 52fbb10d; + // the path from role spread to the fight was not traced). + private const string TwoAiPinnedDigest = "9c70a3ee234fb3b879f721a4a4abf13dd83bf56fd8733690d356b89b8a392258"; private const float TwoAiSeconds = 60f; [Fact] diff --git a/tests/Aetheria.Shared.Tests/StationReactorSpreadTests.cs b/tests/Aetheria.Shared.Tests/StationReactorSpreadTests.cs new file mode 100644 index 000000000..41161975a --- /dev/null +++ b/tests/Aetheria.Shared.Tests/StationReactorSpreadTests.cs @@ -0,0 +1,99 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using Xunit; + +// station-reactor-spread (aetheria-release follow-up scenarios-batch4-minors): a station reactor's product keeps the +// quality spread of the reactor product it was scaled from. The mint reads a product's Roles (Mean, StandardDeviation) per +// design role, and a role missing or left at the unauthored default draws a flat .5 / .15 instead of the maker's spread. +public sealed class StationReactorSpreadTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-station-spread-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public StationReactorSpreadTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + + // Read the way the mint does (ItemManager.Mint): the product's own Roles, matched by role name. + [Fact] + public void ShippedStationReactorProductsCarryTheirSourcesSpread() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var products = cache.GetAll().ToArray(); + FactionProductData Find(string name, string maker) => + products.Single(p => p.Name == name && cache.Get(p.Manufacturer)?.ShortName == maker); + + foreach (var (stationName, sourceName, maker) in StationReactorSpreadCommand.Rows) + { + var station = Find(stationName, maker); + var source = Find(sourceName, maker); + Assert.NotEmpty(station.Roles); + foreach (var role in station.Roles) + { + var from = source.Roles.Single(r => r.Role == role.Role); + Assert.Equal(from.Mean, role.Mean); + Assert.Equal(from.StandardDeviation, role.StandardDeviation); + } + Assert.Contains(station.Roles, r => r.Mean != .5f || r.StandardDeviation != .15f); + } + } + + private void Seed() + { + using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Upsert(new VerseGrammar { Revision = 1 }); + var maker = cache.Upsert(new Faction { Name = "Maker", ShortName = "M" }); + var gear = cache.Upsert(new GearData { Name = "Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10 }); + var design = new CultRecordRef(gear.Key); + cache.Upsert(new FactionProductData + { + Name = "Source", Design = design, Manufacturer = maker, + Roles = { new ProductRole { Role = "core", Mean = .7f, StandardDeviation = .05f }, new ProductRole { Role = "regulator", Mean = .3f, StandardDeviation = .2f } } + }); + cache.Upsert(new FactionProductData + { + Name = "Source Station", Design = design, Manufacturer = maker, + Roles = { new ProductRole { Role = "core" }, new ProductRole { Role = "regulator", Mean = .9f, StandardDeviation = .01f } } + }); + cache.FlushAsync().Wait(); + } + + private static readonly (string Station, string Source, string Maker)[] Fixture = { ("Source Station", "Source", "M") }; + + private string Run(bool apply) + { + using var cache = AetheriaStores.Open(Catalog, catalogWritable: apply); + var output = new StringWriter(); + Assert.Equal(0, StationReactorSpreadCommand.Run(cache, apply, output, Fixture)); + cache.FlushAsync().Wait(); + return output.ToString(); + } + + private ProductRole RoleOf(string role) + { + using var cache = AetheriaStores.Open(Catalog); + return cache.GetAll().Single(p => p.Name == "Source Station").Roles.Single(r => r.Role == role); + } + + // A role at the unauthored default takes the source's pair, a role tuned in Studio is kept, a dry run writes nothing, + // and a second apply has nothing to do. + [Fact] + public void CopiesOnlyUnauthoredRolesAndReplaysToNothing() + { + Seed(); + var before = File.ReadAllBytes(Catalog); + Assert.Contains("core", Run(apply: false)); + Assert.Equal(before, File.ReadAllBytes(Catalog)); + + Run(apply: true); + Assert.Equal(.7f, RoleOf("core").Mean, 3); + Assert.Equal(.05f, RoleOf("core").StandardDeviation, 3); + Assert.Equal(.9f, RoleOf("regulator").Mean, 3); + Assert.Equal(.01f, RoleOf("regulator").StandardDeviation, 3); + + var written = File.ReadAllBytes(Catalog); + Assert.Contains("No changes", Run(apply: true)); + Assert.Equal(written, File.ReadAllBytes(Catalog)); + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 5d3d18e93..2caaaed4b 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -59,6 +59,7 @@ public static class Program case "inspection-scanner": return InspectionScannerCatalog(args.Contains("apply")); case "product-lines": return ProductLinesCommand.Run(args.Skip(1).ToArray()); case "minor-power-factions": return MinorPowerFactionsCommand.Run(args.Skip(1).ToArray()); + case "station-reactor-spread": return StationReactorSpreadCommand.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out); case "assembly-from-maker": return AssemblyFromMakerCommand.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out); case "place-names": Console.OutputEncoding = new System.Text.UTF8Encoding(false); diff --git a/tools/AetherDb/StationReactorSpreadCommand.cs b/tools/AetherDb/StationReactorSpreadCommand.cs new file mode 100644 index 000000000..6f7064fb3 --- /dev/null +++ b/tools/AetherDb/StationReactorSpreadCommand.cs @@ -0,0 +1,82 @@ +/* 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.IO; +using System.Linq; +using GameCult.Caching; + +// station-reactor-spread [apply] (aetheria-release follow-up scenarios-batch4-minors): the per-manufacturer station reactors +// (f458206c7) were authored as scaled copies of each maker's largest reactor, but their products were authored with a bare +// ProductRole { Role } for each role, which is the .5 / .15 default, so they lost the source product's quality spread. This +// gives each station product the Mean and StandardDeviation the source product holds for the same role. It writes only a role +// still at the default pair, so a spread tuned in Studio survives every replay, and a second apply has nothing to do. Replays +// before assembly-from-maker (which derives from role means) when that command has an unauthored maker to fill. Dry run +// unless apply. +public static class StationReactorSpreadCommand +{ + // The role pair an unauthored ProductRole carries (ProductRole's field defaults). + private static readonly ProductRole Unauthored = new ProductRole(); + + // (station product, source product, maker short name): the source is the maker's product of the reactor the station + // design was scaled from. + public static readonly (string Station, string Source, string Maker)[] Rows = + { + ("Steadfast Station", "Steadfast", "R&D"), + ("Manhattan Station", "Manhattan", "Zhestokost"), + ("Notorious Station", "Notorious", "Lucent"), + ("Vulcan Station", "Vulcan", "AU"), + ("MoveOnPro Station", "MoveOnPro", "NiteLife"), + }; + + public static int Run(CultCache cache, bool apply, TextWriter output, (string Station, string Source, string Maker)[] rows = null) + { + var products = cache.GetAll().ToArray(); + FactionProductData Find(string name, string maker) + { + var found = products.Where(p => p.Name == name && cache.Get(p.Manufacturer)?.ShortName == maker).ToArray(); + if (found.Length != 1) throw new InvalidOperationException($"Station reactor row \"{name}\" by {maker}: {found.Length} products match."); + return found[0]; + } + + var changed = new List(); + foreach (var (stationName, sourceName, maker) in rows ?? Rows) + { + var station = Find(stationName, maker); + var source = Find(sourceName, maker); + foreach (var role in station.Roles) + { + var from = source.Roles.SingleOrDefault(r => r.Role == role.Role) + ?? throw new InvalidOperationException($"\"{sourceName}\" holds no \"{role.Role}\" role to copy to \"{stationName}\"."); + if (role.Mean != Unauthored.Mean || role.StandardDeviation != Unauthored.StandardDeviation) continue; + if (from.Mean == role.Mean && from.StandardDeviation == role.StandardDeviation) continue; + output.WriteLine($"{stationName} ({maker}) {role.Role}: {role.Mean:0.###}±{role.StandardDeviation:0.###} -> {from.Mean:0.###}±{from.StandardDeviation:0.###}"); + role.Mean = from.Mean; + role.StandardDeviation = from.StandardDeviation; + if (!changed.Contains(station)) changed.Add(station); + } + } + + if (changed.Count == 0) + { + output.WriteLine("No changes."); + return 0; + } + + if (!apply) + { + output.WriteLine($"\nDry run. Pass \"apply\" to write {changed.Count} products."); + return 0; + } + + foreach (var product in changed) CultRecordRefs.Validate(product); + cache.Commit(batch => + { + foreach (var product in changed) batch.Upsert(typeof(FactionProductData), product, cache.RefOf(product).Key); + }); + output.WriteLine($"\nWrote {changed.Count} products in Aetheria.cc"); + return 0; + } +}