diff --git a/Assets/Scripts/ServerShared/Corporations.cs b/Assets/Scripts/ServerShared/Corporations.cs index c242e3e42..7265941c9 100644 --- a/Assets/Scripts/ServerShared/Corporations.cs +++ b/Assets/Scripts/ServerShared/Corporations.cs @@ -39,8 +39,7 @@ public class Faction // Key 9 was the faction's name-file link, retired by ruling faction-curated-place-names; never reuse it. - [Inspectable, JsonProperty("bossHull"), Key(10)] - public CultRecordRef BossHull; + // Key 10 was the faction's named boss hull, retired by boss-hull-derived: a faction's boss hulls are derived from the Boss hulls it makes; never reuse it. [Inspectable, JsonProperty("influence"), Key(11)] public int InfluenceDistance = 4; diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs index 0b2f71387..86cd34125 100644 --- a/Assets/Scripts/ServerShared/Galaxy.cs +++ b/Assets/Scripts/ServerShared/Galaxy.cs @@ -288,9 +288,9 @@ public class Galaxy progressCallback?.Invoke("Placing Factions"); if (progressCallback != null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao - // Choose some megas to have bosses placed based on whether a boss hull is assigned + // Choose some megas to have bosses placed: the factions with a boss to fly, a Boss hull and its Loadout var bossMegas = Factions - .Where(m => m.BossHull.IsSet()) + .Where(m => Loadouts.BossOf(_cache, m, out _) != null) .Take(bossCount) .ToArray(); diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index ca1484e36..6d9e79ef9 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -555,6 +555,12 @@ public class HullData : EquippableItemData [Inspectable, JsonProperty("visual"), Key(32)] public CultRecordRef Visual; + // Only bosses fly this hull. Which factions it is a boss for comes from its products' makers + // (LoadoutGenerator.BossHullsOf), so the flag names no faction and reads no model. A boss's gear is its faction's + // fixed Loadout on this hull's product (Loadouts.BossOf), never generated. + [Inspectable, JsonProperty("boss"), Key(33)] + public bool Boss; + // The farthest corner of any occupied hull cell from the centre of mass the schematic frame uses, in world // units: a true bound on how far the hull reaches from its position (the half-diagonal of the bounding box is not, // as FireControl.Detonate's comment records). A cell at (x, y) spans x +/- .5 and y +/- .5. diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index bf8a2ac47..c2252087f 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -6,11 +6,11 @@ using GameCult.Caching; using MessagePack; using CultMath; -// A ship preset (a variant): the hull's design, and per hull cell a design and its rotation. A slot names a design and -// may name a product of it; a design-only slot is open on purpose, and whoever materializes the preset chooses its -// product from the galaxy it is built in (ruling loadout-product-optional-context-chooses), which keeps a preset -// portable across galaxies. A preset that needs a brand names it. Authored catalog data: written in CultCache Studio or by the editor capture -// command, read at runtime. +// A Loadout: a hull together with item designs or products equipped to specific cells (ruling boss-gear-is-a-loadout). +// A slot names a design, and names a product only where the loadout demands one; a design-only slot's product is chosen +// by whoever materializes it from the galaxy it is built in (ruling loadout-product-optional-context-chooses), which +// keeps a loadout portable across galaxies. Authored catalog data: written in CultCache Studio or by the AetherDb and +// editor capture commands, read at runtime. [CultDocument("aetheria.loadout", "1"), MessagePackObject] public class Loadout { @@ -30,6 +30,8 @@ public class LoadoutSlot [Key(3)] public CultRecordRef Product; // unset: design-only, chosen at materialization } +public enum BossRefusal { None, NoBossHull, NoLoadout } + // The owner of loadouts: capture from a live ship, the only writer, and the only builder of an entity from a loadout. public static class Loadouts { @@ -66,30 +68,60 @@ public static class Loadouts private static CultRecordRef ProductRef(ItemManager itemManager, CraftedItemInstance unit) => itemManager.Brand(unit).Product is { } product ? itemManager.ItemData.RefOf(product) : default; - // A preset's key derives from its name, so capturing the same name again addresses the same record. + // A loadout's key derives from its name, so capturing the same name again addresses the same record. public static CultRecordKey KeyOf(string name) => new CultRecordKey("loadout:" + name); - // The only writer of presets. It opens its own short-lived cache over the catalog file, writable, and disposes it + // The one owner of what a faction's boss flies: the Loadout whose HullProduct is the product of the faction's first boss + // hull (LoadoutGenerator.BossHullsOf, dearest first), first by Name ordinal. Keyed on the boss hull's product through + // Loadout.HullProduct, never generated; reads no Prefab, Visual or Random. Null with the refusal when there is no boss + // hull or no Loadout for it. + public static Loadout BossOf(CultCache items, Faction faction, out BossRefusal refusal) + { + var hulls = LoadoutGenerator.BossHullsOf(items, faction); + if (hulls.Length == 0) + { + refusal = BossRefusal.NoBossHull; + return null; + } + + var key = items.RefOf(hulls[0].product).Key; + var loadout = items.GetAll() + .Where(candidate => candidate.HullProduct.IsSet() && candidate.HullProduct.Key.Equals(key)) + .OrderBy(candidate => candidate.Name, StringComparer.Ordinal) + .FirstOrDefault(); + refusal = loadout == null ? BossRefusal.NoLoadout : BossRefusal.None; + return loadout; + } + + // Names the field that is missing; echoes no input value and no record key. + public static string Describe(BossRefusal refusal) => refusal switch + { + BossRefusal.NoBossHull => "makes no ship hull with HullData.Boss set", + BossRefusal.NoLoadout => "no Loadout's HullProduct names its boss hull's product", + _ => "" + }; + + // The only writer of Loadout records. It opens its own short-lived cache over the catalog file, writable, and disposes it // after the one commit. No other cache is touched, so no catalog instance play holds (or has mutated) can reach the - // file; a cache that already has the catalog open sees the preset only once it reloads. - // Throws and writes nothing when the loadout names a mod design (hull or slot), when the file is missing (capture never creates a catalog), when a preset of this name + // file; a cache that already has the catalog open sees the loadout only once it reloads. + // Throws and writes nothing when the loadout names a mod design (hull or slot), when the file is missing (capture never creates a catalog), when a loadout of this name // exists under another key, or when one exists under KeyOf(Name) and replace is not set. The commit is conditional - // on the record at KeyOf(Name), so a preset changed on disk since this open is not clobbered: returns false then. + // on the record at KeyOf(Name), so a loadout changed on disk since this open is not clobbered: returns false then. public static bool Commit(string catalogPath, Loadout loadout, bool replace) { var modDesigns = new[] { loadout.Hull.Key }.Concat(loadout.Slots.Select(slot => slot.Design.Key)).Where(ShipModCatalog.IsModKey).Select(key => key.Value).Distinct().ToArray(); if (modDesigns.Length > 0) - throw new InvalidOperationException($"Preset '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a preset."); + throw new InvalidOperationException($"Loadout '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a loadout."); if (!File.Exists(catalogPath)) - throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a preset capture never creates one."); + throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a loadout commit never creates one."); using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true); var key = KeyOf(loadout.Name); var namesake = cache.GetAll().FirstOrDefault(other => other.Name == loadout.Name && !cache.RefOf(other).Key.Equals(key)); if (namesake != null) - throw new InvalidOperationException($"Preset '{loadout.Name}' already exists under key {cache.RefOf(namesake).Key}; rename or remove it first."); + throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists under key {cache.RefOf(namesake).Key}; rename or remove it first."); var existing = cache.Get(key); if (existing != null && !replace) - throw new InvalidOperationException($"Preset '{loadout.Name}' already exists; replace it explicitly."); + throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists; replace it explicitly."); return cache.Commit(batch => { batch.Expect(key, existing); @@ -104,7 +136,8 @@ public static class Loadouts // WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure is listed; on any failure // nothing is returned and the zone and cache are unchanged, but a failed fit has already drawn from // itemManager.Random. How each design is built is Resolve's rule. A design-only slot is chosen by choose; a game - // spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does. The hull's type + // spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does, and so does the + // boss in ZoneGenerator, which is built here too from its faction's Loadout (Loadouts.BossOf). The hull's type // picks the entity: a ship hull builds a Ship, a turret hull an OrbitalEntity with no orbit, which stays where it is // put; a station hull is refused. The entity always gets exactly WeaponGroupCount groups; a loadout with fewer is // padded with empty groups. A failed materialization leaves any minted lots in the live ledger; they are not roots, @@ -119,7 +152,7 @@ public static class Loadouts var hullBuild = Resolve(itemManager, loadout.Hull, loadout.HullProduct, choose, "hull", failures, quality); var hullData = cache.Get(loadout.Hull); if (hullData is { HullType: HullType.Station }) - failures.Add($"hull: {hullData.Name} is a station hull; a preset builds a ship or a turret"); + failures.Add($"hull: {hullData.Name} is a station hull; a loadout builds a ship or a turret"); var slotBuilds = loadout.Slots.Select(slot => Resolve(itemManager, slot.Design, slot.Product, choose, Cell(slot), failures, quality)).ToArray(); var groups = loadout.WeaponGroups ?? new int[0][]; var groupCount = itemManager.GameplaySettings.WeaponGroupCount; diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 55eea27f8..86b300931 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -184,7 +184,9 @@ public class LoadoutGenerator } // The products this faction can be offered for a design kind (only gear needs a home), and whether manufacturer preference applies (it does - // not once a required item has fallen back to any manufacturer). The one availability rule for every selection. + // not once a required item has fallen back to any manufacturer). The one availability rule for every selection: a Boss + // hull is never on offer, so it is never station stock, ordinary traffic or a generated scenario fit; a Boss hull + // reaches a ship only through its faction's boss Loadout (Loadouts.BossOf). private ((FactionProductData product, T design)[] available, bool preferManufacturers) AvailableProducts(Predicate filter, bool required) where T : CraftedItemData { var hulls = SoldHulls(ItemManager.ItemData); @@ -193,6 +195,7 @@ public class LoadoutGenerator .Where(entry => entry.design != null && entry.design.Price > 0 && + !(entry.design is HullData { Boss: true }) && entry.product.Manufacturer.IsSet() && (!(entry.design is EquippableItemData equippable) || HasHome(equippable, hulls)) && (filter?.Invoke(entry.design) ?? true)) @@ -209,25 +212,19 @@ public class LoadoutGenerator return (available, preferManufacturers); } - // A faction's boss: its BossHull when the faction sets one and a product sells it, else the most expensive ship hull - // the faction can be offered, with the gap logged. Null only when no ship hull is on offer at all. - public EntityPack GenerateBossLoadout() - { - var bossHull = Faction != null && Faction.BossHull.IsSet() ? ItemManager.ItemData.Get(Faction.BossHull) : null; - if (bossHull != null) - { - var boss = GenerateShipLoadout(hull => hull == bossHull); - if (boss != null) return boss; - ItemManager.Log($"{Faction.Name}'s BossHull {bossHull.Name} is not sold by any product; its boss flies the most expensive ship hull instead."); - } - else - ItemManager.Log($"{Faction?.Name ?? "This faction"} has no BossHull; its boss flies the most expensive ship hull instead."); - - var (available, _) = AvailableProducts(hull => hull.HullType == HullType.Ship, required: true); - if (available.Length == 0) return null; - var priciest = available.OrderByDescending(entry => entry.design.Price).ThenBy(entry => entry.design.Name, StringComparer.Ordinal).First().design; - return GenerateShipLoadout(hull => hull == priciest); - } + // The one owner of which hull is a faction's boss: the Ship hulls flagged Boss among the designs of the products the + // faction manufactures, dearest first then by name. The flag lives on the hull and the maker on the product, so a Boss + // hull several makers sell is a boss of each; nothing here reads a model, a prefab or a visual. Its readers are + // Loadouts.BossOf and AetherDb factions. + public static (FactionProductData product, HullData hull)[] BossHullsOf(CultCache items, Faction faction) => + faction == null + ? Array.Empty<(FactionProductData, HullData)>() + : items.GetAll() + .Where(product => items.Get(product.Manufacturer) == faction) + .Select(product => (product, hull: items.Get(product.Design) as HullData)) + .Where(entry => entry.hull is { Boss: true, HullType: HullType.Ship }) + .OrderByDescending(entry => entry.hull.Price).ThenBy(entry => entry.hull.Name, StringComparer.Ordinal).ThenBy(entry => entry.product.Name, StringComparer.Ordinal) + .ToArray(); // Hardpoint gear that no hull in the catalog can mount has no home (operator, 2026-09-30: "we'll need to author a // bunch more hulls before all the gear variety in the game has a home"): generation never offers it, so no station diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index c0c8cf16f..80ec0d372 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -397,17 +397,28 @@ public static class ZoneGenerator } // A boss zone gets its faction's boss whether or not the zone is ambient: the boss is the run's goal, not - // wandering traffic. RunGoal reads the mark; nothing else decides whether a boss lives. + // wandering traffic. A boss flies its faction's fixed Loadout, never generated gear (ruling boss-gear-is-a-loadout); + // ChooseProduct only picks the brand of a design-only slot. RunGoal reads the mark; nothing else decides whether a + // boss lives. foreach (var bossFaction in galaxy.BossZones.Where(entry => entry.Value == galaxyZone).Select(entry => entry.Key)) { - var boss = GetLoadoutGenerator(bossFaction).GenerateBossLoadout(); - if (boss == null) + var loadout = Loadouts.BossOf(itemManager.ItemData, bossFaction, out var refusal); + if (loadout == null) { - itemManager.Log($"No boss could be generated for {bossFaction.Name}; its boss zone has no boss to kill!"); + itemManager.Log($"{bossFaction.Name} has no boss: it {Loadouts.Describe(refusal)}."); continue; } - boss.Boss = true; - pack.Entities.Add(boss); + var failures = new List(); + var boss = Loadouts.Materialize(itemManager, null, loadout, GetLoadoutGenerator(bossFaction).ChooseProduct, failures); + if (boss == null) + { + itemManager.Log($"{bossFaction.Name}'s boss Loadout {loadout.Name} cannot be built: {string.Join("; ", failures)}"); + continue; + } + boss.Faction = bossFaction; + var bossPack = EntitySerializer.Pack(boss); + bossPack.Boss = true; + pack.Entities.Add(bossPack); pack.BossSpawned = true; } diff --git a/tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs b/tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs new file mode 100644 index 000000000..0c577d80f --- /dev/null +++ b/tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs @@ -0,0 +1,210 @@ +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; + +// AetherDb's loadout-commit command (aetheria-release, cut boss-hull-derived; ruling boss-loadouts-agent-authored), run on a scratch +// copy of the shipped catalog. Commit writes one Loadout at Loadouts.KeyOf(name) with the records its names resolve to; every +// refusal leaves the catalog byte-identical; a Loadout it commits on a Boss hull's product is that maker's boss (Loadouts.BossOf). +public sealed class LoadoutCommitCommandTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "loadout-commit-" + Guid.NewGuid().ToString("N")); + private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc"); + + public LoadoutCommitCommandTests() + { + 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")); + } + + 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 static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))); + + // A hull product and a weapon slot of the shipped catalog that fit together, found the production way: a generated fit, + // captured. Every name in them is unique in the catalog, so the command resolves it exactly. + private (string hullProduct, string slot, string openSlot, string designName, string productName) Fit() + { + using var cache = Open(); + var settings = AuthoredSettings.Load(_root).Read("GameplaySettings"); + var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + bool UniqueProduct(string name) => cache.GetAll().Count(item => item.Name == name) == 1; + bool UniqueDesign(string name) => cache.GetAll().Count(item => item.Name == name) == 1; + foreach (var hull in cache.GetAll().Where(h => h.HullType == HullType.Ship && h.Price > 0).OrderBy(h => h.Name, StringComparer.Ordinal)) + { + var random = new CultMath.Random(1); + var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); + var pack = generator.GenerateShipLoadout(candidate => candidate == hull); + if (pack == null) continue; + var fit = Loadouts.Capture(items, (Ship) EntitySerializer.Unpack(items, null, pack), "probe"); + var hullProduct = cache.Get(fit.HullProduct); + if (hullProduct == null || !UniqueProduct(hullProduct.Name)) continue; + foreach (var slot in fit.Slots.Where(s => s.Product.IsSet() && cache.Get(s.Design).Behaviors.Any(b => b is WeaponData))) + { + var design = cache.Get(slot.Design); + var product = cache.Get(slot.Product); + if (!UniqueDesign(design.Name) || !UniqueProduct(product.Name)) continue; + var cell = $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{design.Name}"; + return (hullProduct.Name, $"{cell}@{product.Name}", cell, design.Name, product.Name); + } + } + throw new InvalidOperationException("the shipped catalog has no ship hull with a uniquely named weapon fit"); + } + + private int Run(out string output, params string[] args) + { + var writer = new StringWriter(); + var code = LoadoutCommitCommand.Run(args, _root, writer); + output = writer.ToString(); + return code; + } + + [Fact] + public void A_commit_writes_one_loadout_with_the_records_its_names_resolve_to() + { + var fit = Fit(); + Assert.Equal(0, Run(out var output, "Test boss", fit.hullProduct, fit.slot, "--group", "0")); + Assert.StartsWith("Test boss: committed, hull ", output); + + using var cache = Open(); + var loadout = Assert.Single(cache.GetAll()); + Assert.Equal(Loadouts.KeyOf("Test boss"), cache.RefOf(loadout).Key); + var product = cache.GetAll().Single(p => p.Name == fit.hullProduct); + Assert.Equal(cache.RefOf(product).Key, loadout.HullProduct.Key); + Assert.Equal(product.Design.Key, loadout.Hull.Key); + var slot = Assert.Single(loadout.Slots); + Assert.Equal(fit.designName, cache.Get(slot.Design).Name); + Assert.Equal(fit.productName, cache.Get(slot.Product).Name); + Assert.Equal(new[] { new[] { 0 } }, loadout.WeaponGroups); + } + + [Fact] + public void A_design_only_slot_commits_with_no_product_and_replace_overwrites() + { + var fit = Fit(); + Assert.Equal(0, Run(out _, "Test boss", fit.hullProduct, fit.slot)); + var written = Hash(CatalogPath); + Assert.Equal(1, Run(out var refused, "Test boss", fit.hullProduct, fit.openSlot)); + Assert.Contains("already exists", refused); + Assert.Equal(written, Hash(CatalogPath)); + + Assert.Equal(0, Run(out _, "Test boss", fit.hullProduct, fit.openSlot, "--replace")); + using var cache = Open(); + var loadout = Assert.Single(cache.GetAll()); + Assert.False(Assert.Single(loadout.Slots).Product.IsSet()); + Assert.True(loadout.WeaponGroups == null || loadout.WeaponGroups.Length == 0); + } + + // Every refusal happens before Loadouts.Commit, so the catalog is byte-identical; none echoes the name it was given. + [Theory] + [InlineData("unknown-hull-product")] + [InlineData("ambiguous-hull-product")] + [InlineData("unknown-design")] + [InlineData("ambiguous-design")] + [InlineData("unknown-slot-product")] + [InlineData("rotation-not-a-name")] + [InlineData("rotation-a-number")] + [InlineData("slot-does-not-fit")] + [InlineData("group-names-no-slot")] + [InlineData("mod-design")] + public void A_name_that_does_not_resolve_or_a_loadout_that_cannot_be_built_is_refused_and_nothing_is_written(string refusal) + { + var fit = Fit(); + var canary = "Canary9c1e"; + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + object Copy(object document) => MessagePack.MessagePackSerializer.Deserialize(document.GetType(), MessagePack.MessagePackSerializer.Serialize(document.GetType(), document, options), options); + using (var cache = Open()) + { + // Under a key of its own, so the copy is a second record of the same name, not the first overwritten. + void Add(object document) => Assert.True(cache.Commit(batch => batch.Upsert(document.GetType(), document, new CultRecordKey("duplicate:" + Guid.NewGuid().ToString("N"))))); + switch (refusal) + { + case "ambiguous-hull-product": + Add(Copy(cache.GetAll().Single(p => p.Name == fit.hullProduct))); + break; + case "ambiguous-design": + Add(Copy(cache.GetAll().Single(d => d.Name == fit.designName))); + break; + case "mod-design": + var mod = (EquippableItemData) Copy(cache.GetAll().Single(d => d.Name == fit.designName)); + mod.Name = "Mod gadget"; + Assert.True(cache.Commit(batch => batch.Upsert(mod.GetType(), mod, new CultRecordKey("mod-ship:mod.gadget")))); + break; + } + cache.FlushAsync().Wait(); + } + + var cell = fit.openSlot.Split(','); + var args = refusal switch + { + "unknown-hull-product" => new[] { "Test boss", canary, fit.slot }, + "unknown-design" => new[] { "Test boss", fit.hullProduct, $"{cell[0]},{cell[1]},{cell[2]},{canary}" }, + "unknown-slot-product" => new[] { "Test boss", fit.hullProduct, $"{fit.openSlot}@{canary}" }, + "rotation-not-a-name" => new[] { "Test boss", fit.hullProduct, $"{cell[0]},{cell[1]},{canary},{fit.designName}" }, + "rotation-a-number" => new[] { "Test boss", fit.hullProduct, $"{cell[0]},{cell[1]},1,{fit.designName}" }, + "slot-does-not-fit" => new[] { "Test boss", fit.hullProduct, $"999,999,{cell[2]},{fit.designName}" }, + "group-names-no-slot" => new[] { "Test boss", fit.hullProduct, fit.slot, "--group", "5" }, + "mod-design" => new[] { "Test boss", fit.hullProduct, $"{cell[0]},{cell[1]},{cell[2]},Mod gadget" }, + _ => new[] { "Test boss", fit.hullProduct, fit.slot } + }; + var before = Hash(CatalogPath); + + Assert.Equal(1, Run(out var output, args)); + + Assert.StartsWith("refused: ", output); + Assert.DoesNotContain(canary, output); + if (refusal == "mod-design") Assert.Contains("names mod designs", output); + Assert.Equal(before, Hash(CatalogPath)); + using var after = Open(); + Assert.Empty(after.GetAll()); + } + + // Ruling boss-loadouts-agent-authored end to end: a Boss hull and its maker's product in the catalog, a Loadout written by the + // command on that product, and the maker's boss is that Loadout. + [Fact] + public void A_loadout_committed_on_a_boss_hulls_product_is_its_makers_boss() + { + var fit = Fit(); + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + T Copy(T document) => MessagePack.MessagePackSerializer.Deserialize(MessagePack.MessagePackSerializer.Serialize(document, options), options); + using (var cache = Open()) + { + var likeProduct = cache.GetAll().Single(p => p.Name == fit.hullProduct); + var maker = cache.GetAll().OrderBy(f => f.Name, StringComparer.Ordinal).Last(); + var boss = Copy((HullData) cache.Get(likeProduct.Design)); + boss.Name = "Command Boss"; + boss.Boss = true; + var bossRef = cache.Upsert(boss); + var product = Copy(likeProduct); + product.Name = "Command Boss Mk I"; + product.Design = new CultRecordRef(bossRef.Key); + product.Manufacturer = cache.RefOf(maker); + cache.Upsert(product); + cache.FlushAsync().Wait(); + } + + Assert.Equal(0, Run(out _, "Command Boss loadout", "Command Boss Mk I", fit.slot)); + + using var reopened = Open(); + var makerAfter = reopened.GetAll().OrderBy(f => f.Name, StringComparer.Ordinal).Last(); + var found = Loadouts.BossOf(reopened, makerAfter, out var refusal); + Assert.Equal(BossRefusal.None, refusal); + Assert.Equal("Command Boss loadout", found.Name); + Assert.Equal("Command Boss", reopened.Get(found.Hull).Name); + foreach (var other in reopened.GetAll().Where(f => f != makerAfter)) + Assert.Null(Loadouts.BossOf(reopened, other, out _)); + } +} diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 25b46fdd3..6e171b33a 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -8,6 +8,8 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; using UniRx; using Xunit; @@ -16,9 +18,9 @@ using Xunit; // and the run save. public sealed partial class RunStartTests { - private ZonePack BossZonePack(Galaxy galaxy, ItemManager items, GalaxyZone zone, bool ambient = true) + private ZonePack BossZonePack(Galaxy galaxy, ItemManager items, GalaxyZone zone, bool ambient = true, uint seed = 7) { - items.Random = new CultMath.Random(7); + items.Random = new CultMath.Random(seed); return ZoneGenerator.GenerateZone(items, _zoneSettings, _planetSettings, galaxy, zone, galaxy.IsPrelude, ambient); } @@ -35,12 +37,435 @@ public sealed partial class RunStartTests private HullData[] SoldShipHulls() => _cache.GetAll() .Where(product => product.Manufacturer.IsSet()) .Select(product => _cache.Get(product.Design) as HullData) - .Where(hull => hull != null && hull.HullType == HullType.Ship && hull.Price > 0) + .Where(hull => hull != null && hull.HullType == HullType.Ship && hull.Price > 0 && !hull.Boss) .Distinct().ToArray(); + // A Boss hull a faction makes, by the production path: a copy of a sold ship hull (so it builds into a real ship) flagged + // Boss under its own name and price, and one copy of that hull's product per maker. A copy rather than the shipped + // hull itself, because flagging a shipped hull would make it a Boss hull of every maker the catalog gives it. + private HullData MakeBoss(string name, int price, params Faction[] makers) => MakeBoss(HullType.Ship, name, price, makers); + + private HullData MakeBoss(HullType type, string name, int price, params Faction[] makers) + { + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + T Copy(T document) => MessagePack.MessagePackSerializer.Deserialize(MessagePack.MessagePackSerializer.Serialize(document, options), options); + + var like = SoldShipHulls().OrderBy(hull => hull.Name, StringComparer.Ordinal).First(); + var likeProduct = _cache.GetAll().OrderBy(product => product.Name, StringComparer.Ordinal) + .First(product => _cache.Get(product.Design) == like); + var boss = Copy(like); + boss.Name = name; + boss.Price = price; + boss.HullType = type; + boss.Boss = true; + boss.Prefab = null; + boss.Visual = default; + var bossRef = _cache.Upsert(boss); + foreach (var maker in makers) + { + var product = Copy(likeProduct); + product.Name = $"{name} by {maker.Name}"; + product.Design = new CultRecordRef(bossRef.Key); + product.Manufacturer = _cache.RefOf(maker); + _cache.Upsert(product); + } + return _cache.Get(bossRef); + } + + // A boss's fixed Loadout (ruling boss-gear-is-a-loadout), by the production capture: a generated fit of the hull MakeBoss + // copied, captured as a Loadout (Loadouts.Capture), cut down to its weapons so it is smaller than any generated fit (the + // tractor pickup and the rest are not in it), moved onto the boss hull and the maker's product of it, and stored at + // Loadouts.KeyOf(name), the key Loadouts.Commit writes. openRank leaves the slot whose design ranks highest (above 0) design-only. + // The shipped catalog has no Boss hull, so this and MakeBoss are fixtures. + private Loadout MakeBossLoadout(HullData boss, Faction maker, string name, Func openRank = null) + { + var like = SoldShipHulls().OrderBy(hull => hull.Name, StringComparer.Ordinal).First(); + var random = new CultMath.Random(11); + var generator = new LoadoutGenerator(ref random, _items, null, null, null, .5f); + var ship = (Ship) EntitySerializer.Unpack(_items, null, generator.GenerateShipLoadout(hull => hull == like)); + var fit = Loadouts.Capture(_items, ship, name); + var armed = fit.Slots.Select((slot, index) => (slot, index)) + .Where(entry => _cache.Get(entry.slot.Design).Behaviors.Any(behavior => behavior is WeaponData)).ToArray(); + Assert.NotEmpty(armed); + Assert.True(armed.Length < fit.Slots.Count, "the generated fit needs gear besides weapons for this fixture to tell a Loadout from a fit"); + var renumbered = armed.Select((entry, number) => (entry.index, number)).ToDictionary(entry => entry.index, entry => entry.number); + fit.WeaponGroups = fit.WeaponGroups.Select(group => group.Where(renumbered.ContainsKey).Select(index => renumbered[index]).ToArray()).ToArray(); + fit.Slots = armed.Select(entry => entry.slot).ToList(); + if (openRank != null) + { + // Only a design the Loadout fits once can be left open, so its products can be rearranged without touching a pinned slot. + var open = fit.Slots.Where(slot => fit.Slots.Count(other => other.Design.Key.Equals(slot.Design.Key)) == 1).OrderByDescending(slot => openRank(_cache.Get(slot.Design))).First(); + Assert.True(openRank(_cache.Get(open.Design)) > 0, "no weapon of the fit can be left design-only for this maker"); + open.Product = default; + } + fit.Hull = _cache.RefOf(boss); + fit.HullProduct = _cache.RefOf(_cache.GetAll().Single(product => _cache.Get(product.Design) == boss && _cache.Get(product.Manufacturer) == maker)); + StoreLoadout(fit); + return fit; + } + + // A Loadout record at Loadouts.KeyOf(Name), the key Loadouts.Commit writes it under. + private void StoreLoadout(Loadout loadout) => + Assert.True(_cache.Commit(batch => batch.Upsert(typeof(Loadout), loadout, Loadouts.KeyOf(loadout.Name)))); + + // The demo's antagonist gets a Boss hull and its Loadout: the shipped catalog has neither. + private HullData GiveAntagonistABoss(Galaxy galaxy) + { + var antagonist = Zhestokost(galaxy); + var boss = MakeBoss("Antagonist Boss", 100, antagonist); + MakeBossLoadout(boss, antagonist, "Antagonist Boss loadout"); + return boss; + } + + // What a ship carries, cell by cell, as a Loadout would name it: position, rotation, design, product. + private static string[] Cells(ItemManager items, EntityPack pack) + { + var captured = Loadouts.Capture(items, (Ship) EntitySerializer.Unpack(items, null, pack), "probe"); + return captured.Slots.OrderBy(slot => slot.Position.x).ThenBy(slot => slot.Position.y) + .Select(slot => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{items.ItemData.Get(slot.Design).Name},{(slot.Product.IsSet() ? items.ItemData.Get(slot.Product).Name : "-")}").ToArray(); + } + + private static Faction FactionNamed(Galaxy galaxy, string name) => galaxy.Factions.Single(faction => faction.Name == name); + + private HullData PriciestSold() => + SoldShipHulls().OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).First(); + + private LoadoutGenerator GeneratorFor(Galaxy galaxy, ItemManager items, Faction faction, uint seed) + { + var random = new CultMath.Random(seed); + return new LoadoutGenerator(ref random, items, galaxy, galaxy.Exit, faction, .5f); + } + + // BossOf owns the boss. Zhestokost makes Boss A, B and C and a decoy ordinary hull. Loadouts sit on the decoy's product, + // on Boss B's and C's, and two on Boss A's: only Boss A's, first by name, is the boss's, whatever order the names fall in. + [Fact] + public void Boss_flies_its_factions_boss_loadout() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var cheapest = SoldShipHulls().Min(hull => hull.Price); + var priciest = PriciestSold(); + Assert.True(cheapest < priciest.Price, "the catalog needs two ship hulls of different price for this test to tell them apart"); + + // B and A tie on price, C is cheaper: the boss is the dearest Boss hull, ties by name. + var decoy = new FactionProductData { Name = "Decoy", Design = new CultRecordRef(_cache.RefOf(priciest).Key), Manufacturer = _cache.RefOf(antagonist) }; + var decoyRef = _cache.Upsert(decoy); + var b = MakeBoss("Boss B", cheapest + 10, antagonist); + var c = MakeBoss("Boss C", cheapest, antagonist); + var a = MakeBoss("Boss A", cheapest + 10, antagonist); + MakeBossLoadout(b, antagonist, "Aaa loadout of Boss B"); + MakeBossLoadout(c, antagonist, "Aab loadout of Boss C"); + MakeBossLoadout(a, antagonist, "Zz second loadout of Boss A"); + MakeBossLoadout(a, antagonist, "Zz first loadout of Boss A"); + StoreLoadout(new Loadout + { + Name = "Aac loadout of the decoy", Hull = _cache.RefOf(priciest), + HullProduct = new CultRecordRef(decoyRef.Key) + }); + + Assert.Equal(new[] { "Boss A", "Boss B", "Boss C" }, LoadoutGenerator.BossHullsOf(_cache, antagonist).Select(entry => entry.hull.Name)); + var loadout = Loadouts.BossOf(_cache, antagonist, out var refusal); + Assert.Equal(BossRefusal.None, refusal); + Assert.Equal("Zz first loadout of Boss A", loadout.Name); + + var log = new List(); + var items = LoggingItems(log); + var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); + Assert.Equal("Boss A", HullName(items, boss)); + Assert.DoesNotContain(log, line => line.Contains("makes no boss hull")); + } + + // Equal-priced Boss hulls go by hull name, and a hull sold under two products by product name, whatever order the products + // were authored or are named in: the first Boss hull is the same hull for every reader. + [Fact] + public void Equal_priced_boss_hulls_go_by_hull_name_then_product_name() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var y = MakeBoss("Tie Y", 100, antagonist); + var z = MakeBoss("Tie Z", 100, antagonist); + void Rename(HullData hull, string name) + { + var product = _cache.GetAll().Single(p => _cache.Get(p.Design) == hull); + product.Name = name; + Assert.True(_cache.Commit(batch => batch.Upsert(typeof(FactionProductData), product, _cache.RefOf(product).Key))); + } + Rename(y, "zz product of Y"); + Rename(z, "aa product of Z"); + var yProduct = _cache.GetAll().Single(p => p.Name == "zz product of Y"); + _cache.Upsert(new FactionProductData { Name = "a second product of Y", Design = yProduct.Design, Manufacturer = yProduct.Manufacturer }); + + var made = LoadoutGenerator.BossHullsOf(_cache, antagonist); + Assert.Equal(new[] { "Tie Y", "Tie Y", "Tie Z" }, made.Select(entry => entry.hull.Name)); + Assert.Equal(new[] { "a second product of Y", "zz product of Y", "aa product of Z" }, made.Select(entry => entry.product.Name)); + } + + // Ruling boss-gear-is-a-loadout: the boss carries its Loadout's cells and nothing else. The generated fit of the same hull + // carries more (a tractor pickup, consumables); none of it is on the boss. No generator draw decides it: another seed gives + // the same cells. A design-only slot takes its brand from the context's ChooseProduct (ruling + // loadout-product-optional-context-chooses); a pinned one keeps the product it names. + [Fact] + public void Boss_carries_exactly_its_loadouts_cells() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var hull = MakeBoss("Boss A", 100, antagonist); + var loadout = MakeBossLoadout(hull, antagonist, "Boss A loadout", + openRank: design => GeneratorFor(galaxy, _items, antagonist, 1) is var offered + && _cache.GetAll().Any(product => product.Design.Key.Equals(_cache.RefOf(design).Key) && offered.IsAvailable(product)) ? 1 : 0); + Assert.True(loadout.Slots.Count > 1); + var open = Assert.Single(loadout.Slots, slot => !slot.Product.IsSet()); + + string Cell(LoadoutSlot slot, string brand) => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{_cache.Get(slot.Design).Name},{brand}"; + var pinned = loadout.Slots.Where(slot => slot != open).Select(slot => Cell(slot, _cache.Get(slot.Product).Name)).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + // The context's choice, made unmistakable: the design has two products, one the antagonist's outfitter cannot get (listed + // first) and one it can. The open slot is built as the one it can, whatever the key order or the draw. + var template = _cache.GetAll().First(product => product.Design.Key.Equals(open.Design.Key)); + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + FactionProductData Brand(string name, Faction maker) + { + var brand = MessagePack.MessagePackSerializer.Deserialize(MessagePack.MessagePackSerializer.Serialize(template, options), options); + brand.Name = name; + brand.Manufacturer = _cache.RefOf(maker); + return brand; + } + var outfitter = GeneratorFor(galaxy, _items, antagonist, 1); + var outsider = _cache.GetAll().First(faction => !outfitter.IsAvailable(Brand("probe", faction))); + var made = Brand("A brand of an outsider", outsider); + var chosen = Brand("B brand of the antagonist", antagonist); + Assert.False(outfitter.IsAvailable(made)); + Assert.True(outfitter.IsAvailable(chosen)); + foreach (var product in _cache.GetAll().Where(product => product.Design.Key.Equals(open.Design.Key)).ToArray()) + Assert.True(_cache.Remove(_cache.RefOf(product).Key)); + _cache.Upsert(made); + _cache.Upsert(chosen); + + var items = LoggingItems(new List()); + var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); + var cells = Cells(items, boss); + Assert.Equal(loadout.Slots.Count, cells.Length); + Assert.Equal(pinned, cells.Where(cell => !cell.StartsWith($"{open.Position.x},{open.Position.y},")).OrderBy(x => x, StringComparer.Ordinal)); + var openCell = Assert.Single(cells, cell => cell.StartsWith($"{open.Position.x},{open.Position.y},")); + Assert.Equal(Cell(open, "B brand of the antagonist"), openCell); + + var captured = Loadouts.Capture(items, (Ship) EntitySerializer.Unpack(items, null, boss), "probe"); + Assert.Equal(_cache.Get(loadout.HullProduct).Name, _cache.Get(captured.HullProduct).Name); + Assert.Equal(hull.Name, _cache.Get(captured.Hull).Name); + string[][] Groups(Loadout l) => l.WeaponGroups.Select(group => group.Select(i => $"{l.Slots[i].Position.x},{l.Slots[i].Position.y}").OrderBy(x => x, StringComparer.Ordinal).ToArray()).ToArray(); + Assert.Equal(Groups(loadout).Where(group => group.Length > 0), Groups(captured).Where(group => group.Length > 0)); + + // No generator draw decides the gear: another seed builds the same pinned cells. Only the open slot's brand is the context's pick. + var otherItems = LoggingItems(new List()); + var other = Assert.Single(BossZonePack(galaxy, otherItems, galaxy.Exit, seed: 8).Entities, entity => entity.Boss); + var otherCells = Cells(otherItems, other); + Assert.Equal(pinned, otherCells.Where(cell => !cell.StartsWith($"{open.Position.x},{open.Position.y},")).OrderBy(x => x, StringComparer.Ordinal)); + } + + // No generated fallback: the shipped catalog has no Boss hull, so Zhestokost has no boss, however many ordinary hulls it can be offered. + [Fact] + public void Boss_without_a_boss_hull_is_refused() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal)); + Assert.Equal(BossRefusal.NoBossHull, refusal); + + var log = new List(); + var items = LoggingItems(log); + var pack = BossZonePack(galaxy, items, galaxy.Exit); + Assert.False(pack.BossSpawned); + Assert.DoesNotContain(pack.Entities, entity => entity.Boss); + Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("HullData.Boss")); + Assert.Contains(log, line => line.Contains("Galaxy.Exit") && line.Contains("exit-without-boss")); + Assert.DoesNotContain(log, line => line.Contains("makes no boss hull")); + Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, null)); + } + + // No faction has no boss hulls, even when a Boss hull has a product with no maker (which a null faction would equal). + [Fact] + public void No_faction_has_no_boss_hull() + { + var galaxy = DemoGalaxy(1); + var orphan = MakeBoss("Orphan Boss", 100); + var key = _cache.RefOf(orphan).Key; + _cache.Upsert(new FactionProductData { Name = "Orphan product", Design = new CultRecordRef(key) }); + + Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, null)); + Assert.Null(Loadouts.BossOf(_cache, null, out var refusal)); + Assert.Equal(BossRefusal.NoBossHull, refusal); + Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, Zhestokost(galaxy))); + } + + // A Boss hull whose product has no Loadout is refused typed at the derivation. The refusal names the missing field and + // echoes no input: the canary hull name, its product's name and the record keys reach no log line. + [Fact] + public void A_boss_hull_without_a_loadout_is_refused() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var hull = MakeBoss("Canary7f3a hull", 100, antagonist); + var product = _cache.GetAll().Single(p => _cache.Get(p.Design) == hull); + // A Loadout on another maker's boss product is not this boss's. + var lucent = FactionNamed(galaxy, "Lucent Media"); + MakeBossLoadout(MakeBoss("Other boss", 50, lucent), lucent, "Other boss loadout"); + + Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal)); + Assert.Equal(BossRefusal.NoLoadout, refusal); + var described = Loadouts.Describe(refusal); + Assert.Contains("HullProduct", described); + + var log = new List(); + var items = LoggingItems(log); + var pack = BossZonePack(galaxy, items, galaxy.Exit); + Assert.False(pack.BossSpawned); + Assert.DoesNotContain(pack.Entities, entity => entity.Boss); + Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("HullProduct")); + foreach (var canary in new[] { "Canary7f3a", product.Name, _cache.RefOf(product).Key.Value, _cache.RefOf(hull).Key.Value }) + { + Assert.DoesNotContain(log, line => line.Contains(canary)); + Assert.DoesNotContain(canary, described); + } + } + + // The key is the product, not the hull: a Boss hull both makers sell, with a Loadout on Lucent Media's product only. + [Fact] + public void A_loadout_on_another_makers_product_is_not_this_bosses() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var lucent = FactionNamed(galaxy, "Lucent Media"); + var shared = MakeBoss("Shared Boss", 100, antagonist, lucent); + var loadout = MakeBossLoadout(shared, lucent, "Aaa Lucent only"); + + Assert.Equal(loadout.Name, Loadouts.BossOf(_cache, lucent, out var lucentRefusal).Name); + Assert.Equal(BossRefusal.None, lucentRefusal); + Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal)); + Assert.Equal(BossRefusal.NoLoadout, refusal); + Assert.False(BossZonePack(galaxy, LoggingItems(new List()), galaxy.Exit).BossSpawned); + } + + [Fact] + public void Another_factions_boss_hull_is_never_used() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var lucent = FactionNamed(galaxy, "Lucent Media"); + // Dearer than every ordinary hull, and Lucent's alone, with its own Loadout: Zhestokost neither derives it nor falls back to it. + var luxury = MakeBoss("Lucent Flagship", PriciestSold().Price + 1000, lucent); + MakeBossLoadout(luxury, lucent, "Lucent Flagship loadout"); + + var log = new List(); + var items = LoggingItems(log); + var pack = BossZonePack(galaxy, items, galaxy.Exit); + Assert.False(pack.BossSpawned); + Assert.DoesNotContain(pack.Entities, entity => entity.Boss); + Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("HullData.Boss")); + Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal)); + Assert.Equal(BossRefusal.NoBossHull, refusal); + Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, antagonist)); + Assert.Same(luxury, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, lucent)).hull); + Assert.Equal("Lucent Flagship loadout", Loadouts.BossOf(_cache, lucent, out _).Name); + } + + [Fact] + public void A_boss_hull_two_makers_sell_is_a_boss_of_each() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var lucent = FactionNamed(galaxy, "Lucent Media"); + var shared = MakeBoss("Shared Boss", PriciestSold().Price - 1, antagonist, lucent); + // A Boss station hull the antagonist also makes is no boss: a boss is a ship. + MakeBoss(HullType.Station, "Boss Station", PriciestSold().Price + 1, antagonist); + MakeBossLoadout(shared, lucent, "Aaa loadout of Lucent"); + MakeBossLoadout(shared, antagonist, "Zzz loadout of Zhestokost"); + + Assert.Same(shared, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, antagonist)).hull); + Assert.Same(shared, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, lucent)).hull); + Assert.Equal("Zzz loadout of Zhestokost", Loadouts.BossOf(_cache, antagonist, out _).Name); + Assert.Equal("Aaa loadout of Lucent", Loadouts.BossOf(_cache, lucent, out _).Name); + var items = LoggingItems(new List()); + var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); + Assert.Equal("Shared Boss", HullName(items, boss)); + } + + // Ruling hull-programmer-art-reads-as-stand-in: a boss hull may be an agent's stopgap with no authored model. + [Fact] + public void A_boss_hull_needs_no_authored_model() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var bare = MakeBoss("Bare Boss", 1, antagonist); + MakeBossLoadout(bare, antagonist, "Bare Boss loadout"); + Assert.Null(bare.Prefab); + Assert.False(bare.Visual.IsSet()); + + Assert.Same(bare, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, antagonist)).hull); + var items = LoggingItems(new List()); + var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); + Assert.Equal("Bare Boss", HullName(items, boss)); + } + + // A Boss hull is flown only by bosses: with the cheapest price (the heaviest draw weight) and every relevant maker, it still + // never comes out of a generation draw, including the any-manufacturer fallback and a faction no maker is allied to. + [Fact] + public void Boss_hulls_are_never_stock_or_traffic() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var lucent = FactionNamed(galaxy, "Lucent Media"); + var bossShip = MakeBoss("Traffic Boss", 1, antagonist, lucent); + var bossStation = MakeBoss(HullType.Station, "Stock Boss", 1, antagonist, lucent); + var stranger = new Faction { Name = "Stranger" }; + _cache.Upsert(stranger); + stranger = _cache.GetAll().Single(faction => faction.Name == "Stranger"); + MakeBoss("Strangers Foe Boss", 1, lucent); + var log = new List(); + var items = LoggingItems(log); + + var drawn = 0; + for (uint seed = 1; seed <= 20; seed++) + foreach (var faction in new[] { antagonist, lucent, stranger, null }) + { + var generator = GeneratorFor(galaxy, items, faction, seed); + for (var i = 0; i < 3; i++) + { + var pack = generator.GenerateShipLoadout(); + Assert.NotNull(pack); + drawn++; + Assert.DoesNotContain(HullName(items, pack), new[] { "Traffic Boss", "Strangers Foe Boss" }); + Assert.NotEqual("Stock Boss", generator.RandomHull(HullType.Station).design?.Name); + } + // Asked for by name, with the boss the only candidate: nothing is on offer, however the fallback is reached. + Assert.Null(generator.GenerateShipLoadout(hull => hull == bossShip)); + Assert.Null(generator.GenerateShipLoadout(hull => hull.Name == "Strangers Foe Boss")); + Assert.Null(generator.RandomHull(HullType.Station, hull => hull == bossStation).design); + } + Assert.Equal(20 * 4 * 3, drawn); + } + + // PlaceFactionsMain picks through BossOf: a faction with a Boss hull and no Loadout places nothing, and the faction that + // also has the Loadout is the one entry, though another faction sorts first. + [Fact] + public void Main_galaxy_bosses_are_factions_with_a_boss_loadout() + { + Assert.Empty(RunStart.Generate(new MainGalaxy(), Inputs(() => 1)).BossZones); + + var makers = _cache.GetAll().OrderBy(faction => faction.Name, StringComparer.Ordinal).Take(2).ToArray(); + MakeBoss("Unarmed Boss", 100, makers[0]); + var armed = MakeBoss("Main Boss", 100, makers[1]); + Assert.Empty(RunStart.Generate(new MainGalaxy(), Inputs(() => 1)).BossZones); + + MakeBossLoadout(armed, makers[1], "Main Boss loadout"); + var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1)); + var bossFaction = Assert.Single(galaxy.BossZones.Keys); + Assert.Equal(makers[1].Name, bossFaction.Name); + Assert.NotNull(Loadouts.BossOf(_cache, bossFaction, out _)); + } + [Fact] public void Boss_spawns_once_in_boss_zone() { + var antagonistsBoss = GiveAntagonistABoss(DemoGalaxy(DemoSeeds.First())); foreach (var seed in DemoSeeds.Take(3)) { var galaxy = DemoGalaxy(seed); @@ -53,6 +478,7 @@ public sealed partial class RunStartTests var boss = Assert.Single(bosses); Assert.IsType(boss); Assert.Same(antagonist, _cache.Get(boss.Faction)); + Assert.Equal(antagonistsBoss.Name, HullName(_items, boss)); // Generation is repeatable per zone: the same zone yields the same boss hull. var again = Assert.Single(BossZonePack(galaxy, _items, zone).Entities, entity => entity.Boss); Assert.Equal(HullName(_items, boss), HullName(_items, again)); @@ -65,59 +491,17 @@ public sealed partial class RunStartTests public void Boss_spawns_in_a_zone_that_is_not_ambient() { var galaxy = DemoGalaxy(1); + GiveAntagonistABoss(galaxy); var pack = BossZonePack(galaxy, _items, galaxy.Exit, ambient: false); Assert.Single(pack.Entities, entity => entity.Boss); Assert.DoesNotContain(pack.Entities, entity => !entity.Boss && entity is ShipPack); } - [Fact] - public void Boss_hull_from_BossHull_else_fallback() - { - var galaxy = DemoGalaxy(1); - var antagonist = Zhestokost(galaxy); - var hulls = SoldShipHulls().OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).ToArray(); - var priciest = hulls.First(); - var cheaper = hulls.Last(); - Assert.True(cheaper.Price < priciest.Price, "the catalog needs two ship hulls of different price for this test to tell them apart"); - - // Set and sold: the boss flies it, and nothing is logged about a gap. - antagonist.BossHull = _cache.RefOf(cheaper); - var log = new List(); - var items = LoggingItems(log); - var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); - Assert.Equal(cheaper.Name, HullName(items, boss)); - Assert.DoesNotContain(log, line => line.Contains("BossHull")); - - // Unset: the most expensive ship hull on offer, and the log names the missing BossHull. - antagonist.BossHull = default; - log.Clear(); - boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); - Assert.Equal(priciest.Name, HullName(items, boss)); - Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("no BossHull")); - } - - // Set but sold by no product: the boss falls back the same way, and the log says which hull has no seller. - [Fact] - public void Boss_hull_nobody_sells_falls_back() - { - var galaxy = DemoGalaxy(1); - var antagonist = Zhestokost(galaxy); - var sold = SoldShipHulls(); - // A ship hull no product sells, dearer than any that is sold: the fallback must not pick it. - var unsold = new HullData { Name = "Unsold Test Hull", HullType = HullType.Ship, Price = sold.Max(hull => hull.Price) + 1 }; - _cache.Upsert(unsold); - antagonist.BossHull = _cache.RefOf(unsold); - var log = new List(); - var items = LoggingItems(log); - var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); - Assert.Equal(sold.OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).First().Name, HullName(items, boss)); - Assert.Contains(log, line => line.Contains(unsold.Name) && line.Contains("BossHull")); - } - [Fact] public void Exit_sealed_until_boss_dies() { var galaxy = DemoGalaxy(1); + GiveAntagonistABoss(galaxy); var exit = ZoneOf(galaxy, galaxy.Exit); var boss = Assert.Single(exit.Entities, entity => entity.IsBoss); Assert.True(RunGoal.BossAlive(exit)); @@ -138,6 +522,7 @@ public sealed partial class RunStartTests [Fact] public void Boss_mark_survives_continue() { + GiveAntagonistABoss(DemoGalaxy(1)); var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => 1)); Assert.True(failures.Count == 0, string.Join("; ", failures)); galaxy.Entrance.Contents = arena; @@ -196,10 +581,10 @@ public sealed partial class RunStartTests public void Exit_open_only_in_the_exit_zone() { var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1)); - // The shipped catalog sets no BossHull outside the demo's antagonist, so the main galaxy places no boss: give a - // faction one and a boss zone that is not the exit, as the generator would for a faction with a boss hull. + // The shipped catalog has no Boss hull, so the main galaxy places no boss: give a faction one and a boss zone that is + // not the exit, as the generator would for a faction that makes a Boss hull. var faction = galaxy.Factions.First(); - faction.BossHull = _cache.RefOf(SoldShipHulls().First()); + MakeBossLoadout(MakeBoss("Gate Boss", 100, faction), faction, "Gate Boss loadout"); var bossZone = galaxy.Zones.First(zone => zone != galaxy.Exit); galaxy.BossZones[faction] = bossZone; var zone = ZoneOf(galaxy, bossZone); @@ -211,19 +596,27 @@ public sealed partial class RunStartTests Assert.False(RunGoal.ExitOpen(zone)); } - // A boss that cannot be generated (no ship hull on offer at all) never marks the zone as having spawned one. + // A boss Loadout whose slot does not fit gives no boss: the log names the Loadout and the fit failure, and the gate stays sealed. [Fact] - public void Exit_sealed_when_no_ship_hull_is_on_offer() + public void An_unbuildable_boss_loadout_spawns_no_boss() { var galaxy = DemoGalaxy(1); - foreach (var product in _cache.GetAll().Where(product => _cache.Get(product.Design) is HullData { HullType: HullType.Ship }).ToArray()) - Assert.True(_cache.Remove(_cache.RefOf(product).Key)); + var antagonist = Zhestokost(galaxy); + var hull = MakeBoss("Boss A", 100, antagonist); + var loadout = MakeBossLoadout(hull, antagonist, "Unfittable loadout"); + loadout.Slots[0].Position = new int2(999, 999); + loadout.Slots[1].Position = new int2(998, 998); + StoreLoadout(loadout); + Assert.Equal("Unfittable loadout", Loadouts.BossOf(_cache, antagonist, out _).Name); + var log = new List(); var items = LoggingItems(log); var pack = BossZonePack(galaxy, items, galaxy.Exit, ambient: false); Assert.False(pack.BossSpawned); Assert.DoesNotContain(pack.Entities, entity => entity.Boss); - Assert.Contains(log, line => line.Contains("No boss could be generated for Zhestokost")); + var refusal = Assert.Single(log, line => line.Contains("Unfittable loadout") && line.Contains("does not fit")); + Assert.Equal(2, Regex.Matches(refusal, "does not fit").Count); + Assert.Contains("; ", refusal); Assert.False(RunGoal.ExitOpen(new Zone(items, _planetSettings, pack, galaxy.Exit, galaxy))); } @@ -232,6 +625,7 @@ public sealed partial class RunStartTests public void Exit_open_after_save_of_a_slain_boss() { var galaxy = DemoGalaxy(1); + GiveAntagonistABoss(galaxy); var exit = ZoneOf(galaxy, galaxy.Exit); Assert.Single(exit.Entities, entity => entity.IsBoss).HeatstrokeDeath.OnNext(Unit.Default); var repacked = exit.PackZone(); diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index f6f3bad10..a88a2541d 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -99,6 +99,25 @@ public sealed class ShipProductTests : IDisposable } } + // The package path of the derivation: the flag survives compose on the hull, and the maker is the package's product's. + [Fact] + public void ABossPackageIsItsMakersBossAfterCompose() + { + Environment.CurrentDirectory = _root.Path; + var path = Package("mod.boss", "Bosshull", 100, Product("Acme Boss", _acme, "mod.boss")); + Assert.Equal(0, Run("boss", path, "on").Code); + var composition = ShipModCatalog.Compose(Shipped, Derived, Mods); + Assert.Equal(new[] { "mod.boss" }, composition.Included); + + using var cache = AetheriaStores.Open(Derived); + var hull = Assert.IsType(cache.Get(new CultRecordRef(ShipModCatalog.HullKey("mod.boss")))); + Assert.True(hull.Boss); + var acme = cache.Get(_acme); + var rival = cache.Get(_rival); + Assert.Same(hull, Assert.Single(LoadoutGenerator.BossHullsOf(cache, acme)).hull); + Assert.Empty(LoadoutGenerator.BossHullsOf(cache, rival)); + } + [Fact] public void APackageWithAnUnknownMakerIsExcluded() { @@ -299,6 +318,37 @@ public sealed class ShipProductTests : IDisposable RefusedAndUntouched("product", path, "ACM", "--remove"); } + [Fact] + public void BossCommand() + { + Environment.CurrentDirectory = _root.Path; + var path = Package("mod.skiff", "Skiffhull", 100, Product("Acme Skiff", _acme, "mod.skiff")); + void RefusedAndUntouched(params string[] args) + { + var before = File.ReadAllBytes(path); + Assert.Equal(1, Run(args).Code); + Assert.Equal(before, File.ReadAllBytes(path)); + } + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(FactionProductData).Assembly); + byte[] ProductBytes() => MessagePack.MessagePackSerializer.Serialize(ShipAuthoringStore.Load(path).Products.Single(), options); + + var productBefore = ProductBytes(); + Assert.False(ShipAuthoringStore.Read(path).Hull.Boss); + RefusedAndUntouched("boss", path, "yes"); + RefusedAndUntouched("boss", path, "ON"); + RefusedAndUntouched("boss", path); + + Assert.Equal(0, Run("boss", path, "on").Code); + Assert.True(ShipAuthoringStore.Read(path).Hull.Boss); + Assert.Equal(productBefore, ProductBytes()); + Assert.Contains(", boss", Run("inspect", path).Output); + + Assert.Equal(0, Run("boss", path, "off").Code); + Assert.False(ShipAuthoringStore.Read(path).Hull.Boss); + Assert.Equal(productBefore, ProductBytes()); + Assert.DoesNotContain(", boss", Run("inspect", path).Output); + } + [Fact] public void ValidateJudgesProductsOnADraftAndInspectListsThem() { diff --git a/tools/AetherDb/LoadoutCommitCommand.cs b/tools/AetherDb/LoadoutCommitCommand.cs new file mode 100644 index 000000000..7343dc9d8 --- /dev/null +++ b/tools/AetherDb/LoadoutCommitCommand.cs @@ -0,0 +1,125 @@ +/* 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, TextWriter output = null) + { + output ??= Console.Out; + try + { + return Commit(args, root, output); + } + catch (Exception e) when (e is InvalidOperationException or ArgumentException or FormatException) + { + output.WriteLine($"refused: {e.Message}"); + return 1; + } + } + + private static int Commit(string[] args, string root, TextWriter output) + { + 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) + { + output.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"); + output.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 5df9ed99c..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; } } @@ -583,8 +584,7 @@ public static class Program // Every CultRecordRef in the catalog that resolves to nothing, as " -> ". // Each "clear " argument unsets that member's dangling refs, or removes that dictionary's - // dangling keys (Faction.BossHull clears boss hulls that would claim a chokepoint no boss can spawn in). Dry - // run unless passed "apply", which alone opens the catalog writable and lands every changed record in one commit. + // dangling keys. Dry run unless passed "apply", which alone opens the catalog writable and lands every changed record in one commit. private static int Dangling(string[] args) { var apply = args.Contains("apply"); @@ -696,25 +696,22 @@ 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. + // 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"); - var broken = 0; + 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 boss = !faction.BossHull.IsSet() - ? "none" - : db.Cache.Get(faction.BossHull)?.Name ?? "DANGLING"; - if (boss == "DANGLING") broken++; + 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))); } - Console.WriteLine($"\n{broken} factions carry a link that would break generation"); - return broken; + return 0; } // What the saved run actually holds per zone. A zone carrying orbits but no orbital entities was packed by a @@ -808,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 @@ -816,7 +822,7 @@ public static class Program HullType.Turret => generator.GenerateTurretLoadout(), _ => generator.GenerateStationLoadout() }; - outcome = pack == null ? "NO LOADOUT (nothing suitable found)" : "ok"; + outcome = pack != null ? "ok" : "NO LOADOUT (nothing suitable found)"; if (pack == null) failures++; if (pack != null && log.Any(l => l.Contains("targeting system available", StringComparison.OrdinalIgnoreCase))) targetingGaps++; } diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index 9fd7e690a..769db37dc 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -8,7 +8,7 @@ using System.Linq; // Uses the existing database executable; mod authoring does not earn another binary target. public static class ShipAuthoringCommands { - private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] | --remove) | price | compose "; + private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] | --remove) | price | boss on|off | compose "; public static int Run(string[] args) { @@ -29,7 +29,7 @@ public static class ShipAuthoringCommands if (args[0] == "inspect" && args.Length == 2) { var (hull, ship, products) = ShipAuthoringStore.Load(args[1]); - Console.WriteLine($"{ship.Id}: {hull.Name}, {hull.Hardpoints?.Count ?? 0} hardpoints, {ship.SchematicLines?.Count ?? 0} lines, {ship.SchematicLines?.Sum(line => line.Points?.Length / 3 ?? 0) ?? 0} points"); + Console.WriteLine($"{ship.Id}: {hull.Name}, {hull.Hardpoints?.Count ?? 0} hardpoints, {ship.SchematicLines?.Count ?? 0} lines, {ship.SchematicLines?.Sum(line => line.Points?.Length / 3 ?? 0) ?? 0} points{(hull.Boss ? ", boss" : "")}"); foreach (var product in products) Console.WriteLine($"product {product.Manufacturer.Key.Value}: {product.Name}"); return 0; } @@ -91,6 +91,21 @@ public static class ShipAuthoringCommands Console.WriteLine($"{ship.Id}: price {price}."); return 0; } + if (args[0] == "boss" && args.Length == 3) + { + // Judged before the file is touched: a refusal leaves it byte-identical. Sets the hull's flag and no product. + if (args[2] != "on" && args[2] != "off") + throw new InvalidOperationException("boss must be on or off."); + var (hull, ship, _) = ShipAuthoringStore.Load(args[1]); + hull.Boss = args[2] == "on"; + using (var cache = ShipAuthoringStore.Open(args[1], writable: true)) + { + ShipAuthoringStore.Write(cache, hull, ship); + cache.FlushAsync().Wait(); + } + Console.WriteLine($"{ship.Id}: boss {args[2]}."); + return 0; + } if (args[0] == "product" && args.Length >= 4) { var remove = args.Length == 4 && args[3] == "--remove";