diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index 608ba4431..f5fb51a04 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -7,6 +7,7 @@ using CultMath; using GameCult.Caching; using GameCult.Caching.MessagePack; using Xunit; +using Random = CultMath.Random; // Loadouts are ship presets: item designs only, authored into the catalog, materialized all-or-nothing against it. // The fixture hull is 3x3: InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay could use. @@ -84,7 +85,7 @@ public sealed class LoadoutTests : IDisposable Loadout hand; using (var cache = Open()) { - var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); hand = Armed(cache); var failures = new List(); var ship = Build(items, hand, failures); @@ -102,7 +103,7 @@ public sealed class LoadoutTests : IDisposable Assert.Equal(hand.Slots.Select(Describe), loaded.Slots.Select(Describe)); Assert.Equal(hand.WeaponGroups, loaded.WeaponGroups); - var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var failures = new List(); var ship = Build(items, loaded, failures); Assert.Empty(failures); @@ -249,7 +250,7 @@ public sealed class LoadoutTests : IDisposable using (var cache = Open()) { - var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var missing = HandBuilt(cache); missing.Slots[1].Design = new CultRecordRef(new CultRecordKey("absent-design")); var failures = new List(); @@ -274,7 +275,7 @@ public sealed class LoadoutTests : IDisposable public void FailedFitBuildsNothing() { using var cache = Open(); - var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var clash = HandBuilt(cache); clash.Slots[1].Position = HardpointCell; var failures = new List(); @@ -283,24 +284,28 @@ public sealed class LoadoutTests : IDisposable Assert.Contains("slot 0,0: Crate does not fit", failure); } - // Two more Lamp products, 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. + // 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". [Fact] public void FirstAvailableProductInKeyOrderBuildsTheSlot() { using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); - var maker = cache.RefOf(cache.GetByName("Maker")); var lamp = new CultRecordRef(cache.RefOf(cache.GetByName("Lamp")).Key); - foreach (var key in new[] { "lamp-z", "lamp-a" }) - cache.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData { Name = key, Design = lamp, Manufacturer = maker }, new CultRecordKey(key))); + var zMaker = cache.Upsert(new Faction { Name = "Zeta", ShortName = "ZET" }); + var aMaker = cache.Upsert(new Faction { Name = "Alpha", ShortName = "ALP" }); + cache.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData { Name = "lamp-z", Design = lamp, Manufacturer = zMaker }, new CultRecordKey("lamp-z"))); + cache.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData { Name = "lamp-a", Design = lamp, Manufacturer = aMaker }, new CultRecordKey("lamp-a"))); Assert.Equal(new[] { "lamp-z", "lamp-a" }, cache.GetAll().Select(p => p.Name).Where(name => name.StartsWith("lamp-"))); - var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var hand = HandBuilt(cache); string LampProduct(Predicate available, List failures) { var ship = Loadouts.Materialize(items, null, hand, available, failures); - return ship == null ? null : cache.Get(ship.Equipment.Single(item => item.EquippableItem != ship.Hull && item.Position.Equals(HardpointCell)).EquippableItem.Product).Name; + if (ship == null) return null; + var unit = ship.Equipment.Single(item => item.EquippableItem != ship.Hull && item.Position.Equals(HardpointCell)).EquippableItem; + return items.Brand(unit).Product.Name; } var failures = new List(); @@ -311,11 +316,132 @@ public sealed class LoadoutTests : IDisposable Assert.Contains("slot 0,0: no available product of Lamp", Assert.Single(failures)); } + // Materialize a hand-built ship, pack it into a zone, commit through Open() and reopen: every crafted instance + // still resolves in RunSave.Lots, its Data still equals the lot's Design, the origin is Attributed with the + // product's maker, and Quality is unchanged from the lot minted before the save. + [Fact] + public void MaterializedLotsSurviveSaveAndReload() + { + Dictionary preSaveQuality; + CultRecordKey maker; + using (var cache = Open()) + { + maker = cache.RefOf(cache.GetByName("Maker")).Key; + var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var hand = HandBuilt(cache); + var failures = new List(); + var ship = Build(items, hand, failures); + Assert.Empty(failures); + + preSaveQuality = items.Lots.Lots.ToDictionary(kv => kv.Key, kv => kv.Value.Quality); + + var pack = EntitySerializer.Pack(ship); + var game = new SavedGame + { + Factions = Array.Empty>(), + Relationships = Array.Empty(), + HomeZones = new Dictionary(), + BossZones = new Dictionary(), + DiscoveredZones = new[] { 0 }, + ActionBarBindings = new SavedActionBarBinding[0], + Exit = -1 + }; + var zones = new[] + { + new SavedZone + { + Name = "Zone 0", AdjacentZones = Array.Empty(), Factions = Array.Empty(), Owner = -1, + Contents = new ZonePack { Entities = new List { pack } } + } + }; + RunSave.Commit(cache, game, zones, items.Lots); + } + + using (var cache = Open()) + { + var lots = RunSave.Lots(cache); + var run = cache.GetGlobal(); + var zone = cache.Get(run.Zones[0]); + var crafted = EntitySerializer.Items(zone.Contents.Entities[0]).OfType().ToArray(); + Assert.NotEmpty(crafted); + foreach (var instance in crafted) + { + var lot = lots[instance.Lot]; + Assert.Equal(instance.Data.Key, lot.Design.Key); + var attributed = Assert.IsType(lot.Origin); + Assert.Equal(maker, attributed.Faction.Key); + Assert.Equal(preSaveQuality[instance.Lot], lot.Quality); + } + } + } + + // FillInterior requires a capacitor product; this fixture hull adds one alongside a 4x4 hull with two identical + // Sensors hardpoints. + [Fact] + public void MatchingHardpointsShareALot() + { + using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + var maker = cache.RefOf(cache.GetByName("Maker")); + var hullShape = new Shape(4, 4); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + var hull = cache.Upsert(new HullData + { + Name = "Twin", HullType = HullType.Ship, Shape = hullShape, Price = 100, + Hardpoints = + { + new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() }, + new HardpointData { Type = HardpointType.Sensors, Position = new int2(1, 0), Shape = new Shape() } + } + }); + var capacitor = cache.Upsert(new GearData { Name = "Cap", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new CapacitorData() } }); + foreach (var (name, design) in new[] { ("Twin by Maker", hull.Key), ("Cap by Maker", capacitor.Key) }) + cache.Upsert(new FactionProductData { Name = name, Design = new CultRecordRef(design), Manufacturer = maker }); + cache.FlushAsync().Wait(); + + var random = new Random(1); + var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); + var pack = generator.GenerateShipLoadout(candidate => candidate.Name == "Twin"); + Assert.NotNull(pack); + + var hardpointItems = pack.Equipment + .Where(e => (cache.Get(e.item.Data) as GearData)?.Hardpoint == HardpointType.Sensors) + .ToArray(); + Assert.Equal(2, hardpointItems.Length); + Assert.Equal(hardpointItems[0].item.Lot, hardpointItems[1].item.Lot); + } + + // A Lamp lot with Quality .2 and one role fill (lens .9): a stat reading that role gets .9, one reading none + // gets .2, and GetTier reads .2. + [Fact] + public void StatsReadTheLot() + { + using var cache = Open(); + var settings = RunSaveTests.TestSettings(); + settings.Tiers = new[] { new RarityTier { Name = "Common", Quality = .2f, Rarity = 0, Color = new float3(1, 1, 1) } }; + var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var lamp = cache.GetByName("Lamp"); + var lotId = items.Lots.Add(new Lot + { + Design = cache.RefOf(lamp), + Origin = new Attributed(), + Quality = .2f, + Roles = new List { new RoleFill { Role = "lens", Quality = .9f } } + }); + var instance = (EquippableItem) items.CreateInstance(lotId); + + var withRole = new PerformanceStat { FromRole = "lens", Min = 0, Max = 1, QualityExponent = 1 }; + var noRole = new PerformanceStat { Min = 0, Max = 1, QualityExponent = 1 }; + Assert.Equal(.9f, items.Evaluate(withRole, instance), 3); + Assert.Equal(.2f, items.Evaluate(noRole, instance), 3); + Assert.Equal(.2f, items.GetTier(instance).tier.Quality, 3); + } + [Fact] public void OutOfRangeWeaponGroupIndexBuildsNothing() { using var cache = Open(); - var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var stale = Armed(cache); stale.WeaponGroups = new[] { new[] { 0, 2 }, new[] { -1 } }; var failures = new List(); @@ -327,7 +453,7 @@ public sealed class LoadoutTests : IDisposable public void UnequippingAGroupedWeaponRemovesItFromItsGroups() { using var cache = Open(); - var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var ship = Build(items, Armed(cache), new List()); var item = Assert.Single(ship.WeaponGroups[0].items); Assert.NotNull(item.GetBehavior()); @@ -342,7 +468,7 @@ public sealed class LoadoutTests : IDisposable public void NonWeaponGroupIndexBuildsNothing() { using var cache = Open(); - var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var lampGrouped = WithDesign(Armed(cache), 0, cache, "Lamp"); var failures = new List(); Assert.Null(Build(items, lampGrouped, failures)); @@ -353,7 +479,7 @@ public sealed class LoadoutTests : IDisposable public void MoreWeaponGroupsThanTheGameHasBuildsNothing() { using var cache = Open(); - var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var wide = Armed(cache); wide.WeaponGroups = wide.WeaponGroups.Append(new[] { 0 }).ToArray(); var failures = new List(); @@ -367,7 +493,7 @@ public sealed class LoadoutTests : IDisposable public void FewerWeaponGroupsArePaddedToTheGameCount() { using var cache = Open(); - var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var armed = Armed(cache); armed.WeaponGroups = new[] { new[] { 0 } }; foreach (var groups in new[] { armed.WeaponGroups, null }) diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 1f8a7fa7b..7f7f21709 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -40,13 +40,13 @@ public sealed class RunSaveTests : IDisposable using (var cache = Open()) { contents ??= StageZoneContents(cache); - RunSave.Commit(cache, Game(cache), Zones(3, contents, 0)); + RunSave.Commit(cache, Game(cache), Zones(3, contents, 0), new ProvenanceLedger()); } using (var cache = Open()) { - Assert.Equal(7, RunRecordCount(cache)); - Assert.Equal(7, RecordsIn(Run)); + Assert.Equal(8, RunRecordCount(cache)); + Assert.Equal(8, RecordsIn(Run)); Assert.Equal(new[] { "savedzone-0", "savedzone-1", "savedzone-2" }, SavedZoneKeys(cache)); } } @@ -57,8 +57,8 @@ public sealed class RunSaveTests : IDisposable { using (var cache = Open()) { - RunSave.Commit(cache, Game(cache), Zones(3, null, -1)); - RunSave.Commit(cache, Game(cache), Zones(2, null, -1)); + RunSave.Commit(cache, Game(cache), Zones(3, null, -1), new ProvenanceLedger()); + RunSave.Commit(cache, Game(cache), Zones(2, null, -1), new ProvenanceLedger()); } using (var cache = Open()) @@ -73,25 +73,25 @@ public sealed class RunSaveTests : IDisposable var before = new[] { Catalog, Player }.Select(Hash).ToArray(); using (var cache = Open()) - RunSave.Commit(cache, Game(cache), Zones(3, StageZoneContents(cache), 0)); + RunSave.Commit(cache, Game(cache), Zones(3, StageZoneContents(cache), 0), new ProvenanceLedger()); Assert.Equal(before, new[] { Catalog, Player }.Select(Hash).ToArray()); - Assert.Equal(7, RecordsIn(Run)); + Assert.Equal(8, RecordsIn(Run)); } [Fact] public void ResumeThenSaveKeepsUnloadedZoneContents() { using (var cache = Open()) - RunSave.Commit(cache, Game(cache), Zones(2, StageZoneContents(cache), 1)); + RunSave.Commit(cache, Game(cache), Zones(2, StageZoneContents(cache), 1), new ProvenanceLedger()); using (var cache = Open()) { var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); - var itemManager = new ItemManager(cache, TestSettings(), _ => { }); + var itemManager = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); galaxy.Zones[0].Contents = new Zone(itemManager, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy); var (game, zones) = RunSave.Capture(cache, galaxy, galaxy.Zones[0].Contents, null, false, new SavedActionBarBinding[0]); - RunSave.Commit(cache, game, zones); + RunSave.Commit(cache, game, zones, RunSave.Lots(cache)); } using (var cache = Open()) @@ -109,7 +109,7 @@ public sealed class RunSaveTests : IDisposable using (var cache = Open()) { cache.Commit(batch => batch.Upsert(new PlayerSettings())); - RunSave.Commit(cache, Game(cache), Zones(3, StageZoneContents(cache), 0)); + RunSave.Commit(cache, Game(cache), Zones(3, StageZoneContents(cache), 0), new ProvenanceLedger()); } var before = new[] { Catalog, Player }.Select(Hash).ToArray(); @@ -125,6 +125,80 @@ public sealed class RunSaveTests : IDisposable Assert.Equal(before, new[] { Catalog, Player }.Select(Hash).ToArray()); } + // Lot 1 sits on the root ship's hull; lot 3 (Produced from facility 4, input 5) sits in a docked child's + // docking-bay contents; lots 2 and 6 are minted but referenced by nothing packed. Only the reachable closure + // {1, 3, 4, 5} survives into the written copy; the live ledger keeps every lot. + [Fact] + public void CommitKeepsOnlyReachableLots() + { + using (var cache = Open()) + { + var lots = new ProvenanceLedger { NextLot = 7 }; + lots.Lots[1] = new Lot { Origin = new Attributed() }; + lots.Lots[2] = new Lot { Origin = new Attributed() }; + lots.Lots[3] = new Lot { Origin = new Produced { Facility = 4, Inputs = new[] { 5 } } }; + lots.Lots[4] = new Lot { Origin = new Attributed() }; + lots.Lots[5] = new Lot { Origin = new Extracted() }; + lots.Lots[6] = new Lot { Origin = new Attributed() }; + + var child = BarePack(hull: null, dockingBayContents: new (int2, ItemInstance)[][] + { + new (int2, ItemInstance)[] { (default, new CompoundCommodity { Lot = 3 }) } + }); + var root = BarePack(hull: new EquippableItem { Lot = 1 }, children: new EntityPack[] { child }); + + var zones = new[] + { + new SavedZone + { + Name = "Zone 0", AdjacentZones = Array.Empty(), Factions = Array.Empty(), Owner = -1, + Contents = new ZonePack { Entities = new List { root } } + } + }; + RunSave.Commit(cache, Game(cache), zones, lots); + + // The live ledger is never pruned. + Assert.Equal(new[] { 1, 2, 3, 4, 5, 6 }, lots.Lots.Keys.OrderBy(k => k)); + } + + using (var cache = Open()) + { + var stored = RunSave.Lots(cache); + Assert.Equal(new[] { 1, 3, 4, 5 }, stored.Lots.Keys.OrderBy(k => k)); + Assert.Equal(7, stored.NextLot); + } + } + + [Fact] + public void MissingLotIsLoud() + { + var ledger = new ProvenanceLedger(); + Assert.Throws(() => ledger[0]); + Assert.Throws(() => ledger.Reachable(new[] { 9 })); + } + + // A minimal, valid EntityPack with every collection field non-null, for tests that build packs directly rather + // than through EntitySerializer.Pack. + private static ShipPack BarePack(EquippableItem hull, EntityPack[] children = null, + (int2, ItemInstance)[][] dockingBayContents = null) => new ShipPack + { + Name = "ship", + Hull = hull, + Equipment = Array.Empty<(int2, EquippableItem)>(), + CargoBays = Array.Empty<(int2, EquippableItem)>(), + DockingBays = Array.Empty<(int2, EquippableItem)>(), + CargoContents = Array.Empty<(int2, ItemInstance)[]>(), + DockingBayContents = dockingBayContents ?? Array.Empty<(int2, ItemInstance)[]>(), + Children = children ?? Array.Empty(), + PersistedBehaviors = new Dictionary(), + Temperature = new float[0, 0], + Armor = new float[0, 0], + Conductivity = new bool2[0, 0], + DockingBayAssignments = Array.Empty(), + Settings = new EntitySettings(), + WeaponGroups = Array.Empty() + }; + // Zone seeds hash names with this; string.GetHashCode differs per process on .NET Core. The literal was computed // once, from an independent transcription of CultMath's pcg over the UTF-8 bytes. [Fact]