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 <noreply@anthropic.com>
This commit is contained in:
Nibu 2026-11-11 12:43:37 +00:00
commit 4c6dcfea73
5 changed files with 187 additions and 2 deletions

BIN
GameData/Aetheria.cc (Stored with Git LFS)

Binary file not shown.

View file

@ -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]

View file

@ -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<FactionProductData>().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<CraftedItemData>(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<FactionProductData>().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));
}
}

View file

@ -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);

View file

@ -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<FactionProductData>().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<FactionProductData>();
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;
}
}