diff --git a/Assets/Scripts/ServerShared/ItemManager.cs b/Assets/Scripts/ServerShared/ItemManager.cs index bebfb5431..d47c6aaf8 100644 --- a/Assets/Scripts/ServerShared/ItemManager.cs +++ b/Assets/Scripts/ServerShared/ItemManager.cs @@ -140,6 +140,7 @@ public class ItemManager { Design = ItemData.RefOf(design), Origin = new Attributed { Faction = product.Manufacturer }, + Product = ItemData.RefOf(product), Quality = RollQuality(), Roles = new List() }; @@ -200,9 +201,10 @@ public class ItemManager return CreateInstance(CreateLot(product)); } - // Who made an item and what it is branded as, derived from its lot's provenance. Null maker (an unset faction, - // or an Extracted lot with none) means no brand at all; a set maker with no matching product still shows the - // maker, with no product name or flavour text. + // Who made an item and what it is branded as. The maker derives from its lot's provenance and the product is the + // one its lot records; nothing re-derives it. Null maker (an unset faction, or an Extracted lot with none) means + // no brand at all; a set maker whose lot records no product (or one the catalog no longer holds) still shows + // the maker, with no product name or flavour text. public (Faction Maker, FactionProductData Product) Brand(CraftedItemInstance item) { var lot = GetLot(item); @@ -214,14 +216,11 @@ public class ItemManager }; if (!maker.IsSet()) return (null, null); - var product = ItemData.GetAll() - .Where(p => p.Manufacturer.Key.Equals(maker.Key) && p.Design.Key.Equals(item.Data.Key)) - .OrderBy(p => ItemData.RefOf(p).Key.Value, StringComparer.Ordinal) - .FirstOrDefault(); + var product = lot.Product.IsSet() ? ItemData.Get(lot.Product) : null; return (ItemData.Get(maker), product); } - // What the player reads an item as: its product's name when its lot's maker has a product of this design, + // What the player reads an item as: its lot's product name when it records one, // otherwise the design's own name (unbranded, console-given and extracted lots, and commodities). public string Title(ItemInstance item) => (item is CraftedItemInstance crafted ? Brand(crafted).Product?.Name : null) ?? GetData(item).Name; diff --git a/Assets/Scripts/ServerShared/Provenance.cs b/Assets/Scripts/ServerShared/Provenance.cs index b04a4aeeb..80e8e31f0 100644 --- a/Assets/Scripts/ServerShared/Provenance.cs +++ b/Assets/Scripts/ServerShared/Provenance.cs @@ -58,8 +58,8 @@ public class ProvenanceLedger } } -// One lot: the design it built, who made it and from what, its rolled workmanship, and how well each design role -// was filled. Immutable once minted; instances carry a LotId, never a copy. +// One lot: the design it built, who made it and from what, its rolled workmanship, how well each design role +// was filled, and the branded product it was made as. Immutable once minted; instances carry a LotId, never a copy. [MessagePackObject] public class Lot { @@ -68,6 +68,10 @@ public class Lot [Key(2)] public float Quality; [Key(3)] public List Roles; + // The product the lot was made as: the one owner of which product names its units. Unset for unbranded lots + // (console give, presets, extracted), which brand their maker only. + [Key(4)] public CultRecordRef Product; + // The quality a stat reads for one of the design's roles. An unnamed role, a design without roles, and a lot // minted before roles existed all fall back to the lot's own workmanship. public float QualityForRole(string role) diff --git a/tests/Aetheria.Shared.Tests/ItemTitleTests.cs b/tests/Aetheria.Shared.Tests/ItemTitleTests.cs index 3df7484cc..d6c57ae2d 100644 --- a/tests/Aetheria.Shared.Tests/ItemTitleTests.cs +++ b/tests/Aetheria.Shared.Tests/ItemTitleTests.cs @@ -6,13 +6,15 @@ using GameCult.Caching; using Xunit; // Items are titled by their product (ruling catalog-grows-generic-designs-branded-products): ItemManager.Title reads -// Brand, so the lot's maker picks which of a design's products names the unit, and nothing else carries a name. +// Brand, so the lot's own product names the unit (a maker may sell several products of one design), and nothing +// else carries a name. public sealed class ItemTitleTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-itemtitle-" + Guid.NewGuid().ToString("N")); private readonly CultCache _cache; private readonly ItemManager _items; - private readonly FactionProductData _panopticon, _clapBack, _spiceBlend; + private readonly FactionProductData _panopticon, _zulu, _clapBack, _spiceBlend; + private readonly CultRecordRef _finch; private readonly CultRecordRef _stranger; private readonly CompoundCommodityData _spice; private readonly GearData _array; @@ -25,13 +27,16 @@ public sealed class ItemTitleTests : IDisposable _array = new GearData { Name = "Array", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10, Durability = 5 }; _cache.Upsert(_array); _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); - var finch = _cache.Upsert(new Faction { Name = "Finch", ShortName = "FIN" }); + var finch = _finch = _cache.Upsert(new Faction { Name = "Finch", ShortName = "FIN" }); var lucent = _cache.Upsert(new Faction { Name = "Lucent", ShortName = "LUC" }); _stranger = _cache.Upsert(new Faction { Name = "Stranger", ShortName = "STR" }); _spice = new CompoundCommodityData { Name = "Spice", Shape = new Shape(), Price = 3 }; _cache.Upsert(_spice); var design = _cache.RefOf(_cache.GetByName("Array")); _panopticon = new FactionProductData { Name = "Panopticon Prime", Design = design, Manufacturer = finch }; + // Finch's second Array product, keyed to sort after the first: a key-order lookup would hand its units the first. + _zulu = new FactionProductData { Name = "Zulu Prime", Design = design, Manufacturer = finch }; + _cache.Commit(batch => batch.Upsert(typeof(FactionProductData), _zulu, new CultRecordKey("array-finch-zulu"))); _clapBack = new FactionProductData { Name = "ClapBack Ultra", Design = design, Manufacturer = lucent }; _cache.Commit(batch => batch.Upsert(typeof(FactionProductData), _panopticon, new CultRecordKey("array-finch"))); _cache.Commit(batch => batch.Upsert(typeof(FactionProductData), _clapBack, new CultRecordKey("array-lucent"))); @@ -64,6 +69,29 @@ public sealed class ItemTitleTests : IDisposable Assert.Equal("ClapBack Ultra", _items.Title(lucent)); } + [Fact] + public void A_makers_second_product_titles_and_brands_as_itself() + { + var zulu = (CraftedItemInstance) _items.CreateInstance(_items.CreateLot(_zulu)); + Assert.Equal("Zulu Prime", _items.Title(zulu)); + var (maker, product) = _items.Brand(zulu); + Assert.Equal("Finch", maker.Name); + Assert.Equal("Zulu Prime", product.Name); + + var panopticon = _items.CreateInstance(_items.CreateLot(_panopticon)); + Assert.Equal("Panopticon Prime", _items.Title(panopticon)); + } + + [Fact] + public void A_lot_without_a_product_brands_its_maker_only() + { + var unit = (CraftedItemInstance) _items.CreateInstance(_items.CreateLot(_array, _finch, .5f)); + var (maker, product) = _items.Brand(unit); + Assert.Equal("Finch", maker.Name); + Assert.Null(product); + Assert.Equal("Array", _items.Title(unit)); + } + [Fact] public void Unbranded_units_are_titled_by_design() { diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index 0807dd234..1f2c9c842 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -313,8 +313,8 @@ public sealed class LoadoutTests : IDisposable } // Two new makers, each with one Lamp product, upserted into the live cache so its enumeration order (z before a) - // disagrees with record-key order; a reopened snapshot could already hand them back sorted. Brand() requires at - // most one product per (maker, design), so each gets its own maker rather than sharing "Maker". + // disagrees with record-key order; a reopened snapshot could already hand them back sorted. Each gets its own + // maker rather than sharing "Maker". [Fact] public void FirstAvailableProductInKeyOrderBuildsTheSlot() { @@ -372,30 +372,6 @@ public sealed class LoadoutTests : IDisposable 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. - [Fact] - public void BrandPicksFirstProductInKeyOrderForSameMakerAndDesign() - { - using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); - var maker = cache.Upsert(new Faction { Name = "TieBreaker", ShortName = "TIE" }); - var lamp = new CultRecordRef(cache.RefOf(cache.GetByName("Lamp")).Key); - cache.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData { Name = "lamp-b", Design = lamp, Manufacturer = maker }, new CultRecordKey("lamp-b"))); - cache.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData { Name = "lamp-a", Design = lamp, Manufacturer = maker }, new CultRecordKey("lamp-a"))); - - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); - var lotId = items.Lots.Add(new Lot - { - Design = cache.RefOf(cache.GetByName("Lamp")), - Origin = new Produced { Faction = maker, Station = 1, Facility = 2, Inputs = Array.Empty() }, - Quality = .5f, Roles = new List() - }); - var instance = (EquippableItem) items.CreateInstance(lotId); - var (_, product) = items.Brand(instance); - Assert.Equal("lamp-a", product.Name); - } - // F2: this test must not enumerate reloaded instances through EntitySerializer.Items, the GC root walk under // test. It hand-mints one real lot per EntityPack root (Hull, Equipment, CargoBays, DockingBays, CargoContents, // Children, DockingBayContents) so the expected lot ids are known independently of any enumerator, and reads diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 1f60fb4fe..20635e344 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -360,6 +360,48 @@ public sealed class RunSaveTests : IDisposable } } + // A lot's product is what names its units, so it must come back from the run file as the same product key; a lot + // minted without one reads unset. + [Fact] + public void ALotsProductSurvivesTheRunSave() + { + CultRecordRef product; + using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + var lamp = new CultRecordRef(cache.Upsert(new GearData { Name = "Lamp" }).Key); + var maker = cache.Upsert(new Faction { Name = "Maker", ShortName = "MAK" }); + product = new CultRecordRef(cache.Upsert( + new FactionProductData { Name = "Lamp Prime", Design = lamp, Manufacturer = maker }).Key); + cache.FlushAsync().Wait(); + } + using (var cache = Open()) + { + var design = cache.RefOf(cache.GetByName("Lamp")); + var lots = new ProvenanceLedger { NextLot = 3 }; + lots.Lots[1] = new Lot { Design = design, Origin = new Attributed(), Product = product }; + lots.Lots[2] = new Lot { Design = design, Origin = new Attributed() }; + var zone = new SavedZone + { + Name = "Zone 0", AdjacentZones = Array.Empty(), Factions = Array.Empty(), Owner = -1, + Contents = new ZonePack + { + Entities = new List + { + BarePack(hull: new EquippableItem { Data = design, Lot = 1 }), + BarePack(hull: new EquippableItem { Data = design, Lot = 2 }) + } + } + }; + RunSave.Commit(cache, Game(cache), new[] { zone }, lots); + } + using (var cache = Open()) + { + var stored = RunSave.Lots(cache); + Assert.Equal(product.Key, stored[1].Product.Key); + Assert.False(stored[2].Product.IsSet()); + } + } + // MQ4: Continue refuses a run that names a design the catalog no longer holds, naming the missing mod ships, and // never edits the save. A design can be named by an item in a zone or only by a minted lot, so each is covered. [Theory] diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index c65f4c3f2..d09410958 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -52,7 +52,7 @@ public static class Program var products = db.Cache.GetAll() .OrderBy(p => db.Cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray(); - // The manufacturer no longer lives on the design; a runtime item picks one via Brand()'s tie-break, but + // The manufacturer no longer lives on the design; a runtime item's brand is the product its lot records, but // census must not invent that same attribution for a design with several sellers. Instead it lists every // distinct maker that sells the design (via products), in record-key order. A design no product sells, // or that only unset-manufacturer products sell, shows "(none)". @@ -120,22 +120,6 @@ public static class Program } } Console.WriteLine($"\n{roleGaps} (product, role) pairs where a sold, role-bearing design's product authors no matching spread"); - - // Brand() picks the first (maker, design) product in record-key order; more than one means the choice is - // arbitrary rather than authored, which the rule until segment bands exist forbids. - var duplicateBrands = products - .Where(p => p.Manufacturer.IsSet()) - .GroupBy(p => (Maker: p.Manufacturer.Key, Design: p.Design.Key)) - .Where(g => g.Count() > 1) - .ToArray(); - Console.WriteLine($"\n{duplicateBrands.Length} (maker, design) pairs with more than one product:"); - foreach (var group in duplicateBrands) - { - var maker = db.Cache.Get(group.Key.Maker); - var design = db.Cache.Get(group.Key.Design); - Console.WriteLine($" {maker?.ShortName ?? "(unknown)"} / {design?.Name ?? group.Key.Design.Value}: " + - string.Join(", ", group.Select(p => p.Name))); - } return 0; }