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 <noreply@anthropic.com>
This commit is contained in:
Nibu 2026-11-13 03:03:25 +00:00
commit 7d9c4d4844
2 changed files with 313 additions and 5 deletions

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,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<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(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<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 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<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(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<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.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<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);
Assert.Empty(SaveAndReload(new ProvenanceLedger(), old).Lots);
}
}