Merge eureka/aetheria-release-cargo-store-owns-quantity: the cargo bay owns stack quantity; restore splits oversize stacks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-22 23:10:00 +02:00
commit 00e60344ab
5 changed files with 262 additions and 29 deletions

View file

@ -110,7 +110,7 @@ public static class CargoTransfer
}
else if (bay.ItemsOfType.TryGetValue(order.Item.Data.Key, out var stacks))
{
var stack = stacks.OfType<SimpleCommodity>().FirstOrDefault(s => s.Quantity > 0);
var stack = stacks.OfType<SimpleCommodity>().FirstOrDefault();
if (stack != null) return (bay, stack);
}
}

View file

@ -2530,9 +2530,12 @@ public class EquippedCargoBay : EquippedItem
// Try to store the given commodity at the given position
// If there's a stack at the given position it will be added to
// Returns true only when ALL of the items are successfully stored
// The bay decides what quantity may sit in it: a stack below one unit or above its design's MaxStack is refused with
// the bay as it was, so no caller, loot, pickup or restore has to check for it.
public bool TryStore(SimpleCommodity item, int2 cargoCoord)
{
var itemData = ItemManager.GetData(item);
if (item.Quantity < 1 || item.Quantity > itemData.MaxStack) return false;
if (ItemFits(item, cargoCoord))
{
foreach (var p in itemData.Shape.Coordinates)

View file

@ -47,6 +47,51 @@ public static class EntitySerializer
return pack;
}
// A saved stack goes back where it was. The bay refuses what cannot sit in it, and the refusal is logged, never
// silent: a stack below one unit (the typed -5 purchase of old saves) is dropped. A stack above its design's MaxStack
// (an ordinary purchase before the bay capped stacks, or a catalog MaxStack since lowered) is a legitimate save: its
// first MaxStack units go back where it was and the rest is returned to be placed by SpillOversize once every stack
// has its own cell, so a spill cannot take a cell a later stack was saved in.
private static void RestoreStack(ItemManager itemManager, EquippedCargoBay bay, int2 position, ItemInstance item,
List<(EquippedCargoBay home, SimpleCommodity rest)> spill)
{
if (item is SimpleCommodity commodity && commodity.Quantity > itemManager.GetData(commodity).MaxStack
&& 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 };
if (!bay.TryStore(first, position)) rest.Quantity += first.Quantity;
spill.Add((bay, rest));
return;
}
if (bay.TryStore(item, position)) return;
var quantity = item is SimpleCommodity c ? $" x{c.Quantity}" : "";
itemManager.Log($"Dropped a saved item the bay refused at {position}: {itemManager.GetData(item).Name}{quantity}");
}
// Places the units an oversize saved stack left over, in stacks of at most MaxStack: the stack's own bay first, then
// the entity's other bays. Only the units that fit nowhere are dropped, logged with their design and quantity.
private static void SpillOversize(ItemManager itemManager, IReadOnlyList<EquippedCargoBay> bays,
List<(EquippedCargoBay home, SimpleCommodity rest)> spill)
{
foreach (var (home, rest) in spill)
{
var maxStack = itemManager.GetData(rest).MaxStack;
var remaining = rest.Quantity;
while (remaining > 0)
{
var chunk = new SimpleCommodity { Data = rest.Data, 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);
if (placed) continue;
itemManager.Log($"Dropped saved units no bay has room for: {itemManager.GetData(rest).Name} x{remaining}");
break;
}
}
}
public static Entity Unpack(ItemManager itemManager, Zone zone, EntityPack pack)
{
return pack switch
@ -100,15 +145,18 @@ public static class EntitySerializer
entity.DockingBays[i].DockedShip = entity.Children[pack.DockingBayAssignments[i]] as Ship;
}
var spill = new List<(EquippedCargoBay home, SimpleCommodity rest)>();
for (var bayIndex = 0; bayIndex < pack.CargoContents.Length; bayIndex++)
if(entity.CargoBays.Count >= bayIndex + 1)
foreach (var (position, item) in pack.CargoContents[bayIndex])
entity.CargoBays[bayIndex].TryStore(item, position);
RestoreStack(itemManager, entity.CargoBays[bayIndex], position, item, spill);
for (var bayIndex = 0; bayIndex < pack.DockingBayContents.Length; bayIndex++)
if(entity.DockingBays.Count >= bayIndex + 1)
foreach (var (position, item) in pack.DockingBayContents[bayIndex])
entity.DockingBays[bayIndex].TryStore(item, position);
RestoreStack(itemManager, entity.DockingBays[bayIndex], position, item, spill);
SpillOversize(itemManager, entity.CargoBays.Cast<EquippedCargoBay>().Concat(entity.DockingBays).ToList(), spill);
entity.Temperature = pack.Temperature;

View file

@ -79,21 +79,14 @@ public static class StationServices
return BuyResult.Bought;
}
// The purchase of `quantity` units of a station stack, one lot of at most MaxStack at a time. `deliver` puts a lot of
// that many units where it goes, and each lot is charged for the units it delivered. The first lot the station
// cannot sell or the player cannot take ends the purchase, with the lots before it bought.
// The purchase of `quantity` units of a station stack, delivered as one lot. A bay holds no stack above its
// design's MaxStack, so a stack the station holds is a lot already; a stack above it comes from outside a bay and is
// refused, as is a purchase of more than the stack holds or of a stack that holds nothing. All of that is refused
// before anything moves, so `deliver` moves units the station has and the charge is for the units it delivered.
public static BuyResult TryBuyLots(ItemManager items, SimpleCommodity stock, int quantity, ref int credits, Func<int, bool> deliver)
{
if (quantity < 1) return BuyResult.NoRoom;
var maxStack = items.GetData(stock).MaxStack;
for (var remaining = quantity; remaining > 0;)
{
var lot = Math.Min(remaining, maxStack);
var result = TryBuy(items, stock, lot, ref credits, () => deliver(lot));
if (result != BuyResult.Bought) return result;
remaining -= lot;
}
return BuyResult.Bought;
if (quantity < 1 || quantity > stock.Quantity || stock.Quantity > items.GetData(stock).MaxStack) return BuyResult.NoRoom;
return TryBuy(items, stock, quantity, ref credits, () => deliver(quantity));
}
// The quantity a Buy Quantity field may hold: what was typed, at most what the credits cover and what the station

View file

@ -697,15 +697,14 @@ public sealed class CargoTransferTests : IDisposable
Assert.Equal(5, order.Remaining);
}
// An empty stack in a hold is no unit to give: the give moves the real units and then has nothing left.
// A give of more than the hold has moves the real units and then has nothing left.
[Fact]
public void AnEmptyStackIsNotAUnitToGive()
public void AGiveOfMoreThanTheHoldHasEndsNothingLeft()
{
var giver = ShipWithBays("Crate");
var receiver = ShipWithBays("Crate");
var grit = Commodity("Grit", 2);
Assert.True(giver.CargoBays[0].TryStore(grit));
Assert.True(giver.CargoBays[0].TryStore(Commodity("Grit", 0)));
Place(giver, receiver);
giver.Command.Give(receiver, grit, 3);
@ -736,31 +735,32 @@ public sealed class CargoTransferTests : IDisposable
// ---- The lot a purchase delivers ---------------------------------------------------------------------------
[Fact]
public void APurchaseChargesForEachLotItDelivers()
public void APurchaseOfAStackIsOneLotChargedForWhatItDelivers()
{
// Grit stacks hold two. Five units are three lots, and each lot is charged for what it delivers.
var stock = Commodity("Grit", 5);
// Grit stacks hold two: a station stack of two is one lot.
var stock = Commodity("Grit", 2);
var credits = 100;
var delivered = new List<int>();
var result = StationServices.TryBuyLots(_items, stock, 5, ref credits, lot => { delivered.Add(lot); return true; });
var result = StationServices.TryBuyLots(_items, stock, 2, ref credits, lot => { delivered.Add(lot); return true; });
Assert.Equal(BuyResult.Bought, result);
Assert.Equal(new[] { 2, 2, 1 }, delivered);
Assert.Equal(100 - 5, credits);
Assert.Equal(new[] { 2 }, delivered);
Assert.Equal(100 - 2, credits);
}
[Fact]
public void ARefusedLotLeavesEarlierLotsBoughtAndChargesNothingMore()
public void AStackAboveMaxStackFromOutsideABayIsRefusedBeforeAnythingMoves()
{
var stock = Commodity("Grit", 5);
var credits = 100;
var lots = 0;
var delivered = 0;
var result = StationServices.TryBuyLots(_items, stock, 5, ref credits, lot => ++lots < 2);
var result = StationServices.TryBuyLots(_items, stock, 2, ref credits, lot => { delivered++; return true; });
Assert.Equal(BuyResult.NoRoom, result);
Assert.Equal(100 - 2, credits);
Assert.Equal(0, delivered);
Assert.Equal(100, credits);
}
[Theory]
@ -775,4 +775,193 @@ public sealed class CargoTransferTests : IDisposable
var stock = Commodity("Ore", 10); // 7 credits each
Assert.Equal(expected, StationServices.BuyableQuantity(_items, stock, typed, credits));
}
// ---- The bay owns the quantity that may sit in it ---------------------------------------------------------
[Theory]
[InlineData("Ore", 0)]
[InlineData("Ore", -3)]
[InlineData("Ore", int.MinValue)]
[InlineData("Grit", 3)] // MaxStack 2
[InlineData("Ore", 101)]
public void TheBayRefusesAStackBelowOneOrAboveMaxStack(string design, int quantity)
{
var bay = ShipWith("Crate").CargoBays[0];
var massBefore = bay.Mass;
Assert.False(bay.TryStore(Commodity(design, quantity)));
Assert.False(bay.TryStore(Commodity(design, quantity), new int2(0, 0)));
Assert.Empty(bay.Cargo);
Assert.Equal(massBefore, bay.Mass);
}
[Fact]
public void ANegativeStackNeverMergesIntoAHeldOne()
{
var bay = ShipWith("Crate").CargoBays[0];
var held = Commodity("Ore", 5);
Assert.True(bay.TryStore(held));
var at = bay.Cargo[held];
Assert.False(bay.TryStore(Commodity("Ore", -3), at));
Assert.Equal(5, held.Quantity);
}
[Fact]
public void AMergeThatOverflowsFillsTheHeldStackToMaxAndLeavesTheRestWithTheCaller()
{
var bay = ShipWith("Crate").CargoBays[0];
var held = Commodity("Grit", 1);
Assert.True(bay.TryStore(held));
var incoming = Commodity("Grit", 2);
Assert.False(bay.TryStore(incoming, bay.Cargo[held]));
Assert.Equal(2, held.Quantity);
Assert.Equal(1, incoming.Quantity);
}
[Fact]
public void APurchaseAboveTheStationStockIsRefusedWithoutCharge()
{
// Real bays, Grit stacks hold two: a stock of 3 cannot be bought as 5. Before, the second lot took the 1-unit
// remainder and was charged for 2.
var station = Station().CargoBays[0];
var player = ShipWith("Crate").CargoBays[0];
var stock = Commodity("Grit", 2);
Assert.True(station.TryStore(stock));
var second = Commodity("Grit", 1);
Assert.True(station.TryStore(second, new int2(1, 0)));
var credits = 100;
var result = StationServices.TryBuyLots(_items, stock, 5, ref credits, lot => station.TryTransferItem(player, stock, lot));
Assert.Equal(BuyResult.NoRoom, result);
Assert.Equal(100, credits);
Assert.Equal(0, Units(player, stock));
}
[Theory]
[InlineData(-5)]
[InlineData(0)]
public void APurchaseOfNoUnitsIsRefusedNotBought(int quantity)
{
var stock = Commodity("Ore", 10);
var credits = 100;
var result = StationServices.TryBuyLots(_items, stock, quantity, ref credits, lot => true);
Assert.Equal(BuyResult.NoRoom, result);
Assert.Equal(100, credits);
}
[Fact]
public void APurchaseOfAnEmptyStationStackChargesNothingAndDeliversNothing()
{
// An old save's zero stack, as the station's stock. The field clamps the typed quantity up to one unit.
var stock = Commodity("Ore", 0);
var credits = 100;
var delivered = 0;
var result = StationServices.TryBuyLots(_items, stock, StationServices.BuyableQuantity(_items, stock, 1, credits), ref credits,
lot => { delivered += lot; return true; });
Assert.Equal(BuyResult.NoRoom, result);
Assert.Equal(100, credits);
Assert.Equal(0, delivered);
}
[Fact]
public void ThePurchaseOfTheWholeStockChargesTheUnitsTheBayMoved()
{
var station = Station().CargoBays[0];
var player = ShipWith("Crate").CargoBays[0];
var stock = Commodity("Grit", 2);
Assert.True(station.TryStore(stock));
var more = Commodity("Grit", 1);
Assert.True(station.TryStore(more, new int2(1, 0)));
var credits = 100;
var result = StationServices.TryBuyLots(_items, stock, 2, ref credits, lot => station.TryTransferItem(player, stock, lot));
Assert.Equal(BuyResult.Bought, result);
Assert.Equal(98, credits);
Assert.Equal(2, Units(player, stock));
}
[Fact]
public void ARestoredBayDropsAStackBelowOneAndLogsWhy()
{
var log = new List<string>();
var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read<GameplaySettings>("GameplaySettings");
var items = new ItemManager(1u, _cache, _items.Lots, settings, log.Add);
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null);
var ship = ShipWith("Crate");
Assert.True(ship.CargoBays[0].TryStore(Commodity("Slag", 4)));
var pack = (ShipPack) EntitySerializer.Pack(ship);
// The typed -5 purchase of old saves: a negative stack in the hold, beside a good one.
pack.CargoContents[0] = pack.CargoContents[0].Concat(new[] { (new int2(3, 3), (ItemInstance) Commodity("Ore", -5)) }).ToArray();
var restored = (Ship) EntitySerializer.Unpack(items, zone, pack);
var bay = restored.CargoBays[0];
Assert.Equal(new[] { 4 }, bay.Cargo.Keys.OfType<SimpleCommodity>().Select(c => c.Quantity).ToArray());
Assert.Contains(log, l => l.Contains("-5") && l.Contains("Ore"));
}
// ---- Saves from before the bay capped a stack -------------------------------------------------------------
// A pre-f0d7c12c purchase above MaxStack left a whole oversize stack in the hold. The save holds it as one stack.
private (Ship restored, List<string> log) RestoreWithOversize(string[] bays, int oversize, int filler)
{
var log = new List<string>();
var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read<GameplaySettings>("GameplaySettings");
var items = new ItemManager(1u, _cache, _items.Lots, settings, log.Add);
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null);
var ship = new Ship(_items, _zone, Mint("Skiff"), new EntitySettings());
for (var i = 0; i < bays.Length; i++) Assert.True(ship.TryEquip(Mint(bays[i]), new int2(1 + 2 * i, 1)));
var pack = (ShipPack) EntitySerializer.Pack(ship);
// Bay 0 holds the oversize stack at (3,3) and `filler` full Slag stacks in the other cells; later bays are empty.
for (var b = 0; b < bays.Length; b++)
{
var contents = new List<(int2, ItemInstance)>();
if (b == 0) contents.Add((new int2(3, 3), Commodity("Ore", oversize)));
for (var n = 0; n < (b == 0 ? filler : 0); n++) contents.Add((new int2(n % 4, n / 4), Commodity("Slag", 100)));
pack.CargoContents[b] = contents.ToArray();
}
return ((Ship) EntitySerializer.Unpack(items, zone, pack), log);
}
[Fact]
public void ARestoredOversizeStackSplitsIntoMaxStackStacksInTheSameBay()
{
var (restored, log) = RestoreWithOversize(new[] { "Crate" }, 250, 0);
var ore = restored.CargoBays[0].Cargo.Keys.OfType<SimpleCommodity>().Select(c => c.Quantity).OrderByDescending(q => q).ToArray();
Assert.Equal(new[] { 100, 100, 50 }, ore);
Assert.DoesNotContain(log, l => l.Contains("Dropped"));
}
[Fact]
public void ARestoredOversizeStackSpillsToAnotherBayWhenItsOwnIsFull()
{
// Bay 0 has 15 cells of filler and the oversize stack's own cell: only 100 fits there.
var (restored, log) = RestoreWithOversize(new[] { "Crate", "Crate" }, 250, 15);
Assert.Equal(new[] { 100, 100, 50 }, restored.CargoBays.SelectMany(b => b.Cargo.Keys.OfType<SimpleCommodity>())
.Where(c => _cache.GetByName<SimpleCommodityData>("Ore").Name == _items.GetData(c).Name).Select(c => c.Quantity).OrderByDescending(q => q).ToArray());
Assert.Equal(1, restored.CargoBays[0].Cargo.Keys.OfType<SimpleCommodity>().Count(c => _items.GetData(c).Name == "Ore"));
Assert.DoesNotContain(log, l => l.Contains("Dropped"));
}
[Fact]
public void ARestoredOversizeStackDropsOnlyWhatFitsNowhereAndLogsIt()
{
var (restored, log) = RestoreWithOversize(new[] { "Crate" }, 250, 15);
Assert.Equal(new[] { 100 }, restored.CargoBays[0].Cargo.Keys.OfType<SimpleCommodity>()
.Where(c => _items.GetData(c).Name == "Ore").Select(c => c.Quantity).ToArray());
Assert.Contains(log, l => l.Contains("Ore") && l.Contains("150"));
}
}