From e71667aab107c69ef5d01bfb026668e626974751 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 18:25:46 +0200 Subject: [PATCH] AetherDb loadout-commit; factions and loadout show the derived boss Loadout loadout-commit resolves every name exactly, test-materializes, then Loadouts.Commit. factions prints BossOf's Loadout (none / NO LOADOUT); loadout no longer generates a Boss hull. Co-Authored-By: Claude Sonnet 5.5 --- tools/AetherDb/LoadoutCommitCommand.cs | 124 +++++++++++++++++++++++++ tools/AetherDb/Program.cs | 22 +++-- 2 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 tools/AetherDb/LoadoutCommitCommand.cs diff --git a/tools/AetherDb/LoadoutCommitCommand.cs b/tools/AetherDb/LoadoutCommitCommand.cs new file mode 100644 index 000000000..1237e834a --- /dev/null +++ b/tools/AetherDb/LoadoutCommitCommand.cs @@ -0,0 +1,124 @@ +/* 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.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; + +// loadout-commit [,,,[@]]... [--group ,...]... [--replace] +// (aetheria-release, cut boss-hull-derived; ruling boss-loadouts-agent-authored): writes one Loadout record into +// GameData/Aetheria.cc through Loadouts.Commit, the only writer. Every name is resolved exactly (one hull product, one design per +// slot, one product where a slot names one, a rotation by its ItemRotation name), then the loadout is built once on a throwaway +// ItemManager with Loadouts.Materialize, so a slot that does not fit or a weapon group that names a non-weapon is refused here. +// Any refusal happens before Commit: the catalog stays byte-identical. A refusal names the field and the slot, never the value +// it was given. +public static class LoadoutCommitCommand +{ + private const string Usage = "usage: loadout-commit [,,,[@]]... [--group ,...]... [--replace]"; + + public static int Run(string[] args, string root = null) + { + try + { + return Commit(args, root); + } + catch (Exception e) when (e is InvalidOperationException or ArgumentException or FormatException) + { + Console.WriteLine($"refused: {e.Message}"); + return 1; + } + } + + private static int Commit(string[] args, string root) + { + var replace = false; + var groups = new List(); + var positional = new List(); + for (var i = 0; i < args.Length; i++) + { + if (args[i] == "--replace") replace = true; + else if (args[i] == "--group") + { + if (++i >= args.Length) throw new ArgumentException("--group needs a list of slot indices"); + groups.Add(args[i].Split(',').Select(part => int.TryParse(part, out var index) ? index : throw new FormatException($"group {groups.Count}: an index is not a number")).ToArray()); + } + else positional.Add(args[i]); + } + + if (positional.Count < 2) + { + Console.WriteLine(Usage); + return 1; + } + + var db = AetherDb.Open(root: root); + var catalogPath = Path.Combine(db.Root, "GameData", "Aetheria.cc"); + var loadout = Build(db.Cache, positional[0], positional[1], positional.Skip(2).ToArray(), groups); + + var failures = new List(); + var settings = AuthoredSettings.Load(db.Root).Read("GameplaySettings"); + var itemManager = new ItemManager(db.Cache, new ProvenanceLedger(), settings, _ => { }); + var entity = Loadouts.Materialize(itemManager, null, loadout, design => ProductsOf(db.Cache, design).FirstOrDefault(), failures); + if (entity == null) throw new InvalidOperationException($"the loadout cannot be built: {string.Join("; ", failures)}"); + + var hull = db.Cache.Get(loadout.Hull); + var product = db.Cache.Get(loadout.HullProduct); + var slots = loadout.Slots.Count; + (db.Cache as IDisposable)?.Dispose(); + + if (!Loadouts.Commit(catalogPath, loadout, replace)) + throw new InvalidOperationException("the catalog changed since it was read; nothing was written"); + Console.WriteLine($"{loadout.Name}: committed, hull {hull.Name} ({product.Name}), {slots} slots"); + return 0; + } + + // Validation only: a design-only slot is built at its first product by Name, so the throwaway build proves the fit. + private static IEnumerable ProductsOf(CultCache cache, CraftedItemData design) + { + var key = cache.RefOf(design).Key; + return cache.GetAll().Where(product => product.Design.Key.Equals(key)).OrderBy(product => product.Name, StringComparer.Ordinal); + } + + private static Loadout Build(CultCache cache, string name, string hullProductName, string[] slotArgs, List groups) + { + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("the loadout name is empty"); + var hullProduct = ExactlyOne(cache.GetAll().Where(product => product.Name == hullProductName).ToArray(), "hull product"); + var hull = cache.Get(hullProduct.Design) as HullData ?? throw new InvalidOperationException("hull product: its design is not a hull"); + var slots = new List(); + for (var i = 0; i < slotArgs.Length; i++) + { + var where = $"slot {i}"; + var parts = slotArgs[i].Split(',', 4); + if (parts.Length != 4 || !int.TryParse(parts[0], out var x) || !int.TryParse(parts[1], out var y)) + throw new FormatException($"{where}: expected ,,,[@]"); + if (!Enum.TryParse(parts[2], out var rotation) || !Enum.IsDefined(typeof(ItemRotation), rotation) || int.TryParse(parts[2], out _)) + throw new FormatException($"{where}: rotation is not an ItemRotation name"); + var at = parts[3].IndexOf('@'); + var designName = at < 0 ? parts[3] : parts[3][..at]; + var design = ExactlyOne(cache.GetAll().Where(d => d.Name == designName).ToArray(), $"{where} design"); + var slot = new LoadoutSlot { Position = new int2(x, y), Rotation = rotation, Design = cache.RefOf(design) }; + if (at >= 0) + { + var productName = parts[3][(at + 1)..]; + slot.Product = cache.RefOf(ExactlyOne(cache.GetAll().Where(p => p.Name == productName).ToArray(), $"{where} product")); + } + slots.Add(slot); + } + + return new Loadout + { + Name = name, + Hull = cache.RefOf(hull), + HullProduct = cache.RefOf(hullProduct), + Slots = slots, + WeaponGroups = groups.Count == 0 ? null : groups.ToArray() + }; + } + + private static T ExactlyOne(T[] found, string field) => + found.Length == 1 ? found[0] : throw new InvalidOperationException(found.Length == 0 ? $"{field}: no record has that name" : $"{field}: more than one record has that name"); +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 5f2324471..baf782546 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -26,6 +26,7 @@ public static class Program case "station-fit": return StationFit(); case "hardpoint-fit": return HardpointFit(); case "loadout": return Loadout(args.Skip(1).ToArray()); + case "loadout-commit": return LoadoutCommitCommand.Run(args.Skip(1).ToArray()); case "save": return Save(); case "factions": return Factions(); case "dangling": return Dangling(args.Skip(1).ToArray()); @@ -63,7 +64,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], loadout-commit [,,,[@]]... [--group ,...]... [--replace], 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]"); return 1; } } @@ -695,18 +696,18 @@ public static class Program } // Each faction's generation-critical links and the size of its curated place list. An empty list is not broken: - // Galaxy.NamePlaces falls back to catalogue designations for a faction without one. The boss column is derived: - // the Boss hulls the faction makes (LoadoutGenerator.BossHullsOf), never a field on the faction. + // Galaxy.NamePlaces falls back to catalogue designations for a faction without one. The boss column is derived + // through Loadouts.BossOf, never a field on the faction, and is display only. private static int Factions() { var db = AetherDb.Open(); var products = db.Cache.GetAll().ToArray(); - Console.WriteLine($"{"faction",-26} {"short",-13} {"places",-22} {"boss hull",-14} {"influence",-9} {"doctrine",-10} products"); + Console.WriteLine($"{"faction",-26} {"short",-13} {"places",-22} {"boss",-14} {"influence",-9} {"doctrine",-10} products"); foreach (var faction in db.Cache.GetAll().OrderBy(f => f.Name)) { var key = db.Cache.RefOf(faction).Key; - var bosses = LoadoutGenerator.BossHullsOf(db.Cache, faction); - var boss = bosses.Length == 0 ? "none" : string.Join(',', bosses.Select(b => b.hull.Name)); + var bossLoadout = Loadouts.BossOf(db.Cache, faction, out var refusal); + var boss = bossLoadout?.Name ?? (refusal == BossRefusal.NoLoadout ? "NO LOADOUT" : "none"); Console.WriteLine($"{faction.Name,-26} {faction.ShortName,-13} {faction.Places.Length,-22} {boss,-14} {faction.InfluenceDistance,-9} {(faction.Doctrine == null ? "none" : faction.Doctrine.EngageOn.ToString()),-10} " + products.Count(p => p.Manufacturer.Key.Equals(key))); } @@ -804,6 +805,15 @@ public static class Program var generator = new LoadoutGenerator(ref random, itemManager, null, null, null, .5f); log.Clear(); string outcome; + if (hull.Boss && hull.HullType == HullType.Ship) + { + // A boss hull is never generated: it flies the Loadout authored on one of its products. + var productKeys = db.Cache.GetAll().Where(p => db.Cache.Get(p.Design) == hull).Select(p => db.Cache.RefOf(p).Key).ToArray(); + var authored = db.Cache.GetAll().Where(l => l.HullProduct.IsSet() && productKeys.Contains(l.HullProduct.Key)).Select(l => l.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray(); + if (authored.Length == 0) failures++; + Console.WriteLine($"{hull.Name,-12} ({hull.HullType,-7}) {(authored.Length == 0 ? "NO LOADOUT (boss hull: author one with loadout-commit)" : "boss loadouts: " + string.Join(", ", authored))}"); + continue; + } try { var pack = hull.HullType switch