From 00a036427df6ed08c7578567713e59fbd57adff5 Mon Sep 17 00:00:00 2001 From: Nibu Date: Fri, 13 Nov 2026 03:00:44 +0000 Subject: [PATCH 1/4] Lot moves to ItemInstance at key 11; stacks merge, split, restore and root by lot Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 6 +++--- Assets/Scripts/ServerShared/EntitySerializer.cs | 6 +++--- Assets/Scripts/ServerShared/ItemInstance.cs | 9 ++++++--- Assets/Scripts/ServerShared/ItemManager.cs | 5 +++-- Assets/Scripts/ServerShared/SavedGame.cs | 2 +- 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 43d9729d9..b520db17d 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -2465,7 +2465,7 @@ public class EquippedCargoBay : EquippedItem // For simple commodities, search for existing item stacks to add to foreach (var cargoItem in Cargo.Keys) { - if (!item.Data.Key.Equals(cargoItem.Data.Key)) continue; + if (!item.Data.Key.Equals(cargoItem.Data.Key) || item.Lot != cargoItem.Lot) continue; var cargoCommodity = (SimpleCommodity) cargoItem; if (cargoCommodity.Quantity >= itemData.MaxStack) continue; @@ -2563,7 +2563,7 @@ public class EquippedCargoBay : EquippedItem ItemsOfType[item.Data.Key] = new List(); ItemsOfType[item.Data.Key].Add(item); } - else if (Occupancy[cargoCoord.x, cargoCoord.y] is SimpleCommodity cargoCommodity && cargoCommodity.Data.Key.Equals(item.Data.Key)) + else if (Occupancy[cargoCoord.x, cargoCoord.y] is SimpleCommodity cargoCommodity && cargoCommodity.Data.Key.Equals(item.Data.Key) && cargoCommodity.Lot == item.Lot) { if (cargoCommodity.Quantity + item.Quantity <= itemData.MaxStack) { @@ -2643,7 +2643,7 @@ public class EquippedCargoBay : EquippedItem item.Quantity -= quantity; Mass -= itemData.Mass * quantity; ThermalMass -= itemData.Mass * itemData.SpecificHeat * quantity; - return new SimpleCommodity{Data = item.Data, Quantity = quantity, Rotation = item.Rotation}; + return new SimpleCommodity{Data = item.Data, Lot = item.Lot, Quantity = quantity, Rotation = item.Rotation}; } public void Remove(CraftedItemInstance item) diff --git a/Assets/Scripts/ServerShared/EntitySerializer.cs b/Assets/Scripts/ServerShared/EntitySerializer.cs index 9aefe5a19..73656b87e 100644 --- a/Assets/Scripts/ServerShared/EntitySerializer.cs +++ b/Assets/Scripts/ServerShared/EntitySerializer.cs @@ -60,8 +60,8 @@ public static class EntitySerializer && itemManager.GetData(commodity).MaxStack >= 1) { var maxStack = itemManager.GetData(commodity).MaxStack; - var first = new SimpleCommodity { Data = commodity.Data, Rotation = commodity.Rotation, Quantity = maxStack }; - var rest = new SimpleCommodity { Data = commodity.Data, Rotation = commodity.Rotation, Quantity = commodity.Quantity - maxStack }; + var first = new SimpleCommodity { Data = commodity.Data, Lot = commodity.Lot, Rotation = commodity.Rotation, Quantity = maxStack }; + var rest = new SimpleCommodity { Data = commodity.Data, Lot = commodity.Lot, Rotation = commodity.Rotation, Quantity = commodity.Quantity - maxStack }; if (!bay.TryStore(first, position)) rest.Quantity += first.Quantity; spill.Add((bay, rest)); return; @@ -82,7 +82,7 @@ public static class EntitySerializer var remaining = rest.Quantity; while (remaining > 0) { - var chunk = new SimpleCommodity { Data = rest.Data, Rotation = rest.Rotation, Quantity = Math.Min(remaining, maxStack) }; + var chunk = new SimpleCommodity { Data = rest.Data, Lot = rest.Lot, Rotation = rest.Rotation, Quantity = Math.Min(remaining, maxStack) }; var placed = home.TryStore(chunk) || bays.Where(b => b != home).Any(b => b.TryStore(chunk)); // A refused store may still have topped up a stack; what is left in the chunk found no room. remaining -= Math.Min(remaining, maxStack) - (placed ? 0 : chunk.Quantity); diff --git a/Assets/Scripts/ServerShared/ItemInstance.cs b/Assets/Scripts/ServerShared/ItemInstance.cs index 80a891666..a16553b9f 100644 --- a/Assets/Scripts/ServerShared/ItemInstance.cs +++ b/Assets/Scripts/ServerShared/ItemInstance.cs @@ -24,6 +24,11 @@ public abstract class ItemInstance { [JsonProperty("data"), Key(0)] public CultRecordRef Data; [JsonProperty("rotation"), Key(1)] public ItemRotation Rotation; + + // The lot this unit was minted or mined from: crafted units' design, provenance, workmanship and role fills all + // live there; a mined SimpleCommodity names its Extracted lot (zone, belt, ore). Lot 0 is no lot: bought, staged + // and loadout commodities, and every save from before the field reached SimpleCommodity. + [JsonProperty("lot"), Key(11)] public int Lot; } [Union(0, typeof(CompoundCommodity)), @@ -33,9 +38,7 @@ public abstract class ItemInstance public abstract class CraftedItemInstance : ItemInstance { // Keys 2 (Quality), 9 (Ingredients) and 10 (Product) belonged to fields moved to Lot; do not reuse them. - - // The lot this unit was minted from: its design, provenance, workmanship and role fills all live there. - [JsonProperty("lot"), Key(11)] public int Lot; + // Key 11 (Lot) lives on ItemInstance. } [MessagePackObject, JsonObject(MemberSerialization.OptIn)] diff --git a/Assets/Scripts/ServerShared/ItemManager.cs b/Assets/Scripts/ServerShared/ItemManager.cs index 090fe1545..774abbe83 100644 --- a/Assets/Scripts/ServerShared/ItemManager.cs +++ b/Assets/Scripts/ServerShared/ItemManager.cs @@ -72,8 +72,9 @@ public class ItemManager }; } - // The one resolution path from a crafted instance to the lot it was minted from. - public Lot GetLot(CraftedItemInstance item) => Lots[item.Lot]; + // The one resolution path from an instance to the lot it was minted or mined from. Lot 0 throws: a bought or + // staged commodity has no lot, and the caller asks Lot != 0 first. + public Lot GetLot(ItemInstance item) => Lots[item.Lot]; // Returns stat when not equipped: a null-entity context with no heat, no power and no modifiers. It says so // in its own name (UnequippedStatContext) rather than being a third quiet dialect of the arithmetic. diff --git a/Assets/Scripts/ServerShared/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs index 5f42de446..21f3f3b71 100644 --- a/Assets/Scripts/ServerShared/SavedGame.cs +++ b/Assets/Scripts/ServerShared/SavedGame.cs @@ -225,7 +225,7 @@ public static class RunSave var roots = zones .SelectMany(zone => zone.Contents?.Entities ?? new List()) .SelectMany(EntitySerializer.Items) - .OfType() + .Where(item => item.Lot != 0) .Select(item => item.Lot); cache.Commit(batch => { From df67c875e349a85a274018ca80950d5636eacf2e Mon Sep 17 00:00:00 2001 From: Nibu Date: Fri, 13 Nov 2026 03:03:25 +0000 Subject: [PATCH 2/4] FireControl.Extract stamps every mined stack with the Extracted lot of its zone, belt and ore Extracted gains Body at key 2. ItemManager.ExtractedLot finds or mints the lot; its (zone, belt, ore) index is derived from the ledger at construction and never saved. SavedGame roots skip null items and Lot 0. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/FireControl.cs | 7 ++++-- Assets/Scripts/ServerShared/ItemManager.cs | 26 ++++++++++++++++++++++ Assets/Scripts/ServerShared/Provenance.cs | 2 ++ Assets/Scripts/ServerShared/SavedGame.cs | 2 +- 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 3df60cf50..bb6d48d6c 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -1864,14 +1864,17 @@ public static class FireControl var at = zone.ChunkPose(chunk.Field, chunk.Index).xy; var velocity = zone.ChunkVelocity(chunk); + var zoneIndex = zone.Galaxy?.Zones == null ? -1 : Array.IndexOf(zone.Galaxy.Zones, zone.GalaxyZone); var present = zone.Entities.Contains(shooter); for (var i = 0; i < ores.Length; i++) { var key = ores[i].Commodity.Key; - var maxStack = Math.Max(1, ((SimpleCommodityData) catalog.Get(ores[i].Commodity)).MaxStack); + var commodity = (SimpleCommodityData) catalog.Get(ores[i].Commodity); + var maxStack = Math.Max(1, commodity.MaxStack); + var lot = counts[i] > 0 ? shooter.ItemManager.ExtractedLot(zoneIndex, chunk.Field, commodity) : 0; for (var left = counts[i]; left > 0; left -= maxStack) { - var stack = new SimpleCommodity { Data = new CultRecordRef(key), Quantity = Math.Min(left, maxStack) }; + var stack = new SimpleCommodity { Data = new CultRecordRef(key), Lot = lot, Quantity = Math.Min(left, maxStack) }; // A partial store leaves the remainder in Quantity and returns false, so the next bay is offered the rest. var stored = false; if (present) foreach (var bay in shooter.CargoBays) if (stored = bay.TryStore(stack)) break; diff --git a/Assets/Scripts/ServerShared/ItemManager.cs b/Assets/Scripts/ServerShared/ItemManager.cs index 774abbe83..c91568efd 100644 --- a/Assets/Scripts/ServerShared/ItemManager.cs +++ b/Assets/Scripts/ServerShared/ItemManager.cs @@ -25,6 +25,11 @@ public class ItemManager public CultCache ItemData { get; } public ProvenanceLedger Lots { get; } + + // (zone index, belt, commodity) -> the Extracted lot minted for it. Derived from Lots at construction and on mint, + // never persisted: a reload rebuilds it from the ledger, so the same source finds the same lot. + private readonly Dictionary<(int, CultRecordKey, CultRecordKey), int> _extracted = + new Dictionary<(int, CultRecordKey, CultRecordKey), int>(); public GameplaySettings GameplaySettings { get; } public ItemManager(uint seed, CultCache itemData, ProvenanceLedger lots, GameplaySettings settings, Action logger) @@ -33,6 +38,9 @@ public class ItemManager Random = new Random(seed); ItemData = itemData; Lots = lots; + foreach (var (id, lot) in lots.Lots) + if (lot.Origin is Extracted extracted) + _extracted[(extracted.Zone, extracted.Body.Key, extracted.Commodity.Key)] = id; GameplaySettings = settings; _logger = logger; } @@ -181,6 +189,24 @@ public class ItemManager return Lots.Add(lot); } + // Mining Cut 5b: the lot of every unit mined from one commodity in one belt of one zone, found or minted. Unbranded + // (Product unset) and unworked (Quality 1, no roles). FireControl.Extract is the only caller. + public int ExtractedLot(int zone, CultRecordKey belt, SimpleCommodityData commodity) + { + var key = (zone, belt, ItemData.RefOf(commodity).Key); + if (_extracted.TryGetValue(key, out var found)) return found; + var id = Lots.Add(new Lot + { + Design = ItemData.RefOf(commodity), + Origin = new Extracted + { + Zone = zone, Body = new CultRecordRef(belt), Commodity = ItemData.RefOf(commodity) + }, + Quality = 1 + }); + return _extracted[key] = id; + } + // The one writer of a crafted instance's Data: copied from the lot's design, never independently set. public CraftedItemInstance CreateInstance(int lot) { diff --git a/Assets/Scripts/ServerShared/Provenance.cs b/Assets/Scripts/ServerShared/Provenance.cs index 25a4a6175..71c260496 100644 --- a/Assets/Scripts/ServerShared/Provenance.cs +++ b/Assets/Scripts/ServerShared/Provenance.cs @@ -137,6 +137,8 @@ public class Produced : Provenance [MessagePackObject] public class Extracted : Provenance { + // Zone is the zone's index in galaxy.Zones (SavedGame's convention), -1 with no galaxy; Body is the belt. [Key(0)] public int Zone; [Key(1)] public CultRecordRef Commodity; + [Key(2)] public CultRecordRef Body; } diff --git a/Assets/Scripts/ServerShared/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs index 21f3f3b71..7c7952494 100644 --- a/Assets/Scripts/ServerShared/SavedGame.cs +++ b/Assets/Scripts/ServerShared/SavedGame.cs @@ -225,7 +225,7 @@ public static class RunSave var roots = zones .SelectMany(zone => zone.Contents?.Entities ?? new List()) .SelectMany(EntitySerializer.Items) - .Where(item => item.Lot != 0) + .Where(item => item != null && item.Lot != 0) .Select(item => item.Lot); cache.Commit(batch => { From 7d9c4d4844f7adee237176fadd34360c4b166ca4 Mon Sep 17 00:00:00 2001 From: Nibu Date: Fri, 13 Nov 2026 03:03:25 +0000 Subject: [PATCH 3/4] Tests: mined lots, stack merge and split by lot, save GC roots, rebuilt index, old saves read Lot 0 Co-Authored-By: Claude Sonnet 5.5 --- .../Aetheria.Shared.Tests/MiningCut5aTests.cs | 10 +- .../Aetheria.Shared.Tests/MiningCut5bTests.cs | 308 ++++++++++++++++++ 2 files changed, 313 insertions(+), 5 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/MiningCut5bTests.cs 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); + } +} From d3572c01906653d231310ab7fa625db63a106e9c Mon Sep 17 00:00:00 2001 From: Nibu Date: Fri, 13 Nov 2026 03:55:32 +0000 Subject: [PATCH 4/4] Save roots: every crafted item roots at Lot 0 too; pin zero-draw mint guard and both root sides Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/SavedGame.cs | 4 +- .../Aetheria.Shared.Tests/MiningCut5bTests.cs | 52 ++++++++++++++++--- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/Assets/Scripts/ServerShared/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs index 7c7952494..cf225ab8b 100644 --- a/Assets/Scripts/ServerShared/SavedGame.cs +++ b/Assets/Scripts/ServerShared/SavedGame.cs @@ -222,10 +222,12 @@ public static class RunSave .Select(stored => stored.Key) .ToArray(); game.Zones = keys.Select(key => new CultRecordRef(key)).ToArray(); + // Every crafted item roots, Lot 0 included: a crafted item with no lot is corruption and Reachable throws on it. + // A SimpleCommodity roots only when it names a lot; Lot 0 is bought, staged or loadout stock. var roots = zones .SelectMany(zone => zone.Contents?.Entities ?? new List()) .SelectMany(EntitySerializer.Items) - .Where(item => item != null && item.Lot != 0) + .Where(item => item != null && (item is CraftedItemInstance || item.Lot != 0)) .Select(item => item.Lot); cache.Commit(batch => { diff --git a/tests/Aetheria.Shared.Tests/MiningCut5bTests.cs b/tests/Aetheria.Shared.Tests/MiningCut5bTests.cs index 45f52c144..9ad960a77 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut5bTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut5bTests.cs @@ -196,9 +196,9 @@ public sealed class MiningCut5bTests : IDisposable 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 + private static ShipPack PackHolding(int hullLot, params ItemInstance[] stacks) => new ShipPack { - Name = "ship", Hull = new EquippableItem(), + Name = "ship", Hull = new EquippableItem { Lot = hullLot }, 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() }, @@ -207,9 +207,13 @@ public sealed class MiningCut5bTests : IDisposable DockingBayAssignments = Array.Empty(), Settings = new EntitySettings(), WeaponGroups = Array.Empty() }; + // The ship's hull is a crafted item and roots like any other, so the fixture gives it a lot of its own. + private int _hullLot; + // The ledger as the run file holds it after a save of ships holding `held`. private ProvenanceLedger SaveAndReload(ProvenanceLedger live, params ItemInstance[] held) { + _hullLot = live.Add(new Lot { Design = default, Origin = new Attributed(), Quality = 1f, Roles = new List() }); using (var cache = AetheriaStores.Open(Path.Combine(_root, "Aetheria.cc"), catalogWritable: true)) { cache.Commit(batch => batch.Upsert(new VerseGrammar { Revision = 1 })); @@ -225,13 +229,46 @@ public sealed class MiningCut5bTests : IDisposable var zone = new SavedZone { Name = "Zone 0", AdjacentZones = Array.Empty(), Factions = Array.Empty(), Owner = -1, - Contents = new ZonePack { Entities = new List { PackHolding(held) } } + Contents = new ZonePack { Entities = new List { PackHolding(_hullLot, held) } } }; RunSave.Commit(cache, game, new[] { zone }, live); } using (var cache = Open()) return RunSave.Lots(cache); } + // An ore the draw never picks mints no lot: the ledger gains nothing for it, only for the ore that was drawn. + [Fact] + public void AnOreThatDrawsZeroUnitsMintsNoLot() + { + var f = Build(); + var dud = MiningCut5aTests.Commodity("Dud"); + MiningCut5aTests.Stock(f.Scene, f.Scene.Kinds[0], (f.Ore, 1f), (dud, 1e-12f)); + var zone = ZoneOf(f, 0); + var ship = Shooter(f, zone); + var bay = MiningCut5aTests.Bay(f.Scene, ship, 4, 4, "Hold"); + var before = f.Scene.Items.Lots.Lots.Count; + Mine(f, zone, ship, 0, 0); + + Assert.Equal(4, MiningCut5aTests.Units(bay, "Rock", f.Scene)); + Assert.Equal(0, MiningCut5aTests.Units(bay, "Dud", f.Scene)); + Assert.Equal(before + 1, f.Scene.Items.Lots.Lots.Count); + Assert.Equal(1, f.Scene.Items.Lots.Lots.Values.Count(l => l.Origin is Extracted)); + } + + // A crafted item with no lot is corruption and the save says so; a SimpleCommodity at Lot 0 is ordinary stock. + [Fact] + public void ACraftedItemAtLotZeroFailsTheSaveLoudly() + { + var f = Build(); + var crafted = new EquippableItem { Data = f.Scene.Cache.RefOf(f.Scene.Hull), Lot = 0 }; + var thrown = Assert.Throws(() => SaveAndReload(new ProvenanceLedger(), crafted)); + Assert.Contains("Lot 0 is not in the provenance ledger", thrown.Message); + + var stock = new SimpleCommodity { Data = new CultRecordRef(f.Scene.Cache.RefOf(f.Ore).Key), Quantity = 3, Lot = 0 }; + var savedStock = SaveAndReload(new ProvenanceLedger(), stock); + Assert.Equal(new[] { _hullLot }, savedStock.Lots.Keys); + } + [Fact] public void AMinedStackSurvivesSaveGc() { @@ -248,7 +285,7 @@ public sealed class MiningCut5bTests : IDisposable 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.Equal(held.Select(s => s.Lot).Append(_hullLot).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); @@ -269,10 +306,10 @@ public sealed class MiningCut5bTests : IDisposable 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); + Assert.Equal(2, saved.Lots.Count); // the mined lot and the hull var next = continued.ExtractedLot(1, f.Scene.Belts[1], f.Ore); Assert.NotEqual(held.Lot, next); - Assert.Equal(2, saved.Lots.Count); + Assert.Equal(3, 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)); } @@ -303,6 +340,7 @@ public sealed class MiningCut5bTests : IDisposable 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); + var savedOld = SaveAndReload(new ProvenanceLedger(), old); + Assert.Equal(new[] { _hullLot }, savedOld.Lots.Keys); // the hull only } }