diff --git a/tests/Aetheria.Shared.Tests/MiningCut5aTests.cs b/tests/Aetheria.Shared.Tests/MiningCut5aTests.cs index d71607031..04f02164d 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut5aTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut5aTests.cs @@ -23,11 +23,11 @@ public sealed class MiningCut5aTests : IDisposable _scenes.Dispose(); } - private static SimpleCommodityData Commodity(string name, int maxStack = 100) => + internal static SimpleCommodityData Commodity(string name, int maxStack = 100) => new SimpleCommodityData { Name = name, Shape = new Shape(), Price = 5, MaxStack = maxStack }; // Registers the ores and the kind's yields in the scene's catalog (the kind refs need the stored commodities). - private static void Stock(MiningCut3Tests.Scene scene, FieldKindData kind, params (SimpleCommodityData Ore, float Abundance)[] yields) + internal static void Stock(MiningCut3Tests.Scene scene, FieldKindData kind, params (SimpleCommodityData Ore, float Abundance)[] yields) { kind.Yields.Clear(); foreach (var (ore, abundance) in yields) @@ -38,7 +38,7 @@ public sealed class MiningCut5aTests : IDisposable scene.Cache.Upsert(kind); } - private EquippedCargoBay Bay(MiningCut3Tests.Scene scene, Ship ship, int width, int height, string name) + internal static EquippedCargoBay Bay(MiningCut3Tests.Scene scene, Ship ship, int width, int height, string name) { var shape = new Shape(width, height); foreach (var cell in shape.AllCoordinates) shape[cell] = true; @@ -49,10 +49,10 @@ public sealed class MiningCut5aTests : IDisposable return bay; } - private static int Units(IEnumerable items, string ore, MiningCut3Tests.Scene scene) => + internal static int Units(IEnumerable items, string ore, MiningCut3Tests.Scene scene) => items.OfType().Where(c => scene.Items.GetData(c).Name == ore).Sum(c => c.Quantity); - private static int Units(EquippedCargoBay bay, string ore, MiningCut3Tests.Scene scene) => Units(bay.Cargo.Keys, ore, scene); + internal static int Units(EquippedCargoBay bay, string ore, MiningCut3Tests.Scene scene) => Units(bay.Cargo.Keys, ore, scene); // A rig whose rock (size .5: 4 units, whole) yields `ores`, shooter holding one bay. private (MiningCut4bTests.Rig Rig, EquippedCargoBay Bay) Mining(int bayWidth = 4, int bayHeight = 4, float damage = 1000f, diff --git a/tests/Aetheria.Shared.Tests/MiningCut5bTests.cs b/tests/Aetheria.Shared.Tests/MiningCut5bTests.cs new file mode 100644 index 000000000..45f52c144 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MiningCut5bTests.cs @@ -0,0 +1,308 @@ +/* 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; +using GameCult.Caching.MessagePack; +using MessagePack; +using Xunit; + +// Mining Cut 5b (docs/mining-cut-refresh.md, Cut 5, Q6b = A): a mined unit carries an Extracted lot naming the zone, the belt +// and the ore; stacks merge, split, restore and save by lot. Fixtures are Cut 3's belt scene and Cut 5a's bay and stock helpers. +public sealed class MiningCut5bTests : IDisposable +{ + private readonly MiningCut3Tests _scenes = new MiningCut3Tests(); + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-mining5b-" + Guid.NewGuid().ToString("N")); + private uint _ordinal; + + public MiningCut5bTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + _scenes.Dispose(); + Directory.Delete(_root, true); + } + + private sealed class Field + { + public MiningCut3Tests.Scene Scene; + public Galaxy Galaxy; + public SimpleCommodityData Ore; + } + + // Two belts of rocks (size .5: 4 whole units a rock), one ore, in a galaxy of three zones. + private Field Build() + { + var ore = MiningCut5aTests.Commodity("Rock"); + var kind = MiningCut3Tests.Kind("Asteroid", 4f); + var scene = _scenes.BuildScene(new[] { kind }, + MiningCut3Tests.Belt(kind, MiningCut3Tests.Rock(150f, 0f), MiningCut3Tests.Rock(155f, .3f)), + MiningCut3Tests.Belt(kind, MiningCut3Tests.Rock(160f, .6f))); + MiningCut5aTests.Stock(scene, kind, (ore, 1f)); + var game = new SavedGame + { + Seed = 5, Factions = new CultRecordRef[0], Relationships = new FactionRelationship[0], + HomeZones = new Dictionary(), BossZones = new Dictionary(), DiscoveredZones = new[] { 0 }, + ActionBarBindings = new SavedActionBarBinding[0], Entrance = 0, Exit = -1 + }; + var zones = Enumerable.Range(0, 3) + .Select(i => new SavedZone { Name = $"Z{i}", AdjacentZones = new int[0], Factions = new int[0], Owner = -1 }).ToArray(); + RunSave.Commit(scene.Cache, game, zones, new ProvenanceLedger()); + return new Field { Scene = scene, Galaxy = new Galaxy(scene.Cache, scene.Cache.GetGlobal(), _ => { }), Ore = ore }; + } + + private static Zone ZoneOf(Field f, int index) + { + var settings = MiningCut3Tests.PlanetSettings(); + settings.AsteroidYield = new ExponentialLerp { Minimum = 4f, Maximum = 4f, Exponent = 1f }; + return index < 0 + ? new Zone(f.Scene.Items, settings, f.Scene.Pack, new GalaxyZone { Name = "Lone", Owner = null }, null) + : new Zone(f.Scene.Items, settings, f.Scene.Pack, f.Galaxy.Zones[index], f.Galaxy); + } + + private Ship Shooter(Field f, Zone zone) + { + var ship = new Ship(f.Scene.Items, zone, MiningCut3Tests.Mint(f.Scene, f.Scene.Hull), new EntitySettings()); + zone.Admit(ship, piloted: false); + return ship; + } + + private void Mine(Field f, Zone zone, Ship ship, int belt, int rock) + { + var chunk = new ChunkId(f.Scene.Belts[belt], rock); + FireControl.Extract(zone, chunk, zone.Wear(chunk, 1e6f), ship, SimulationDice.For(zone.DropSeed, SimulationDice.OreStream, ++_ordinal)); + } + + private static SimpleCommodity[] Stacks(EquippedCargoBay bay) => bay.Cargo.Keys.OfType().OrderBy(s => s.Lot).ToArray(); + + [Fact] + public void MinedUnitsCarryAnExtractedLotNamingZoneBeltAndOre() + { + var f = Build(); + var zone = ZoneOf(f, 2); + var ship = Shooter(f, zone); + var bay = MiningCut5aTests.Bay(f.Scene, ship, 4, 4, "Hold"); + Mine(f, zone, ship, 1, 0); + + var stack = Assert.Single(Stacks(bay)); + Assert.Equal(4, stack.Quantity); + Assert.NotEqual(0, stack.Lot); + var lot = f.Scene.Items.GetLot(stack); + var origin = Assert.IsType(lot.Origin); + Assert.Equal(2, origin.Zone); + Assert.Equal(f.Scene.Belts[1], origin.Body.Key); + Assert.Equal(stack.Data.Key, origin.Commodity.Key); + Assert.Equal(stack.Data.Key, lot.Design.Key); + Assert.Equal(1f, lot.Quality); + Assert.Equal(default, lot.Product); + } + + [Fact] + public void AZoneWithNoGalaxyIsMinus1() + { + var f = Build(); + var zone = ZoneOf(f, -1); + var ship = Shooter(f, zone); + var bay = MiningCut5aTests.Bay(f.Scene, ship, 4, 4, "Hold"); + Mine(f, zone, ship, 0, 0); + Assert.Equal(-1, Assert.IsType(f.Scene.Items.GetLot(Assert.Single(Stacks(bay))).Origin).Zone); + } + + [Fact] + public void UnitsFromOneSourceStack_UnitsFromTwoSourcesDoNot() + { + var f = Build(); + var zone = ZoneOf(f, 1); + var ship = Shooter(f, zone); + var bay = MiningCut5aTests.Bay(f.Scene, ship, 4, 4, "Hold"); + Mine(f, zone, ship, 0, 0); + Mine(f, zone, ship, 0, 1); + var one = Assert.Single(Stacks(bay)); + Assert.Equal(8, one.Quantity); + + Mine(f, zone, ship, 1, 0); + var stacks = Stacks(bay); + Assert.Equal(new[] { 8, 4 }, stacks.Select(s => s.Quantity)); + Assert.NotEqual(stacks[0].Lot, stacks[1].Lot); + Assert.Equal(2, f.Scene.Items.Lots.Lots.Values.Count(l => l.Origin is Extracted)); + // Two lots of one ore still total one commodity for the market and the hold. + Assert.Equal(12, MiningCut5aTests.Units(bay, "Rock", f.Scene)); + } + + // TryFindSpace and the at-coordinate store both look for a stack to top up: a stack of another lot is not one. + [Fact] + public void ASearchAndAStoreIgnoreAStackOfAnotherLot() + { + var f = Build(); + var zone = ZoneOf(f, 0); + var ship = Shooter(f, zone); + var bay = MiningCut5aTests.Bay(f.Scene, ship, 1, 1, "Hold"); + var key = new CultRecordRef(f.Scene.Cache.RefOf(f.Ore).Key); + SimpleCommodity Units(int lot, int quantity) => new SimpleCommodity { Data = key, Lot = lot, Quantity = quantity }; + + Assert.True(bay.TryStore(Units(7, 90))); + Assert.False(bay.TryFindSpace(Units(8, 5), out _)); + Assert.True(bay.TryFindSpace(Units(7, 5), out _)); + var other = Units(8, 5); + Assert.False(bay.TryStore(other, new int2(0, 0))); + Assert.Equal(90, Assert.Single(bay.Cargo.Keys.OfType()).Quantity); + Assert.Equal(5, other.Quantity); + Assert.True(bay.TryStore(Units(7, 5), new int2(0, 0))); + Assert.Equal(95, Assert.Single(bay.Cargo.Keys.OfType()).Quantity); + } + + [Fact] + public void ASplitStackKeepsItsLot() + { + var f = Build(); + var zone = ZoneOf(f, 0); + var ship = Shooter(f, zone); + var bay = MiningCut5aTests.Bay(f.Scene, ship, 4, 4, "Hold"); + Mine(f, zone, ship, 0, 0); + var stack = Assert.Single(Stacks(bay)); + + var part = bay.Remove(stack, 3); + Assert.Equal(3, part.Quantity); + Assert.Equal(stack.Lot, part.Lot); + Assert.NotEqual(0, part.Lot); + Assert.Equal(1, stack.Quantity); + } + + // A saved stack above its design's MaxStack comes back as several stacks, every one of the saved lot. + [Fact] + public void ARestoredOversizeStackKeepsItsLot() + { + var f = Build(); + var zone = ZoneOf(f, 0); + var ship = Shooter(f, zone); + var bay = MiningCut5aTests.Bay(f.Scene, ship, 4, 4, "Hold"); + for (var rock = 0; rock < 2; rock++) Mine(f, zone, ship, 0, rock); + var lot = Assert.Single(Stacks(bay)).Lot; + + var pack = EntitySerializer.Pack(ship); + f.Ore.MaxStack = 3; + var restored = EntitySerializer.Unpack(f.Scene.Items, zone, pack); + + var stacks = restored.CargoBays.SelectMany(Stacks).ToArray(); + Assert.Equal(8, stacks.Sum(s => s.Quantity)); + Assert.True(stacks.Length >= 3); + Assert.All(stacks, s => Assert.Equal(lot, s.Lot)); + } + + private CultCache Open() => AetheriaStores.Open(Path.Combine(_root, "Aetheria.cc"), Path.Combine(_root, "run.cc"), Path.Combine(_root, "player.cc")); + + private static ShipPack PackHolding(params ItemInstance[] stacks) => new ShipPack + { + Name = "ship", Hull = new EquippableItem(), + Equipment = Array.Empty<(int2, EquippableItem)>(), CargoBays = Array.Empty<(int2, EquippableItem)>(), + DockingBays = Array.Empty<(int2, EquippableItem)>(), + CargoContents = new[] { stacks.Select((s, i) => (new int2(i, 0), s)).ToArray() }, + DockingBayContents = Array.Empty<(int2, ItemInstance)[]>(), Children = Array.Empty(), + Temperature = new float[0, 0], Armor = new float[0, 0], Conductivity = new bool2[0, 0], + DockingBayAssignments = Array.Empty(), Settings = new EntitySettings(), WeaponGroups = Array.Empty() + }; + + // The ledger as the run file holds it after a save of ships holding `held`. + private ProvenanceLedger SaveAndReload(ProvenanceLedger live, params ItemInstance[] held) + { + using (var cache = AetheriaStores.Open(Path.Combine(_root, "Aetheria.cc"), catalogWritable: true)) + { + cache.Commit(batch => batch.Upsert(new VerseGrammar { Revision = 1 })); + } + using (var cache = Open()) + { + var game = new SavedGame + { + Factions = new CultRecordRef[0], Relationships = new FactionRelationship[0], + HomeZones = new Dictionary(), BossZones = new Dictionary(), DiscoveredZones = new[] { 0 }, + ActionBarBindings = new SavedActionBarBinding[0], Exit = -1 + }; + var zone = new SavedZone + { + Name = "Zone 0", AdjacentZones = Array.Empty(), Factions = Array.Empty(), Owner = -1, + Contents = new ZonePack { Entities = new List { PackHolding(held) } } + }; + RunSave.Commit(cache, game, new[] { zone }, live); + } + using (var cache = Open()) return RunSave.Lots(cache); + } + + [Fact] + public void AMinedStackSurvivesSaveGc() + { + var f = Build(); + var zone = ZoneOf(f, 1); + var ship = Shooter(f, zone); + var bay = MiningCut5aTests.Bay(f.Scene, ship, 4, 4, "Hold"); + Mine(f, zone, ship, 0, 0); + Mine(f, zone, ship, 1, 0); + var held = Stacks(bay); + Assert.Equal(2, held.Length); + // A third lot nobody holds: floated and gone, or never collected. + var orphan = f.Scene.Items.ExtractedLot(1, new CultRecordKey("elsewhere"), f.Ore); + + var saved = SaveAndReload(f.Scene.Items.Lots, held); + + Assert.Equal(held.Select(s => s.Lot).OrderBy(i => i), saved.Lots.Keys.OrderBy(i => i)); + Assert.DoesNotContain(orphan, saved.Lots.Keys); + var origin = Assert.IsType(saved[held[0].Lot].Origin); + Assert.Equal(1, origin.Zone); + Assert.Equal(f.Scene.Belts[0], origin.Body.Key); + } + + [Fact] + public void TheLotIndexIsRebuiltOnContinue() + { + var f = Build(); + var zone = ZoneOf(f, 1); + var ship = Shooter(f, zone); + var bay = MiningCut5aTests.Bay(f.Scene, ship, 4, 4, "Hold"); + Mine(f, zone, ship, 0, 0); + var held = Assert.Single(Stacks(bay)); + + var saved = SaveAndReload(f.Scene.Items.Lots, held); + var continued = new ItemManager(1u, f.Scene.Cache, saved, RunSaveTests.TestSettings(), _ => { }); + + Assert.Equal(held.Lot, continued.ExtractedLot(1, f.Scene.Belts[0], f.Ore)); + Assert.Single(saved.Lots); + var next = continued.ExtractedLot(1, f.Scene.Belts[1], f.Ore); + Assert.NotEqual(held.Lot, next); + Assert.Equal(2, saved.Lots.Count); + // Zone and ore tell lots apart as the belt does. + Assert.NotEqual(next, continued.ExtractedLot(2, f.Scene.Belts[1], f.Ore)); + } + + // A save from before the field reached SimpleCommodity: Data, Rotation, Quantity and nothing else. + [Fact] + public void OldSavesReadLotZero() + { + var f = Build(); + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(ItemInstance).Assembly); + var key = new CultRecordRef(f.Scene.Cache.RefOf(f.Ore).Key); + var buffer = new System.Buffers.ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + writer.WriteArrayHeader(2); + writer.Write(0); // Union(0, SimpleCommodity) + writer.WriteArrayHeader(3); + MessagePackSerializer.Serialize(ref writer, key, options); + MessagePackSerializer.Serialize(ref writer, ItemRotation.None, options); + writer.Write(7); + writer.Flush(); + + var old = Assert.IsType(MessagePackSerializer.Deserialize(buffer.WrittenMemory, options)); + Assert.Equal(7, old.Quantity); + Assert.Equal(0, old.Lot); + Assert.Equal(key.Key, old.Data.Key); + + // A lot rides the wire; a Lot-0 stack is no root, so the old stack saves no lot. + var back = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize( + new SimpleCommodity { Data = key, Quantity = 2, Lot = 9 }, options), options); + Assert.Equal(9, back.Lot); + Assert.Empty(SaveAndReload(new ProvenanceLedger(), old).Lots); + } +}