From 5706b657e312f3f6ed48c0f7fa17c88744fade91 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 12:33:00 +0200 Subject: [PATCH] Scenarios Cut 1: Materialize builds product-less designs unbranded; the hull picks the entity Two rules on Loadouts.Materialize (docs/scenarios-cut.md, 2.2, Q3): - A design no product makes is outside the economy and is built unbranded, with no maker, at Loadouts.UnbrandedQuality (.95, the console give's). A design whose products are all unavailable still fails, as before. - The hull's type picks the entity: a ship hull builds a Ship, a turret hull an OrbitalEntity with no orbit; a station hull is refused. Materialize now returns Entity. Loadouts.Resolve is the one rule for how a design is built; RunStart uses it for scenario cargo. LoadoutTests.MissingDesignReports no longer uses the product-less "Orphan" as a failure (it now builds); two tests pin the Q3 rule and the unavailable-product contract it does not loosen. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Loadout.cs | 102 +++++++++++++------- tests/Aetheria.Shared.Tests/LoadoutTests.cs | 34 ++++++- 2 files changed, 98 insertions(+), 38 deletions(-) diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index d8b868cf5..d4a7262b1 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -27,7 +27,7 @@ public class LoadoutSlot [Key(2)] public CultRecordRef Design; } -// The owner of loadouts: capture from a live ship, the only writer, and the only builder of a ship from a loadout. +// 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 { // Records the hull design and one slot per equipped item (gear, then cargo bays, then docking bays) with its cell, @@ -86,36 +86,30 @@ public static class Loadouts }); } - // All-or-nothing: returns a ship only when every design resolved, had an available product and fitted, the loadout - // has at most 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. A design is built by its first available product in record-key order. A game spawner is to - // pass LoadoutGenerator.IsAvailable as isAvailable, with no fallback to any manufacturer. The ship 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, so the next commit drops them. - public static Ship Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout, + // The quality a design no manufacturer makes is built at: the console give's. + public const float UnbrandedQuality = .95f; + + // All-or-nothing: returns an entity only when every design resolved and fitted, the loadout has at most + // 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 game spawner is to pass + // LoadoutGenerator.IsAvailable as isAvailable, with no fallback to any manufacturer; RunStart does. 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, + // so the next commit drops them. + public static Entity Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout, Predicate isAvailable, List failures) { var cache = itemManager.ItemData; - var products = cache.GetAll().OrderBy(p => cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray(); + var products = ProductsInKeyOrder(cache); var reported = failures.Count; - FactionProductData Resolve(CultRecordRef design, string where) where T : EquippableItemData - { - var data = cache.Get(design); - if (data == null) - { - failures.Add($"{where}: design {design.Key} is not in the catalog"); - return null; - } - - var product = products.FirstOrDefault(p => p.Design.Key.Equals(design.Key) && isAvailable(p)); - if (product == null) failures.Add($"{where}: no available product of {data.Name}"); - return product; - } - - var hullProduct = Resolve(loadout.Hull, "hull"); - var slotProducts = loadout.Slots.Select(slot => Resolve(slot.Design, Cell(slot))).ToArray(); + var hullBuild = Resolve(itemManager, products, loadout.Hull, isAvailable, "hull", failures); + 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"); + var slotBuilds = loadout.Slots.Select(slot => Resolve(itemManager, products, slot.Design, isAvailable, Cell(slot), failures)).ToArray(); var groups = loadout.WeaponGroups ?? new int[0][]; var groupCount = itemManager.GameplaySettings.WeaponGroupCount; if (groups.Length > groupCount) failures.Add($"weapon groups: {groups.Length} groups, at most {groupCount}"); @@ -127,23 +121,26 @@ public static class Loadouts } if (failures.Count > reported) return null; - var hull = (EquippableItem) itemManager.CreateInstance(hullProduct); - var ship = new Ship(itemManager, liveZone, hull, itemManager.GameplaySettings.DefaultEntitySettings); + var hull = hullBuild(); + var settings = itemManager.GameplaySettings.DefaultEntitySettings; + Entity entity = hullData.HullType == HullType.Turret + ? new OrbitalEntity(itemManager, liveZone, hull, default, settings) + : new Ship(itemManager, liveZone, hull, settings); var equipped = new EquippedItem[loadout.Slots.Count]; for (var i = 0; i < loadout.Slots.Count; i++) { var slot = loadout.Slots[i]; - var unit = (EquippableItem) itemManager.CreateInstance(slotProducts[i]); + var unit = slotBuilds[i](); unit.Rotation = slot.Rotation; - if (!ship.TryEquip(unit, slot.Position)) + if (!entity.TryEquip(unit, slot.Position)) { failures.Add($"{Cell(slot)}: {cache.Get(slot.Design).Name} does not fit"); continue; } - equipped[i] = ship.Equipment - .Concat(ship.CargoBays) - .Concat(ship.DockingBays) + equipped[i] = entity.Equipment + .Concat(entity.CargoBays) + .Concat(entity.DockingBays) .First(item => item.EquippableItem == unit); } if (failures.Count > reported) return null; @@ -154,9 +151,44 @@ public static class Loadouts var items = g < groups.Length ? groups[g].Select(i => equipped[i]).ToList() : new List(); built[g] = (items.Select(item => item.GetBehavior()).ToList(), items); } - ship.WeaponGroups = built; - return ship; + entity.WeaponGroups = built; + return entity; } + // How one design is built, or null with the failure listed; nothing is minted until the returned builder runs. A + // design is built by its first available product in record-key order. A design with no product at all is outside + // the economy (docs/scenarios-cut.md, Q3: generation, stock and sale all go through products) and is built + // unbranded, with no maker, at UnbrandedQuality. A design whose products are all unavailable is not built. + public static Func Resolve(ItemManager itemManager, CultRecordRef design, + Predicate isAvailable, string where, List failures) where T : EquippableItemData => + Resolve(itemManager, ProductsInKeyOrder(itemManager.ItemData), design, isAvailable, where, failures); + + private static Func Resolve(ItemManager itemManager, FactionProductData[] products, + CultRecordRef design, Predicate isAvailable, string where, List failures) + where T : EquippableItemData + { + var data = itemManager.ItemData.Get(design); + if (data == null) + { + failures.Add($"{where}: design {design.Key} is not in the catalog"); + return null; + } + + var made = products.Where(p => p.Design.Key.Equals(design.Key)).ToArray(); + if (made.Length == 0) + return () => (EquippableItem) itemManager.CreateInstance(itemManager.CreateLot(data, default, UnbrandedQuality)); + + var product = made.FirstOrDefault(p => isAvailable(p)); + if (product == null) + { + failures.Add($"{where}: no available product of {data.Name}"); + return null; + } + return () => (EquippableItem) itemManager.CreateInstance(product); + } + + private static FactionProductData[] ProductsInKeyOrder(CultCache cache) => + cache.GetAll().OrderBy(p => cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray(); + private static string Cell(LoadoutSlot slot) => $"slot {slot.Position.x},{slot.Position.y}"; } diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index 48cd75cbd..03ea71364 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -264,13 +264,13 @@ public sealed class LoadoutTests : IDisposable Assert.Contains("1,1", failure); Assert.Contains("absent-design", failure); - missing.Slots.Add(new LoadoutSlot { Position = new int2(2, 2), Design = cache.RefOf(cache.GetByName("Orphan")) }); + missing.Slots.Add(new LoadoutSlot { Position = new int2(2, 2), Design = new CultRecordRef(new CultRecordKey("another-absent-design")) }); failures.Clear(); Assert.Null(Build(items, missing, failures)); Assert.Equal(2, failures.Count); Assert.Contains("slot 1,1", failures[0]); Assert.Contains("slot 2,2", failures[1]); - Assert.Contains("no available product of Orphan", failures[1]); + Assert.Contains("another-absent-design", failures[1]); } Assert.Equal(before, new[] { Catalog, Run, Player }.Select(Hash).ToArray()); @@ -321,6 +321,34 @@ public sealed class LoadoutTests : IDisposable Assert.Contains("slot 0,0: no available product of Lamp", Assert.Single(failures)); } + // Scenarios Q3 (docs/scenarios-cut.md): a design no product makes is outside the economy, and a preset builds it + // unbranded -- no maker -- at the one named quality. "Orphan" has no product in this catalog. + [Fact] + public void ADesignWithNoProductBuildsUnbranded() + { + using var cache = Open(); + var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var failures = new List(); + var ship = Build(items, WithDesign(HandBuilt(cache), 0, cache, "Orphan"), failures); + Assert.Empty(failures); + var unit = ship.Equipment.Single(item => item.EquippableItem != ship.Hull && item.Position.Equals(HardpointCell)).EquippableItem; + Assert.Equal("Orphan", items.GetData(unit).Name); + Assert.Null(items.Brand(unit).Maker); + Assert.Equal(Loadouts.UnbrandedQuality, items.GetLot(unit).Quality); + } + + // The existing contract Q3 does not loosen: a design that has a product, none of them available, is not built, + // unbranded or otherwise. + [Fact] + public void ADesignWhoseOnlyProductIsUnavailableStillFails() + { + using var cache = Open(); + var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var failures = new List(); + Assert.Null(Loadouts.Materialize(items, null, HandBuilt(cache), p => p.Name != "Lamp by Maker", failures)); + Assert.Equal("slot 0,0: no available product of Lamp", Assert.Single(failures)); + } + // Census's duplicate (maker, design) detector exists precisely because this should never happen in an // authored catalog; Brand()'s tie-break is what a runtime item falls back on if it ever does, so it must stay // deterministic rather than however the cache happens to enumerate. @@ -895,7 +923,7 @@ public sealed class LoadoutTests : IDisposable } // Everything available: for tests about placement and failure lists. - private static Ship Build(ItemManager items, Loadout loadout, List failures) => + private static Entity Build(ItemManager items, Loadout loadout, List failures) => Loadouts.Materialize(items, null, loadout, _ => true, failures); private static (int2, ItemRotation, CultRecordKey) Describe(LoadoutSlot slot) => (slot.Position, slot.Rotation, slot.Design.Key);