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:
parent
2806b443c9
commit
6768cdcb95
3 changed files with 263 additions and 4 deletions
177
tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs
Normal file
177
tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue