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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-18 18:08:56 +02:00
commit 6768cdcb95
3 changed files with 263 additions and 4 deletions

View file

@ -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<FactionProductData>().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<string, CultPersistedRecord> Records() =>
CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(CatalogPath)).Records.ToDictionary(r => r.Key, StringComparer.Ordinal);
private FactionProductData[] Bins()
{
using var cache = Open();
return cache.GetAll<FactionProductData>().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<CultCache> 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<InvalidOperationException>(() => 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<FactionProductData>(), p => p.Name == row.Name);
var line = Assert.Single(cache.GetAll<FactionProductData>(), 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<FactionProductData>().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<CraftedItemData>().First().Name;
Refused(new ProductLinesCommand.TierRow("Sparrow", design.ToUpperInvariant(), 0f, "x"), "name of a design");
}
}

View file

@ -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<TierRow> TierTable() => Tiers;
// Every line the command knows, by the word that follows "product-lines".
private static readonly Dictionary<string, Row[]> 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<TierRow> tiers, bool apply, string root)
{
var db = AetherDb.Open(catalogWritable: apply, root: root);
var designs = db.Cache.GetAll<CraftedItemData>().ToArray();
var products = db.Cache.GetAll<FactionProductData>().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;
}
}

View file

@ -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 <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective <fraction>] [--end <multiplier>], 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 <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective <fraction>] [--end <multiplier>], 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;
}
}