AetherDb: product-lines command, with the weapons table

One command file owns the twenty second-maker weapon products; roles and quality come from Program.MakerProfiles, now internal. A maker already selling the design is not refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-16 18:46:12 +02:00
commit fa3c2ab4d3
2 changed files with 134 additions and 3 deletions

View file

@ -0,0 +1,130 @@
/* 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 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."),
};
// Every line the command knows, by the word that follows "product-lines".
private static readonly Dictionary<string, Row[]> Lines = new() { ["weapons"] = Weapons };
// 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];
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;
}
// ExpectUnchanged lands the batch 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.
if (!db.Cache.Commit(batch =>
{
batch.ExpectUnchanged();
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(),
});
}
}))
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;
}
}

View file

@ -55,8 +55,9 @@ public static class Program
case "sensor-stat-set": return SensorStatSet(args.Contains("apply"));
case "generic-designs": return GenericDesigns(args.Contains("apply"));
case "inspection-scanner": return InspectionScannerCatalog(args.Contains("apply"));
case "product-lines": return ProductLinesCommand.Run(args.Skip(1).ToArray());
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]");
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], product-lines weapons [apply]");
return 1;
}
}
@ -250,8 +251,8 @@ public static class Program
};
// One Mean / StandardDeviation per maker, applied to every role of a design the maker sells (catalog-growth
// addenda, "Maker profiles"); the Pirates are middling and inconsistent (pirates-record).
private static readonly Dictionary<string, (float Mean, float Dev)> MakerProfiles = new Dictionary<string, (float Mean, float Dev)>
// addenda, "Maker profiles"); the Pirates are middling and inconsistent (pirates-record). Read by ProductLinesCommand too.
internal static readonly Dictionary<string, (float Mean, float Dev)> MakerProfiles = new Dictionary<string, (float Mean, float Dev)>
{
["Zhestokost"] = (.50f, .08f), ["AU"] = (.50f, .12f), ["Lightsail"] = (.55f, .10f), ["R&D"] = (.60f, .15f),
["Lucent"] = (.60f, .15f), ["NiteLife"] = (.55f, .12f), ["Finch"] = (.65f, .20f), ["Alakrita"] = (.60f, .14f),