Merge pull request 'Mining cut 5b: extracted lots; stacks merge, split and root by lot' (#20) from eureka/aetheria-release-mining-5b into master

This commit is contained in:
Nibu 2026-11-13 05:06:11 +00:00
commit ae64dbce2c
9 changed files with 402 additions and 19 deletions

View file

@ -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<ItemInstance>();
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)

View file

@ -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);

View file

@ -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<ItemData>(key), Quantity = Math.Min(left, maxStack) };
var stack = new SimpleCommodity { Data = new CultRecordRef<ItemData>(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;

View file

@ -24,6 +24,11 @@ public abstract class ItemInstance
{
[JsonProperty("data"), Key(0)] public CultRecordRef<ItemData> 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)]

View file

@ -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<string> 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;
}
@ -72,8 +80,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.
@ -180,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<ItemData>(commodity).Key);
if (_extracted.TryGetValue(key, out var found)) return found;
var id = Lots.Add(new Lot
{
Design = ItemData.RefOf<ItemData>(commodity),
Origin = new Extracted
{
Zone = zone, Body = new CultRecordRef<BodyData>(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)
{

View file

@ -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<SimpleCommodityData> Commodity;
[Key(2)] public CultRecordRef<BodyData> Body;
}

View file

@ -222,10 +222,12 @@ public static class RunSave
.Select(stored => stored.Key)
.ToArray();
game.Zones = keys.Select(key => new CultRecordRef<SavedZone>(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<EntityPack>())
.SelectMany(EntitySerializer.Items)
.OfType<CraftedItemInstance>()
.Where(item => item != null && (item is CraftedItemInstance || item.Lot != 0))
.Select(item => item.Lot);
cache.Commit(batch =>
{

View file

@ -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<ItemInstance> items, string ore, MiningCut3Tests.Scene scene) =>
internal static int Units(IEnumerable<ItemInstance> items, string ore, MiningCut3Tests.Scene scene) =>
items.OfType<SimpleCommodity>().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,

View file

@ -0,0 +1,346 @@
/* 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<Faction>[0], Relationships = new FactionRelationship[0],
HomeZones = new Dictionary<int, int>(), BossZones = new Dictionary<int, int>(), 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<SavedGame>(), _ => { }), 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<SimpleCommodity>().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<Extracted>(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<Extracted>(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<ItemData>(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<SimpleCommodity>()).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<SimpleCommodity>()).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(int hullLot, params ItemInstance[] stacks) => new ShipPack
{
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() },
DockingBayContents = Array.Empty<(int2, ItemInstance)[]>(), Children = Array.Empty<EntityPack>(),
Temperature = new float[0, 0], Armor = new float[0, 0], Conductivity = new bool2[0, 0],
DockingBayAssignments = Array.Empty<int>(), Settings = new EntitySettings(), WeaponGroups = Array.Empty<int[]>()
};
// 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<RoleFill>() });
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<Faction>[0], Relationships = new FactionRelationship[0],
HomeZones = new Dictionary<int, int>(), BossZones = new Dictionary<int, int>(), DiscoveredZones = new[] { 0 },
ActionBarBindings = new SavedActionBarBinding[0], Exit = -1
};
var zone = new SavedZone
{
Name = "Zone 0", AdjacentZones = Array.Empty<int>(), Factions = Array.Empty<int>(), Owner = -1,
Contents = new ZonePack { Entities = new List<EntityPack> { 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<ItemData>(f.Scene.Hull), Lot = 0 };
var thrown = Assert.Throws<InvalidOperationException>(() => SaveAndReload(new ProvenanceLedger(), crafted));
Assert.Contains("Lot 0 is not in the provenance ledger", thrown.Message);
var stock = new SimpleCommodity { Data = new CultRecordRef<ItemData>(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()
{
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).Append(_hullLot).OrderBy(i => i), saved.Lots.Keys.OrderBy(i => i));
Assert.DoesNotContain(orphan, saved.Lots.Keys);
var origin = Assert.IsType<Extracted>(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.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(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));
}
// 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<ItemData>(f.Scene.Cache.RefOf(f.Ore).Key);
var buffer = new System.Buffers.ArrayBufferWriter<byte>();
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<SimpleCommodity>(MessagePackSerializer.Deserialize<ItemInstance>(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<ItemInstance>(MessagePackSerializer.Serialize<ItemInstance>(
new SimpleCommodity { Data = key, Quantity = 2, Lot = 9 }, options), options);
Assert.Equal(9, back.Lot);
var savedOld = SaveAndReload(new ProvenanceLedger(), old);
Assert.Equal(new[] { _hullLot }, savedOld.Lots.Keys); // the hull only
}
}