From 6768cdcb954d0ff018d82b70eebf58f1c0588d2c Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:08:56 +0200
Subject: [PATCH] product-lines tiers: authors the six bins from a table of
(line, bin, floor, text)
A bin gets its line's design and maker, Bin = { Line, Floor }, and no roles or assembly. Resolve-then-write
as the other lines do; a replay is a no-op. The catalog is not yet applied.
Co-Authored-By: Claude Sonnet 5.5
---
.../ProductTiersCommandTests.cs | 177 ++++++++++++++++++
tools/AetherDb/ProductLinesCommand.cs | 88 ++++++++-
tools/AetherDb/Program.cs | 2 +-
3 files changed, 263 insertions(+), 4 deletions(-)
create mode 100644 tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs
diff --git a/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs b/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs
new file mode 100644
index 000000000..856cca7dc
--- /dev/null
+++ b/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs
@@ -0,0 +1,177 @@
+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 tiers command (cut product-tiers; rulings maker-design-tiers-allowed, bin-share-by-floor), run on a
+// scratch copy of the shipped catalog with the table's bins removed from it. A standalone class: a tier row is not a Row.
+public sealed class ProductTiersCommandTests : IDisposable
+{
+ private static readonly string[] Apply = { "tiers", "apply" };
+ private static readonly string[] DryRun = { "tiers" };
+
+ private readonly string _root = Path.Combine(Path.GetTempPath(), "product-tiers-command-" + Guid.NewGuid().ToString("N"));
+ private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc");
+ private readonly string[] _names = ProductLinesCommand.TierTable().Select(r => r.Name).ToArray();
+
+ public ProductTiersCommandTests()
+ {
+ 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();
+ foreach (var bin in cache.GetAll().Where(p => _names.Contains(p.Name)).ToArray()) Assert.True(cache.Remove(cache.RefOf(bin).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 Dictionary Records() =>
+ CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(CatalogPath)).Records.ToDictionary(r => r.Key, StringComparer.Ordinal);
+
+ private FactionProductData[] Bins()
+ {
+ using var cache = Open();
+ return cache.GetAll().Where(p => _names.Contains(p.Name)).ToArray();
+ }
+
+ // Edits the scratch catalog before a refusal case; the bins the table authors stay out of it.
+ private void Edit(Action edit)
+ {
+ using var cache = Open();
+ edit(cache);
+ cache.FlushAsync().Wait();
+ }
+
+ // The command refuses the row with a message naming the rule, and the catalog's bytes are as they were.
+ private void Refused(ProductLinesCommand.TierRow row, string phrase)
+ {
+ var before = File.ReadAllBytes(CatalogPath);
+ var error = Assert.Throws(() => ProductLinesCommand.RunTiers(new[] { row }, true, _root));
+ Assert.Contains(phrase, error.Message);
+ Assert.Equal(before, File.ReadAllBytes(CatalogPath));
+ }
+
+ [Fact]
+ public void A_dry_run_writes_nothing_and_apply_lands_the_table_once_and_replays_as_a_no_op()
+ {
+ var before = File.ReadAllBytes(CatalogPath);
+ Assert.Empty(Bins());
+
+ Assert.Equal(0, ProductLinesCommand.Run(DryRun, _root));
+ Assert.Equal(before, File.ReadAllBytes(CatalogPath));
+
+ Assert.Equal(0, ProductLinesCommand.Run(Apply, _root));
+ var landed = File.ReadAllBytes(CatalogPath);
+ Assert.NotEqual(before, landed);
+ Assert.Equal(6, Bins().Length);
+
+ Assert.Equal(0, ProductLinesCommand.Run(Apply, _root));
+ Assert.Equal(landed, File.ReadAllBytes(CatalogPath));
+ Assert.Equal(0, ProductLinesCommand.Run(DryRun, _root));
+ Assert.Equal(landed, File.ReadAllBytes(CatalogPath));
+ }
+
+ [Fact]
+ public void Each_bin_is_its_lines_design_and_maker_at_its_floor_with_nothing_of_its_own()
+ {
+ Assert.Equal(0, ProductLinesCommand.Run(Apply, _root));
+ using var cache = Open();
+ foreach (var row in ProductLinesCommand.TierTable())
+ {
+ var bin = Assert.Single(cache.GetAll(), p => p.Name == row.Name);
+ var line = Assert.Single(cache.GetAll(), p => p.Name == row.Line);
+ Assert.Equal(cache.RefOf(line).Key, bin.Bin.Line.Key);
+ Assert.Equal(line.Design.Key, bin.Design.Key);
+ Assert.Equal(line.Manufacturer.Key, bin.Manufacturer.Key);
+ Assert.Equal(row.Floor, bin.Bin.Floor);
+ Assert.Equal(row.Description, bin.Description);
+ Assert.Empty(bin.Roles);
+ Assert.Null(bin.Assembly);
+ Assert.Null(line.Bin);
+ }
+ }
+
+ [Fact]
+ public void Apply_writes_only_the_records_it_authors()
+ {
+ var before = Records();
+ Assert.Equal(0, ProductLinesCommand.Run(Apply, _root));
+ var after = Records();
+ Assert.Equal(before.Count + 6, 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.StoredAt, kept.StoredAt);
+ Assert.True(record.Payload.AsSpan().SequenceEqual(kept.Payload), "a record the command did not author has changed bytes");
+ }
+ }
+
+ [Fact]
+ public void A_bin_already_authored_at_another_floor_is_refused()
+ {
+ Assert.Equal(0, ProductLinesCommand.Run(Apply, _root));
+ Refused(new ProductLinesCommand.TierRow("Sparrow", "Goldcrest", .5f, "x"), "already belongs");
+ }
+
+ [Fact]
+ public void A_line_the_catalog_does_not_hold_is_refused()
+ {
+ Refused(new ProductLinesCommand.TierRow("No Such Line", "Fresh Bin", 0f, "x"), "found 0");
+ }
+
+ [Fact]
+ public void An_ambiguous_line_is_refused()
+ {
+ Edit(cache =>
+ {
+ var sparrow = cache.GetAll().Single(p => p.Name == "Sparrow");
+ cache.Upsert(new FactionProductData { Name = "Sparrow", Design = sparrow.Design, Manufacturer = sparrow.Manufacturer });
+ });
+ Refused(new ProductLinesCommand.TierRow("Sparrow", "Fresh Bin", 0f, "x"), "found 2");
+ }
+
+ [Fact]
+ public void A_line_that_is_itself_a_bin_is_refused()
+ {
+ Assert.Equal(0, ProductLinesCommand.Run(Apply, _root));
+ Refused(new ProductLinesCommand.TierRow("Goldcrest", "Fresh Bin", 0f, "x"), "itself a bin");
+ }
+
+ [Theory]
+ [InlineData(-.1f)]
+ [InlineData(float.NaN)]
+ [InlineData(float.PositiveInfinity)]
+ public void A_floor_that_is_negative_or_not_finite_is_refused(float floor)
+ {
+ Refused(new ProductLinesCommand.TierRow("Sparrow", "Fresh Bin", floor, "x"), "finite number");
+ }
+
+ [Theory]
+ [InlineData("Spotlight")]
+ [InlineData("SPOTLIGHT")]
+ public void A_bin_name_held_by_another_product_is_refused_ignoring_case(string name)
+ {
+ Refused(new ProductLinesCommand.TierRow("Sparrow", name, 0f, "x"), "already belongs");
+ }
+
+ [Fact]
+ public void A_bin_name_that_is_a_designs_is_refused()
+ {
+ string design;
+ using (var cache = Open()) design = cache.GetAll().First().Name;
+ Refused(new ProductLinesCommand.TierRow("Sparrow", design.ToUpperInvariant(), 0f, "x"), "name of a design");
+ }
+}
diff --git a/tools/AetherDb/ProductLinesCommand.cs b/tools/AetherDb/ProductLinesCommand.cs
index 911f01ffb..5ee316a38 100644
--- a/tools/AetherDb/ProductLinesCommand.cs
+++ b/tools/AetherDb/ProductLinesCommand.cs
@@ -16,9 +16,13 @@ using GameCult.Caching;
// 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".
+// product-lines tiers [apply] (cut product-tiers; rulings maker-design-tiers-allowed, bin-share-by-floor): authors the bins of
+// those lines. A tier row is (line product name, bin name, floor, text); the bin gets Design and Manufacturer from its line,
+// Bin = { Line, Floor }, and no Roles or Assembly of its own: the mint derives both from the line.
public static class ProductLinesCommand
{
internal sealed record Row(string Design, string Name, string Maker, string Description);
+ internal sealed record TierRow(string Line, string Name, float Floor, string Description);
private static readonly Row[] Weapons =
{
@@ -85,6 +89,19 @@ public static class ProductLinesCommand
new("Industrial Thermostatic Heater", "WarmUp+-", "NiteLife", "Room temperature, whatever the room."),
};
+ // The bins of the shipped lines. The names and text are drafts the operator may rename.
+ private static readonly TierRow[] Tiers =
+ {
+ new("Sparrow", "Goldcrest", .67f, "Only the Sparrows that sang on the test bench. The rest still fly."),
+ new("Spotlight", "Spotlight Premiere", 0f, "Hand-picked for opening night."),
+ new("Rattler", "Rattler Select", 0f, "Pick of the batch. Still loud."),
+ new("SM-6 Marksman", "SM-6C Marksman Certified", 0f, "Individually certified to published tolerance.*"),
+ new("PowerHour 2.0", "PowerHour 2.0 VIP", .67f, "Skip the line. Same party, better parts."),
+ new("Juggernaut", "Juggernaut Guards", 0f, "The column's best drives go to the front of the column."),
+ };
+
+ internal static IReadOnlyList TierTable() => Tiers;
+
// Every line the command knows, by the word that follows "product-lines".
private static readonly Dictionary Lines = new() { ["weapons"] = Weapons, ["minor-powers"] = MinorPowers, ["gear"] = Gear };
@@ -93,16 +110,19 @@ public static class ProductLinesCommand
// 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"));
+ private static CultRecordKey KeyOf(Row row) => KeyOf(row.Name);
+
+ private static CultRecordKey KeyOf(string name) =>
+ new(new Guid(SHA256.HashData(Encoding.UTF8.GetBytes("product-line:" + name)).AsSpan(0, 16)).ToString("N"));
public static int Run(string[] args, string root = null)
{
var apply = args.Contains("apply");
var line = args.FirstOrDefault(a => a != "apply");
+ if (line == "tiers") return RunTiers(Tiers, apply, root);
if (line == null || !Lines.TryGetValue(line, out var rows))
{
- Console.WriteLine($"usage: product-lines {string.Join("|", Lines.Keys)} [apply]");
+ Console.WriteLine($"usage: product-lines {string.Join("|", Lines.Keys)}|tiers [apply]");
return 1;
}
@@ -178,4 +198,66 @@ public static class ProductLinesCommand
Console.WriteLine($"\nAuthored {missing.Length} products in Aetheria.cc");
return 0;
}
+
+ // The tiers line has Run's shape: resolve every row first, so a refusal leaves the catalog untouched, then land the missing
+ // bins in one batch that expects each of their keys absent. A bin already in the catalog (this line, this floor) is skipped,
+ // so the same command is the first apply and the replay onto a merged catalog.
+ internal static int RunTiers(IReadOnlyList tiers, bool apply, string root)
+ {
+ var db = AetherDb.Open(catalogWritable: apply, root: root);
+ var designs = db.Cache.GetAll().ToArray();
+ var products = db.Cache.GetAll().ToArray();
+
+ var resolved = new List<(TierRow Row, FactionProductData Line, bool Present)>();
+ for (var i = 0; i < tiers.Count; i++)
+ {
+ var row = tiers[i];
+ var lines = products.Where(p => p.Name == row.Line).ToArray();
+ if (lines.Length != 1) throw new InvalidOperationException($"Tier row {i}: expected exactly one line product, found {lines.Length}.");
+ var parent = lines[0];
+ if (parent.Bin != null) throw new InvalidOperationException($"Tier row {i}: the line is itself a bin.");
+ if (!float.IsFinite(row.Floor) || row.Floor < 0) throw new InvalidOperationException($"Tier row {i}: the floor is not a finite number of at least 0.");
+
+ 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].Bin != null &&
+ named[0].Bin.Line.Key.Equals(db.Cache.RefOf(parent).Key) && named[0].Bin.Floor == row.Floor;
+ if (named.Length != 0 && !present) throw new InvalidOperationException($"Tier row {i}: the bin name already belongs to another product.");
+ if (!present && designs.Any(d => string.Equals(d.Name, row.Name, StringComparison.OrdinalIgnoreCase)))
+ throw new InvalidOperationException($"Tier row {i}: the bin name is the name of a design.");
+ resolved.Add((row, parent, present));
+ }
+
+ var missing = resolved.Where(r => !r.Present).ToArray();
+ if (missing.Length == 0)
+ {
+ Console.WriteLine("The tiers are already authored; nothing to do.");
+ return 0;
+ }
+
+ Console.WriteLine("Product tiers:");
+ foreach (var (row, parent, present) in resolved)
+ Console.WriteLine($" {(present ? "present" : "new ")} {row.Line} -> \"{row.Name}\" by {db.Cache.Get(parent.Manufacturer)?.ShortName}, floor {row.Floor:0.##}: {row.Description}");
+ if (!apply)
+ {
+ Console.WriteLine($"\nDry run. Pass \"apply\" to author {missing.Length} bins.");
+ return 0;
+ }
+
+ if (!db.Cache.Commit(batch =>
+ {
+ foreach (var (row, _, _) in missing) batch.Expect(KeyOf(row.Name), null);
+ foreach (var (row, parent, _) in missing)
+ batch.Upsert(typeof(FactionProductData), new FactionProductData
+ {
+ Name = row.Name,
+ Description = row.Description,
+ Design = parent.Design,
+ Manufacturer = parent.Manufacturer,
+ Bin = new ProductBin { Line = db.Cache.RefOf(parent), Floor = row.Floor },
+ }, KeyOf(row.Name));
+ }))
+ throw new InvalidOperationException("The catalog changed while the tiers were being authored; nothing was written. Run it again.");
+ Console.WriteLine($"\nAuthored {missing.Length} bins in Aetheria.cc");
+ return 0;
+ }
}
diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs
index 46a812ba2..d88718817 100644
--- a/tools/AetherDb/Program.cs
+++ b/tools/AetherDb/Program.cs
@@ -63,7 +63,7 @@ public static class Program
return PlaceNamesCommand.Run(args.Skip(1).ToArray());
case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out);
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], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear [apply], doctrine-catalog [apply], assembly-from-maker [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], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply]");
return 1;
}
}