Aetheria/tools/AetherDb/ProductLinesCommand.cs
Meta Vens fbd8ab0915 product-lines minor-powers: ten products of Aya, Megiddo and Forge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:54:34 +02:00

154 lines
10 KiB
C#

/* 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.Linq;
using System.Security.Cryptography;
using System.Text;
using GameCult.Caching;
// product-lines <line> [apply] (aetheria-release, cut product-lines-weapons; rulings catalog-grows-generic-designs-branded-products,
// maker-design-tiers-allowed, miss-terris-belongs-to-emily-r3, pirates-record-content): authors the branded products of one
// product line into GameData/Aetheria.cc. A line is a table of (design, product, maker, text); each row becomes one
// FactionProductData with one ProductRole per role of its design, at the maker's profile in Program.MakerProfiles, so the table
// 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".
public static class ProductLinesCommand
{
internal sealed record Row(string Design, string Name, string Maker, string Description);
private static readonly Row[] Weapons =
{
new("Light Machine Gun", "Rattler", "AU", "Cheap, loud, and there when the rocks start moving."),
new("Slug Rifle", "SM-6 Marksman", "R&D", "Standardised, certified, guaranteed to hit the target.*"),
new("Long Slug Rifle", "Longshot", "Finch", "Light, quiet and dead accurate, when it feels like it."),
new("Autocannon", "Workhorse", "AU", "Feeds from anything, fires at anything."),
new("Cluster Cannon", "everything at once", "DME", "why aim/ when you can be everywhere"),
new("Charged Scattergun", "Grapeshot", "Zhestokost", "The old answer to boarders, sized for the void."),
new("Light Laser", "Sparrow", "Finch", "Barely registers on the scale, or on their sensors."),
new("Light Laser", "Spotlight", "Lucent", "Make sure they know who got them."),
new("Cell Blaster", "Strobe", "Lucent", "Fast, bright, and very hard to ignore."),
new("Long Laser", "Merlin", "Finch", "Hovers at the edge of range, then stoops."),
new("Heavy Beam Laser", "Afterglow", "NiteLife", "Keeps glowing long after the fight is over."),
new("Charged Blaster", "Encore", "Lucent", "One more, for the fans."),
new("Charged Scatter Blaster", "nobody leaves early", "DME", "doors are locked/ lights are low/ stay for the finale"),
new("Lightning Projector", "Static Shock", "NiteLife", "A jolt for every ship in reach."),
new("Guided Missile Launcher", "Homer", "Finch", "Finds its way, mostly."),
new("Long-Range Missile Rack", "Long Haul", "AU", "Delivers to the far side of the zone."),
new("Short-Range Missile Rack", "Short Fuse", "AU", "For problems already too close."),
new("Swarm Rocket Pod", "Confetti", "Lucent", "Every launch is a celebration."),
new("Incendiary Rocket Pod", "Trebuchet", "Zhestokost", "Siege fire for a column that does not stop."),
new("Mine Launcher", "Tripwire", "Zhestokost", "Closes the road behind the column."),
};
// The Aya Collective, Megiddo and Orbital Forge (cut minor-power-factions): ordinary makers, applied after minor-power-factions.
private static readonly Row[] MinorPowers =
{
new("Point Defense Gun", "Common Guard", "Aya", "Standard mount, removable feed box, a barrel you can swap at the bench. Drawings included."),
new("Point Defense Laser", "Porch Light", "Aya", "Keeps the dark off the doorstep. The manual is longer than the warranty, on purpose."),
new("Flak Gun", "Canopy", "Aya", "Shade for the whole convoy. Every part replaceable by the people it covers."),
new("Point Defense Gun", "Watchman", "Megiddo", "Holds the line it is given. Every round accounted for."),
new("Point Defense Laser", "Vigil", "Megiddo", "It does not sleep while the boundary stands."),
new("Flak Gun", "Perimeter", "Megiddo", "Nothing crosses that we have not seen."),
new("Light Machine Gun", "Pattern 12", "Forge", "Drawings ship with the gun. Build the next one yourself."),
new("Autocannon", "Pattern 30", "Forge", "Standard bore, standard feed, standard anywhere a Belt yard can cut steel."),
new("Slug Rifle", "Pattern 6", "Forge", "Licensed or not, it fits the same mount."),
new("Point Defense Gun", "Pattern 2", "Forge", "Close-in defence to an open standard. No license check, ever."),
};
// 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 };
// A line's rows, for the catalog tests that check the shipped records against the table the command authors from.
internal static IReadOnlyList<Row> Table(string line) => Lines[line];
// 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"));
public static int Run(string[] args, string root = null)
{
var apply = args.Contains("apply");
var line = args.FirstOrDefault(a => a != "apply");
if (line == null || !Lines.TryGetValue(line, out var rows))
{
Console.WriteLine($"usage: product-lines {string.Join("|", Lines.Keys)} [apply]");
return 1;
}
var db = AetherDb.Open(catalogWritable: apply, root: root);
var designs = db.Cache.GetAll<CraftedItemData>().ToArray();
var factions = db.Cache.GetAll<Faction>().ToArray();
var products = db.Cache.GetAll<FactionProductData>().ToArray();
// Resolve every row before anything is written: a missing or ambiguous design or maker, a name that belongs to another
// product or to a design, and a design with no roles each stop the command with the catalog untouched.
var resolved = new List<(Row Row, CraftedItemData Design, Faction Maker, bool Present)>();
foreach (var row in rows)
{
var design = designs.Where(d => d.Name == row.Design).ToArray();
if (design.Length != 1) throw new InvalidOperationException($"Expected exactly one design named \"{row.Design}\", found {design.Length}.");
var maker = factions.Where(f => f.ShortName == row.Maker).ToArray();
if (maker.Length != 1) throw new InvalidOperationException($"Expected exactly one faction with short name \"{row.Maker}\", found {maker.Length}.");
if (!Program.MakerProfiles.ContainsKey(row.Maker)) throw new InvalidOperationException($"No maker profile for \"{row.Maker}\".");
if (design[0].Roles.Count == 0) throw new InvalidOperationException($"The design \"{row.Design}\" has no roles.");
var designKey = db.Cache.RefOf(design[0]).Key;
var makerKey = db.Cache.RefOf(maker[0]).Key;
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].Design.Key.Equals(designKey) && named[0].Manufacturer.Key.Equals(makerKey);
if (named.Length != 0 && !present) throw new InvalidOperationException($"The product name \"{row.Name}\" already belongs to another product.");
if (!present && designs.Any(d => string.Equals(d.Name, row.Name, StringComparison.OrdinalIgnoreCase)))
throw new InvalidOperationException($"The product name \"{row.Name}\" is the name of a design.");
resolved.Add((row, design[0], maker[0], present));
}
var missing = resolved.Where(r => !r.Present).ToArray();
if (missing.Length == 0)
{
Console.WriteLine($"The {line} product line is already authored; nothing to do.");
return 0;
}
Console.WriteLine($"Product line {line}:");
foreach (var (row, design, _, present) in resolved)
{
var (mean, dev) = Program.MakerProfiles[row.Maker];
Console.WriteLine($" {(present ? "present" : "new ")} {row.Design} -> \"{row.Name}\" by {row.Maker}, mean {mean:0.##} dev {dev:0.##} on [{string.Join(", ", design.Roles.Select(r => r.Name))}]: {row.Description}");
}
if (!apply)
{
Console.WriteLine($"\nDry run. Pass \"apply\" to author {missing.Length} products.");
return 0;
}
// The batch lands onto the file as it is: every other record keeps the bytes it has, where a plain commit would
// re-encode the whole catalog through today's types. What it expects is that each key it authors is still absent, the
// one condition the command's own validation rests on. ExpectUnchanged would compare the file's schema ids with the
// loaded ones and refuse any catalog that holds a record written on an older schema, whatever the batch is.
if (!db.Cache.Commit(batch =>
{
foreach (var (row, _, _, _) in missing) batch.Expect(KeyOf(row), null);
foreach (var (row, design, maker, _) in missing)
{
var (mean, dev) = Program.MakerProfiles[row.Maker];
batch.Upsert(typeof(FactionProductData), new FactionProductData
{
Name = row.Name,
Description = row.Description,
Design = db.Cache.RefOf(design),
Manufacturer = db.Cache.RefOf(maker),
Roles = design.Roles.Select(r => new ProductRole { Role = r.Name, Mean = mean, StandardDeviation = dev }).ToList(),
}, KeyOf(row));
}
}))
throw new InvalidOperationException("The catalog changed while the product line was being authored; nothing was written. Run it again.");
Console.WriteLine($"\nAuthored {missing.Length} products in Aetheria.cc");
return 0;
}
}