From 3fa4768d1c76353c9a2b76a4daa5d7ae9aa71b8d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:31:18 +0200 Subject: [PATCH 01/12] Cargo transfer tests: a partial commodity move duplicates the stack left behind (red), also through the buy path Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/CargoTransferTests.cs diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs new file mode 100644 index 000000000..ce8732fe1 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; + +// Goods moving bay to bay between ships and stations: the one conserving commit (EquippedCargoBay.TryTransferItem) that +// the docked trade, the sale and the give all go through. Fixture as StationServicesTests: authored settings, Ore. +public sealed class CargoTransferTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-cargotransfer-" + Guid.NewGuid().ToString("N")); + private readonly CultCache _cache; + private readonly ItemManager _items; + private readonly Zone _zone; + + public CargoTransferTests() + { + Directory.CreateDirectory(_root); + _cache = AetheriaStores.Open(Path.Combine(_root, "Aetheria.cc"), catalogWritable: true); + _cache.Upsert(new VerseGrammar { Revision = 1 }); + var shape = new Shape(5, 5); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + _cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = shape, Price = 1000, Durability = 10, Armor = 10 }); + _cache.Upsert(new HullData { Name = "Hub", HullType = HullType.Station, Shape = shape, Price = 5000, Durability = 10 }); + var bay = new Shape(4, 4); + foreach (var cell in bay.AllCoordinates) bay[cell] = true; + _cache.Upsert(new CargoBayData { Name = "Crate", Shape = new Shape(), InteriorShape = bay, Price = 20, Durability = 4 }); + var pocket = new Shape(1, 1); + foreach (var cell in pocket.AllCoordinates) pocket[cell] = true; + _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); + _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); + _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); + _cache.FlushAsync().Wait(); + + var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); + _items = new ItemManager(1u, _cache, new ProvenanceLedger(), settings, _ => { }); + _zone = new Zone(_items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + } + + public void Dispose() + { + _cache.Dispose(); + Directory.Delete(_root, true); + } + + private EquippableItem Mint(string design) + { + var data = _cache.GetByName(design); + return new EquippableItem + { + Data = _cache.RefOf(data), + Durability = data.Durability, + Lot = _items.Lots.Add(new Lot { Design = _cache.RefOf(data), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + } + + private SimpleCommodity Commodity(string design, int quantity) => + new SimpleCommodity { Data = _cache.RefOf(_cache.GetByName(design)), Quantity = quantity }; + + private Ship ShipWith(string bay) + { + var ship = new Ship(_items, _zone, Mint("Skiff"), new EntitySettings()); + Assert.True(ship.TryEquip(Mint(bay), new int2(1, 1))); + return ship; + } + + private OrbitalEntity Station() + { + var station = new OrbitalEntity(_items, _zone, Mint("Hub"), default, new EntitySettings()); + Assert.True(station.TryEquip(Mint("Crate"), new int2(1, 1))); + return station; + } + + // The units of the same design as `like` that the bay holds, over every stack. + private static int Units(EquippedCargoBay bay, SimpleCommodity like) => + bay.Cargo.Keys.OfType().Where(c => c.Data.Key.Equals(like.Data.Key)).Sum(c => c.Quantity); + + [Fact] + public void PartialCommodityTransferConserves() + { + var from = ShipWith("Crate").CargoBays[0]; + var to = ShipWith("Crate").CargoBays[0]; + var ore = Commodity("Ore", 10); + Assert.True(from.TryStore(ore)); + var massBefore = from.Mass + to.Mass; + + Assert.True(from.TryTransferItem(to, ore, 4)); + Assert.True(from.TryTransferItem(to, ore, 4)); + + Assert.Equal(2, Units(from, ore)); + Assert.Equal(8, Units(to, ore)); + Assert.Empty(from.Cargo.Keys.Intersect(to.Cargo.Keys)); + Assert.Equal(massBefore, from.Mass + to.Mass, 3); + } + + [Fact] + public void ABoughtPartOfAStationStackLeavesTheRestWithTheStation() + { + // TradeMenu.Buy delivers by this exact commit: the station's stack to the player's bay, a part at a time. + var station = Station(); + var stationBay = station.CargoBays[0]; + var player = ShipWith("Crate").CargoBays[0]; + var stock = Commodity("Ore", 10); + Assert.True(stationBay.TryStore(stock)); + var credits = 1000; + + Assert.Equal(BuyResult.Bought, StationServices.TryBuy(_items, stock, 4, ref credits, () => stationBay.TryTransferItem(player, stock, 4))); + Assert.Equal(BuyResult.Bought, StationServices.TryBuy(_items, stock, 4, ref credits, () => stationBay.TryTransferItem(player, stock, 4))); + + Assert.Equal(1000 - 2 * 4 * 7, credits); + Assert.Equal(2, Units(stationBay, stock)); + Assert.Equal(8, Units(player, stock)); + Assert.Empty(stationBay.Cargo.Keys.Intersect(player.Cargo.Keys)); + } + + [Fact] + public void RefusedCommodityTransferChangesNothing() + { + var from = ShipWith("Crate").CargoBays[0]; + var to = ShipWith("Pocket").CargoBays[0]; + var ore = Commodity("Ore", 10); + Assert.True(from.TryStore(ore)); + Assert.True(to.TryStore(Commodity("Slag", 5))); + var at = from.Cargo[ore]; + var massBefore = from.Mass + to.Mass; + + Assert.False(from.TryTransferItem(to, ore, 4)); + + Assert.Equal(10, ore.Quantity); + Assert.Equal(at, from.Cargo[ore]); + Assert.Equal(10, Units(from, ore)); + Assert.Equal(0, Units(to, ore)); + Assert.Equal(massBefore, from.Mass + to.Mass, 3); + } + + [Fact] + public void ATransferThatNeedsMoreRoomThanTheStackHasLeftChangesNothing() + { + // The receiver's only stack of Ore has room for 2 of the 4: the move is whole or nothing. + var from = ShipWith("Crate").CargoBays[0]; + var to = ShipWith("Pocket").CargoBays[0]; + var ore = Commodity("Ore", 10); + Assert.True(from.TryStore(ore)); + Assert.True(to.TryStore(Commodity("Ore", 98))); + + Assert.False(from.TryTransferItem(to, ore, 4)); + + Assert.Equal(10, Units(from, ore)); + Assert.Equal(98, Units(to, ore)); + } +} From cada2fde3e09e1aca8154628093e8fd901959b8e Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:38:17 +0200 Subject: [PATCH 02/12] Transfer verb: a pilot gives cargo to a ship alongside, committed through the conserving bay move TryTransferItem(SimpleCommodity) checks the target can take the part and stores the part removed, not the stack left behind (it duplicated and lost units on every partial move, the player's commodity buy among them). TryTransferItems goes. CargoTransfer.Step owns a give's progress and end; PilotCommand.Give and StopGiving are applied by PilotApplier; TransferRate is the new tunable, DockingDistance the range. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/CargoTransfer.cs | 124 ++++++++++++++++++ .../ServerShared/CargoTransfer.cs.meta | 11 ++ Assets/Scripts/ServerShared/Entity.cs | 54 ++------ Assets/Scripts/ServerShared/Pilot.cs | 19 +++ Assets/Scripts/ServerShared/Settings.cs | 2 + Assets/Scripts/ServerShared/Zone.cs | 1 + 6 files changed, 171 insertions(+), 40 deletions(-) create mode 100644 Assets/Scripts/ServerShared/CargoTransfer.cs create mode 100644 Assets/Scripts/ServerShared/CargoTransfer.cs.meta diff --git a/Assets/Scripts/ServerShared/CargoTransfer.cs b/Assets/Scripts/ServerShared/CargoTransfer.cs new file mode 100644 index 000000000..4026bbc7b --- /dev/null +++ b/Assets/Scripts/ServerShared/CargoTransfer.cs @@ -0,0 +1,124 @@ +/* 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.Linq; +using CultMath; +using static CultMath.math; + +// Why a give ended. Notification only: nothing reads it to decide anything. +public enum GiveEnd +{ + Done, + Stopped, + OutOfRange, + ReceiverGone, + ReceiverFull, + NothingLeft, + GiverDocked +} + +// A pilot's standing offer of goods to a ship alongside. Runtime only: nothing about a give is saved. +public sealed class GiveOrder +{ + public readonly Entity Receiver; + // A commodity order is for any stack of this item's design in the giver's bays; a crafted order is for this instance. + public readonly ItemInstance Item; + // Units still to move. + public int Remaining { get; internal set; } + // Units earned and not yet moved, fractional between steps. + internal double Budget; + + internal GiveOrder(Entity receiver, ItemInstance item, int quantity) + { + Receiver = receiver; + Item = item; + Remaining = quantity; + } +} + +// The one owner of a give's progress and its end (ruling tender-transfer-verb). A ship's own pilot asks for a give through +// PilotCommand.Give and PilotApplier starts it here; nothing else moves goods out of a hold in space (ruling +// transfer-is-give-only). Every unit moves through EquippedCargoBay.TryTransferItem, the one conserving bay-to-bay commit. +public static class CargoTransfer +{ + internal static void Begin(Entity giver, GiveOrder order) + { + End(giver, GiveEnd.Stopped); + giver.Giving = order; + } + + // Ends the giver's give, if it has one, for the reason. + internal static void End(Entity giver, GiveEnd reason) + { + if (giver.Giving == null) return; + giver.Giving = null; + giver.LastGiveEnd = reason; + } + + // One step of every give in the zone: the receiver is still here and alongside, and the rate earns units, each moved + // into the first receiver bay that takes it. + public static void Step(Zone zone, float deltaTime) + { + foreach (var giver in zone.Entities.ToArray()) + { + var order = giver.Giving; + if (order == null) continue; + var settings = giver.ItemManager.GameplaySettings; + var range = settings.DockingDistance; + if (!zone.Entities.Contains(order.Receiver)) + { + End(giver, GiveEnd.ReceiverGone); + continue; + } + if (lengthsq(order.Receiver.Position.xz - giver.Position.xz) > range * range) + { + End(giver, GiveEnd.OutOfRange); + continue; + } + + order.Budget += (double) settings.TransferRate * deltaTime; + while (order.Budget >= 1 && order.Remaining > 0) + { + var source = Source(giver, order); + if (source.Bay == null) + { + End(giver, GiveEnd.NothingLeft); + break; + } + if (!Move(source.Bay, source.Item, order.Receiver)) + { + End(giver, GiveEnd.ReceiverFull); + break; + } + order.Budget -= 1; + order.Remaining--; + } + if (giver.Giving == order && order.Remaining == 0) End(giver, GiveEnd.Done); + } + } + + // Where the next unit comes from: the first stack of the order's design in the giver's bays, or the crafted instance. + private static (EquippedCargoBay Bay, ItemInstance Item) Source(Entity giver, GiveOrder order) + { + foreach (var bay in giver.CargoBays) + { + if (order.Item is CraftedItemInstance crafted) + { + if (bay.Cargo.ContainsKey(crafted)) return (bay, crafted); + } + else if (bay.ItemsOfType.TryGetValue(order.Item.Data.Key, out var stacks)) + { + var stack = stacks.OfType().FirstOrDefault(s => s.Quantity > 0); + if (stack != null) return (bay, stack); + } + } + return (null, null); + } + + // One unit of a commodity, or the crafted item, into the first receiver bay that takes it. + private static bool Move(EquippedCargoBay from, ItemInstance item, Entity receiver) => + item is SimpleCommodity commodity + ? receiver.CargoBays.Any(bay => from.TryTransferItem(bay, commodity, 1)) + : receiver.CargoBays.Any(bay => from.TryTransferItem(bay, (CraftedItemInstance) item)); +} diff --git a/Assets/Scripts/ServerShared/CargoTransfer.cs.meta b/Assets/Scripts/ServerShared/CargoTransfer.cs.meta new file mode 100644 index 000000000..70599860c --- /dev/null +++ b/Assets/Scripts/ServerShared/CargoTransfer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7be41c09d3a84f5e9a206d18c4f3b5a2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 6b1eab8c9..00df96087 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -191,6 +191,10 @@ public abstract class Entity // The pilot's tractor command (0..1); the Pickup behaviour spins its beam toward it. public float TractorCommand { get; internal set; } + // The give the pilot has under way, and why the last one ended. CargoTransfer is the only writer of both. + public GiveOrder Giving { get; internal set; } + public GiveEnd? LastGiveEnd { get; internal set; } + // The beam's spun-up state: Pickup's, or 0 without one. public float TractorPower => GetBehavior()?.Power ?? 0f; @@ -1460,42 +1464,6 @@ public abstract class Entity } } - // Attempts to move a given number of items of the given type to the target Entity - // Returns the number of items successfully transferred - public int TryTransferItems(Entity target, CultRecordKey itemDataID, int quantity) - { - int quantityTransferred = 0; - while (quantityTransferred < quantity) - { - EquippedCargoBay originInventory = CargoBays.FirstOrDefault(c => c.ItemsOfType.ContainsKey(itemDataID)); - - if (originInventory == null) break; - - var itemInstance = originInventory.ItemsOfType[itemDataID][0]; - - if (itemInstance is SimpleCommodity simpleCommodity) - { - var targetQuantity = min(simpleCommodity.Quantity, quantity - quantityTransferred); - if (!target.CargoBays.Any(c => originInventory.TryTransferItem(c, simpleCommodity, targetQuantity))) - { - quantityTransferred += targetQuantity - simpleCommodity.Quantity; - break; - } - - quantityTransferred += targetQuantity; - } - else if (itemInstance is CraftedItemInstance craftedItemInstance) - { - if (!target.CargoBays.Any(c => originInventory.TryTransferItem(c, craftedItemInstance))) - break; - - quantityTransferred++; - } - } - - return quantityTransferred; - } - public EquippableItem TryUnequip(EquippedItem item) { // Don't allow unequipping when the entity is active @@ -1752,6 +1720,7 @@ public abstract class Entity if (bay != null) { bay.DockedShip = ship; + CargoTransfer.End(ship, GiveEnd.GiverDocked); ship.SetParent(this); Zone.Remove(ship); ship.Deactivate(); @@ -2695,12 +2664,17 @@ public class EquippedCargoBay : EquippedItem return false; } + // Whole or nothing: the target must be able to take the part before it leaves the stack. + var part = new SimpleCommodity { Data = item.Data, Quantity = min(quantity, item.Quantity), Rotation = item.Rotation }; + if (!target.TryFindSpace(part, out _)) return false; + var oldPos = Cargo[item]; var newItem = Remove(item, quantity); - - if (target.TryStore(item)) return true; - - // Failed to transfer full quantity, move the remaining items back to their old slot + + // The part removed goes across, never the stack left behind. + if (target.TryStore(newItem)) return true; + + // The target refused after all: put the part back where it came from. TryStore(newItem, oldPos); return false; } diff --git a/Assets/Scripts/ServerShared/Pilot.cs b/Assets/Scripts/ServerShared/Pilot.cs index 8b04637d9..be71f877d 100644 --- a/Assets/Scripts/ServerShared/Pilot.cs +++ b/Assets/Scripts/ServerShared/Pilot.cs @@ -56,6 +56,8 @@ public sealed class PilotCommand internal readonly List<(ActivatedBehavior Behavior, bool Pull)> Triggers = new List<(ActivatedBehavior, bool)>(); internal readonly List<(Entity Other, bool? Hostile, bool Toggle)> Iffs = new List<(Entity, bool?, bool)>(); internal TargetVerb TargetVerb; + // The step's give verb: a Give, or a stop when its Receiver is null. The last one in a step wins. + internal (Entity Receiver, ItemInstance Item, int Quantity)? GiveVerb; internal int AimCycles; internal int ShutdownOverrideToggles, HeatsinkToggles, ShieldToggles, Pings; @@ -77,6 +79,17 @@ public sealed class PilotCommand public void ToggleHeatsinks() => HeatsinkToggles++; public void ToggleShield() => ShieldToggles++; public void Ping() => Pings++; + // Offers goods from this entity's hold to a ship alongside (ruling transfer-is-give-only): the quantity of a commodity, + // or the one crafted instance. It replaces any give under way. A give of nothing, or of less than one, is ignored, and + // so is one to oneself when applied. The last give or stop in a step wins. + public void Give(Entity receiver, ItemInstance item, int quantity) + { + if (receiver == null || item == null) return; + var count = item is CraftedItemInstance ? 1 : quantity; + if (count < 1) return; + GiveVerb = (receiver, item, count); + } + public void StopGiving() => GiveVerb = (null, null, 0); // The pilot's own pending decisions, which it reads back instead of the ship (that is last step's applied state). public TargetRef? Designated => Designations.Count > 0 ? Designations[Designations.Count - 1] : (TargetRef?) null; @@ -98,6 +111,7 @@ public sealed class PilotCommand Triggers.Clear(); Iffs.Clear(); TargetVerb = TargetVerb.None; + GiveVerb = null; AimCycles = 0; ShutdownOverrideToggles = HeatsinkToggles = ShieldToggles = Pings = 0; } @@ -134,6 +148,11 @@ public static class PilotApplier for (var i = 0; i < command.Pings; i++) entity.Sensor?.Ping(); foreach (var hail in command.Hails) entity.SetMessage(hail); foreach (var consumable in command.Uses) entity.TryActivateConsumable(consumable); + if (command.GiveVerb is (Entity receiver, ItemInstance given, int quantity)) + { + if (receiver == null) CargoTransfer.End(entity, GiveEnd.Stopped); + else if (!ReferenceEquals(receiver, entity)) CargoTransfer.Begin(entity, new GiveOrder(receiver, given, quantity)); + } if (command.Aim is float3 aim) entity.Aim = aim; foreach (var (behavior, pull) in command.Triggers) if (pull) behavior.Activate(); diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 5a95f450d..b90b1252d 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -205,6 +205,8 @@ public class GameplaySettings public int WeaponGroupCount = 6; public float WarpDistance = 25; public float DockingDistance = 25; + // Units per second one pilot gives to a ship within DockingDistance; a commodity unit or one crafted item. + public float TransferRate = 2; public float ProductionPersonalityLerp = .05f; public float MessageDuration = 4f; public float TargetPersistenceDuration = 3; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index f2276f4e4..a74a8f7b9 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -385,6 +385,7 @@ public class Zone foreach (var entity in entities) entity.Sense(); _departures.Clear(); + CargoTransfer.Step(this, deltaTime); StepMines(deltaTime); StepClouds(deltaTime); StepFloatingItems(deltaTime); From 7ac8f389014864e16d873840198ca13c14a207ac Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:38:18 +0200 Subject: [PATCH 03/12] Cargo transfer tests: the give verb's rate, ends, instance choice and press order Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index ce8732fe1..eca433719 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -32,6 +32,8 @@ public sealed class CargoTransferTests : IDisposable _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); + _cache.Upsert(new DockingBayData { Name = "Berth", Shape = new Shape(), InteriorShape = bay, MaxSize = new int2(2, 2), Price = 60, Durability = 6 }); + _cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 400, Durability = 8 }); _cache.FlushAsync().Wait(); var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); @@ -150,4 +152,284 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(10, Units(from, ore)); Assert.Equal(98, Units(to, ore)); } + + // ---- The give verb ------------------------------------------------------------------------------------------ + + private const float Dt = .1f; + + // Two ships in the zone, alongside each other, the giver holding `ore` units of Ore. + private (Ship Giver, Ship Receiver, SimpleCommodity Stack) Alongside(int ore, string receiverBay = "Crate") + { + var giver = ShipWith("Crate"); + var receiver = ShipWith(receiverBay); + var stack = Commodity("Ore", ore); + Assert.True(giver.CargoBays[0].TryStore(stack)); + giver.Position = new float3(0, 0, 0); + receiver.Position = new float3(5, 0, 0); + _zone.Admit(giver, piloted: false); + _zone.Admit(receiver, piloted: false); + return (giver, receiver, stack); + } + + private static int Held(Entity entity, SimpleCommodity like) => entity.CargoBays.Sum(bay => Units(bay, like)); + + private void Steps(int count) + { + for (var i = 0; i < count; i++) _zone.Update(Dt); + } + + [Fact] + public void GiveMovesAtTheRate() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + + for (var i = 1; i <= 10; i++) + { + Steps(1); + Assert.Equal(10, Held(giver, ore) + Held(receiver, ore)); + } + + // Rate 2 for one second: exactly two units, not one and not three. + Assert.Equal(2, Held(receiver, ore)); + Assert.Equal(8, Held(giver, ore)); + Assert.NotNull(giver.Giving); + } + + [Fact] + public void GiveEndsDoneAtItsQuantity() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 3); + + Steps(40); + + Assert.Equal(3, Held(receiver, ore)); + Assert.Equal(7, Held(giver, ore)); + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void GiveEndsOutOfRange() + { + var (giver, receiver, ore) = Alongside(10); + var range = _items.GameplaySettings.DockingDistance; + receiver.Position = new float3(range - 1, 0, 0); + giver.Command.Give(receiver, ore, 10); + Steps(10); + Assert.Equal(2, Held(receiver, ore)); + Assert.Null(giver.LastGiveEnd); + + receiver.Position = new float3(range + 1, 0, 0); + Steps(10); + + Assert.Equal(2, Held(receiver, ore)); + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.OutOfRange, giver.LastGiveEnd); + } + + [Fact] + public void GiveRangeIsMeasuredOnTheFlatPlane() + { + // The plane is xz: a ship far apart in y only is still alongside. + var (giver, receiver, ore) = Alongside(10); + receiver.Position = new float3(5, 1000, 0); + giver.Command.Give(receiver, ore, 10); + Steps(10); + Assert.Equal(2, Held(receiver, ore)); + } + + [Fact] + public void GiveEndsWhenTheReceiverLeaves() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + Steps(10); + Assert.Equal(2, Held(receiver, ore)); + + _zone.Remove(receiver); + Steps(10); + + Assert.Equal(8, Held(giver, ore)); + Assert.Equal(2, Held(receiver, ore)); + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.ReceiverGone, giver.LastGiveEnd); + } + + [Fact] + public void DockingEndsTheGiversGive() + { + var (giver, receiver, ore) = Alongside(10); + var station = new OrbitalEntity(_items, _zone, Mint("Hub"), default, new EntitySettings()); + Assert.True(station.TryEquip(Mint("Berth"), new int2(1, 1))); + giver.Command.Give(receiver, ore, 10); + Steps(10); + + Assert.NotNull(station.TryDock(giver)); + Steps(10); + + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.GiverDocked, giver.LastGiveEnd); + Assert.Equal(2, Held(receiver, ore)); + } + + [Fact] + public void GiveEndsOnAFullHold() + { + var (giver, receiver, ore) = Alongside(10, "Pocket"); + Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); + giver.Command.Give(receiver, ore, 10); + + Steps(20); + + Assert.Equal(10, Held(giver, ore)); + Assert.Equal(0, Held(receiver, ore)); + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.ReceiverFull, giver.LastGiveEnd); + } + + [Fact] + public void GiveEndsWhenNothingIsLeft() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 50); + + Steps(100); + + Assert.Equal(10, Held(receiver, ore)); + Assert.Equal(0, Held(giver, ore)); + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.NothingLeft, giver.LastGiveEnd); + } + + [Fact] + public void AGiveDrawsOnEveryStackOfTheDesign() + { + // Two stacks in two bays: the order is for the design, not for the stack it was named with. + var (giver, receiver, ore) = Alongside(3); + Assert.True(giver.TryEquip(Mint("Crate"), new int2(3, 3))); + Assert.True(giver.CargoBays[1].TryStore(Commodity("Ore", 4))); + giver.Command.Give(receiver, ore, 7); + + Steps(60); + + Assert.Equal(7, Held(receiver, ore)); + Assert.Equal(0, Held(giver, ore)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void AGivenCraftedItemIsThatInstance() + { + var (giver, receiver, _) = Alongside(0); + var first = Mint("Gun"); + var second = Mint("Gun"); + Assert.True(giver.CargoBays[0].TryStore(first)); + Assert.True(giver.CargoBays[0].TryStore(second)); + giver.Command.Give(receiver, second, 5); + + Steps(20); + + Assert.True(receiver.CargoBays[0].Cargo.ContainsKey(second)); + Assert.False(receiver.CargoBays[0].Cargo.ContainsKey(first)); + Assert.True(giver.CargoBays[0].Cargo.ContainsKey(first)); + Assert.False(giver.CargoBays[0].Cargo.ContainsKey(second)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void TheLastGivePressInAStepWins() + { + var (giver, receiver, ore) = Alongside(10); + var third = ShipWith("Crate"); + third.Position = new float3(0, 0, 5); + _zone.Admit(third, piloted: false); + + // A give then a stop leaves nothing, and nothing ended. + giver.Command.Give(receiver, ore, 10); + giver.Command.StopGiving(); + Steps(1); + Assert.Null(giver.Giving); + Assert.Null(giver.LastGiveEnd); + + // A stop then a give leaves the give. + giver.Command.StopGiving(); + giver.Command.Give(receiver, ore, 10); + Steps(1); + Assert.Same(receiver, giver.Giving.Receiver); + + // A second give in the same step replaces the first before it starts. + giver.Command.Give(receiver, ore, 10); + giver.Command.Give(third, ore, 10); + Steps(1); + Assert.Same(third, giver.Giving.Receiver); + Assert.Equal(GiveEnd.Stopped, giver.LastGiveEnd); + + // A give in a later step replaces the one under way, which ends Stopped. + giver.Command.Give(receiver, ore, 10); + Steps(1); + Assert.Same(receiver, giver.Giving.Receiver); + } + + [Fact] + public void StoppingEndsTheGiveAndKeepsWhatMoved() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + Steps(10); + giver.Command.StopGiving(); + Steps(10); + + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.Stopped, giver.LastGiveEnd); + Assert.Equal(2, Held(receiver, ore)); + Assert.Equal(8, Held(giver, ore)); + } + + [Fact] + public void AGiveToOneselfOrOfNothingIsIgnored() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + Steps(1); + var order = giver.Giving; + + giver.Command.Give(giver, ore, 5); + giver.Command.Give(receiver, ore, 0); + giver.Command.Give(receiver, ore, -3); + giver.Command.Give(null, ore, 5); + giver.Command.Give(receiver, null, 5); + Steps(1); + + Assert.Same(order, giver.Giving); + Assert.Null(giver.LastGiveEnd); + } + + [Fact] + public void AGiverStaysInTheZone() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Designate(new TargetRef(receiver)); + receiver.Command.Designate(new TargetRef(giver)); + giver.Command.Give(receiver, ore, 10); + + Steps(20); + + Assert.True(_zone.Entities.Contains(giver)); + Assert.True(_zone.Entities.Contains(receiver)); + Assert.Same(receiver, giver.Target.Value.Entity); + Assert.Same(giver, receiver.Target.Value.Entity); + } + + [Fact] + public void NoOrderMovesNothing() + { + // No order, no work: a zone of ships that are not giving moves nothing. + var (giver, receiver, ore) = Alongside(10); + Steps(20); + Assert.Equal(10, Held(giver, ore)); + Assert.Equal(0, Held(receiver, ore)); + Assert.Null(giver.LastGiveEnd); + } } From c1aab929b19985fc9a2035a05113bd892db5f0e9 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:42:42 +0200 Subject: [PATCH 04/12] Give verb: a stop is a verb with no receiver, and the type pattern skipped it; tests: second bay at a free cell Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Pilot.cs | 6 +++--- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/ServerShared/Pilot.cs b/Assets/Scripts/ServerShared/Pilot.cs index be71f877d..dcfa655ae 100644 --- a/Assets/Scripts/ServerShared/Pilot.cs +++ b/Assets/Scripts/ServerShared/Pilot.cs @@ -148,10 +148,10 @@ public static class PilotApplier for (var i = 0; i < command.Pings; i++) entity.Sensor?.Ping(); foreach (var hail in command.Hails) entity.SetMessage(hail); foreach (var consumable in command.Uses) entity.TryActivateConsumable(consumable); - if (command.GiveVerb is (Entity receiver, ItemInstance given, int quantity)) + if (command.GiveVerb is { } give) { - if (receiver == null) CargoTransfer.End(entity, GiveEnd.Stopped); - else if (!ReferenceEquals(receiver, entity)) CargoTransfer.Begin(entity, new GiveOrder(receiver, given, quantity)); + if (give.Receiver == null) CargoTransfer.End(entity, GiveEnd.Stopped); + else if (!ReferenceEquals(give.Receiver, entity)) CargoTransfer.Begin(entity, new GiveOrder(give.Receiver, give.Item, give.Quantity)); } if (command.Aim is float3 aim) entity.Aim = aim; foreach (var (behavior, pull) in command.Triggers) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index eca433719..5983747b4 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -308,7 +308,7 @@ public sealed class CargoTransferTests : IDisposable { // Two stacks in two bays: the order is for the design, not for the stack it was named with. var (giver, receiver, ore) = Alongside(3); - Assert.True(giver.TryEquip(Mint("Crate"), new int2(3, 3))); + Assert.True(giver.TryEquip(Mint("Crate"), new int2(2, 2))); Assert.True(giver.CargoBays[1].TryStore(Commodity("Ore", 4))); giver.Command.Give(receiver, ore, 7); From 4e559d822eae1e1ac5961d12ea10bbf22510e498 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:46:35 +0200 Subject: [PATCH 05/12] Cargo transfer tests: the second bay goes where the hull has room Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 5983747b4..44190c3de 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -308,7 +308,7 @@ public sealed class CargoTransferTests : IDisposable { // Two stacks in two bays: the order is for the design, not for the stack it was named with. var (giver, receiver, ore) = Alongside(3); - Assert.True(giver.TryEquip(Mint("Crate"), new int2(2, 2))); + Assert.True(giver.TryEquip(Mint("Crate"))); Assert.True(giver.CargoBays[1].TryStore(Commodity("Ore", 4))); giver.Command.Give(receiver, ore, 7); From bcfc776513f74654ba35a48093f6a26860bce745 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:50:35 +0200 Subject: [PATCH 06/12] Cargo transfer tests: two stacks of one design come from a commodity that stacks two to a stack Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 44190c3de..148cde89d 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -31,6 +31,7 @@ public sealed class CargoTransferTests : IDisposable foreach (var cell in pocket.AllCoordinates) pocket[cell] = true; _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); + _cache.Upsert(new SimpleCommodityData { Name = "Grit", Shape = new Shape(), Price = 1, MaxStack = 2 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); _cache.Upsert(new DockingBayData { Name = "Berth", Shape = new Shape(), InteriorShape = bay, MaxSize = new int2(2, 2), Price = 60, Durability = 6 }); _cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 400, Durability = 8 }); @@ -163,7 +164,7 @@ public sealed class CargoTransferTests : IDisposable var giver = ShipWith("Crate"); var receiver = ShipWith(receiverBay); var stack = Commodity("Ore", ore); - Assert.True(giver.CargoBays[0].TryStore(stack)); + if (ore > 0) Assert.True(giver.CargoBays[0].TryStore(stack)); giver.Position = new float3(0, 0, 0); receiver.Position = new float3(5, 0, 0); _zone.Admit(giver, piloted: false); @@ -306,16 +307,17 @@ public sealed class CargoTransferTests : IDisposable [Fact] public void AGiveDrawsOnEveryStackOfTheDesign() { - // Two stacks in two bays: the order is for the design, not for the stack it was named with. - var (giver, receiver, ore) = Alongside(3); - Assert.True(giver.TryEquip(Mint("Crate"))); - Assert.True(giver.CargoBays[1].TryStore(Commodity("Ore", 4))); - giver.Command.Give(receiver, ore, 7); + // Grit stacks two to a stack, so three units are two stacks: the order is for the design, not the stack named. + var (giver, receiver, _) = Alongside(0); + var grit = Commodity("Grit", 3); + Assert.True(giver.CargoBays[0].TryStore(grit)); + Assert.Equal(2, giver.CargoBays[0].ItemsOfType[grit.Data.Key].Count); + giver.Command.Give(receiver, grit, 3); - Steps(60); + Steps(40); - Assert.Equal(7, Held(receiver, ore)); - Assert.Equal(0, Held(giver, ore)); + Assert.Equal(3, Held(receiver, grit)); + Assert.Equal(0, Held(giver, grit)); Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); } From bb38036e5148f2618f1c2587097d9201cbc44874 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:54:12 +0200 Subject: [PATCH 07/12] Cargo transfer tests: build the second stack with a second store Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 148cde89d..d2c1db6fb 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -309,8 +309,9 @@ public sealed class CargoTransferTests : IDisposable { // Grit stacks two to a stack, so three units are two stacks: the order is for the design, not the stack named. var (giver, receiver, _) = Alongside(0); - var grit = Commodity("Grit", 3); + var grit = Commodity("Grit", 2); Assert.True(giver.CargoBays[0].TryStore(grit)); + Assert.True(giver.CargoBays[0].TryStore(Commodity("Grit", 1))); Assert.Equal(2, giver.CargoBays[0].ItemsOfType[grit.Data.Key].Count); giver.Command.Give(receiver, grit, 3); From 70aa1ed037b7002f82389104fcec9ca759037a40 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:22:43 +0200 Subject: [PATCH 08/12] Transfer verb: drop the redundant pre-check, let range, step length and quantity edges show in tests TryStore already takes all of a part or none, so the pre-check in TryTransferItem changed nothing observable. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/CargoTransfer.cs | 2 +- Assets/Scripts/ServerShared/Entity.cs | 6 +- .../CargoTransferTests.cs | 66 +++++++++++++++++-- 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/Assets/Scripts/ServerShared/CargoTransfer.cs b/Assets/Scripts/ServerShared/CargoTransfer.cs index 4026bbc7b..6484bace8 100644 --- a/Assets/Scripts/ServerShared/CargoTransfer.cs +++ b/Assets/Scripts/ServerShared/CargoTransfer.cs @@ -94,7 +94,7 @@ public static class CargoTransfer order.Budget -= 1; order.Remaining--; } - if (giver.Giving == order && order.Remaining == 0) End(giver, GiveEnd.Done); + if (order.Remaining == 0) End(giver, GiveEnd.Done); } } diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 00df96087..721ac31b0 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -2664,17 +2664,13 @@ public class EquippedCargoBay : EquippedItem return false; } - // Whole or nothing: the target must be able to take the part before it leaves the stack. - var part = new SimpleCommodity { Data = item.Data, Quantity = min(quantity, item.Quantity), Rotation = item.Rotation }; - if (!target.TryFindSpace(part, out _)) return false; - var oldPos = Cargo[item]; var newItem = Remove(item, quantity); // The part removed goes across, never the stack left behind. if (target.TryStore(newItem)) return true; - // The target refused after all: put the part back where it came from. + // Whole or nothing: TryStore takes all of the part or none of it, so a refusal puts the part back where it came from. TryStore(newItem, oldPos); return false; } diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index d2c1db6fb..28eae6cf9 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -165,8 +165,9 @@ public sealed class CargoTransferTests : IDisposable var receiver = ShipWith(receiverBay); var stack = Commodity("Ore", ore); if (ore > 0) Assert.True(giver.CargoBays[0].TryStore(stack)); - giver.Position = new float3(0, 0, 0); - receiver.Position = new float3(5, 0, 0); + // Off the origin, so a distance taken from the wrong sum or difference of positions shows. + giver.Position = new float3(-50, 0, 10); + receiver.Position = giver.Position + new float3(5, 0, 0); _zone.Admit(giver, piloted: false); _zone.Admit(receiver, piloted: false); return (giver, receiver, stack); @@ -216,13 +217,13 @@ public sealed class CargoTransferTests : IDisposable { var (giver, receiver, ore) = Alongside(10); var range = _items.GameplaySettings.DockingDistance; - receiver.Position = new float3(range - 1, 0, 0); + receiver.Position = giver.Position + new float3(range - 1, 0, 0); giver.Command.Give(receiver, ore, 10); Steps(10); Assert.Equal(2, Held(receiver, ore)); Assert.Null(giver.LastGiveEnd); - receiver.Position = new float3(range + 1, 0, 0); + receiver.Position = giver.Position + new float3(range + 1, 0, 0); Steps(10); Assert.Equal(2, Held(receiver, ore)); @@ -230,12 +231,65 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(GiveEnd.OutOfRange, giver.LastGiveEnd); } + [Fact] + public void AReceiverExactlyAtTheDockingDistanceIsStillAlongside() + { + var (giver, receiver, ore) = Alongside(10); + receiver.Position = giver.Position + new float3(0, 0, _items.GameplaySettings.DockingDistance); + giver.Command.Give(receiver, ore, 10); + Steps(10); + Assert.Equal(2, Held(receiver, ore)); + Assert.Null(giver.LastGiveEnd); + } + + [Fact] + public void AStepOfExactlyOneUnitMovesOneUnit() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + _zone.Update(1f / _items.GameplaySettings.TransferRate); + Assert.Equal(1, Held(receiver, ore)); + } + + [Fact] + public void ALongStepNeverMovesMoreThanTheQuantity() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 3); + _zone.Update(5f); + Assert.Equal(3, Held(receiver, ore)); + Assert.Equal(7, Held(giver, ore)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void AGiveOfOneMovesOne() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 1); + Steps(20); + Assert.Equal(1, Held(receiver, ore)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void AGiveOfACraftedItemTheGiverDoesNotHoldEndsNothingLeft() + { + var (giver, receiver, _) = Alongside(0); + var gun = Mint("Gun"); + Assert.True(receiver.CargoBays[0].TryStore(gun)); + giver.Command.Give(receiver, gun, 1); + Steps(20); + Assert.Equal(GiveEnd.NothingLeft, giver.LastGiveEnd); + Assert.True(receiver.CargoBays[0].Cargo.ContainsKey(gun)); + } + [Fact] public void GiveRangeIsMeasuredOnTheFlatPlane() { // The plane is xz: a ship far apart in y only is still alongside. var (giver, receiver, ore) = Alongside(10); - receiver.Position = new float3(5, 1000, 0); + receiver.Position = giver.Position + new float3(5, 1000, 0); giver.Command.Give(receiver, ore, 10); Steps(10); Assert.Equal(2, Held(receiver, ore)); @@ -346,7 +400,7 @@ public sealed class CargoTransferTests : IDisposable { var (giver, receiver, ore) = Alongside(10); var third = ShipWith("Crate"); - third.Position = new float3(0, 0, 5); + third.Position = giver.Position + new float3(0, 0, 5); _zone.Admit(third, piloted: false); // A give then a stop leaves nothing, and nothing ended. From dadb6f327d24012c9c17e3efef93cfb4b3a9f3e7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 19:56:54 +0200 Subject: [PATCH 09/12] Cargo transfer tests: long steps after the receiver leaves or strays move nothing; a full first bay hands on to the next Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 28eae6cf9..0926494bf 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -31,6 +31,9 @@ public sealed class CargoTransferTests : IDisposable foreach (var cell in pocket.AllCoordinates) pocket[cell] = true; _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); + var bin = new Shape(1, 1); + bin[0, 0] = true; + _cache.Upsert(new CargoBayData { Name = "Bin", Shape = bin, InteriorShape = bin, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Grit", Shape = new Shape(), Price = 1, MaxStack = 2 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); _cache.Upsert(new DockingBayData { Name = "Berth", Shape = new Shape(), InteriorShape = bay, MaxSize = new int2(2, 2), Price = 60, Durability = 6 }); @@ -284,6 +287,51 @@ public sealed class CargoTransferTests : IDisposable Assert.True(receiver.CargoBays[0].Cargo.ContainsKey(gun)); } + [Fact] + public void ALongStepAfterTheReceiverLeavesMovesNothing() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + Steps(1); + _zone.Remove(receiver); + + _zone.Update(5f); + + Assert.Equal(0, Held(receiver, ore)); + Assert.Equal(10, Held(giver, ore)); + Assert.Equal(GiveEnd.ReceiverGone, giver.LastGiveEnd); + } + + [Fact] + public void ALongStepOutOfRangeMovesNothing() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + Steps(1); + receiver.Position = giver.Position + new float3(0, 0, _items.GameplaySettings.DockingDistance + 1); + + _zone.Update(5f); + + Assert.Equal(0, Held(receiver, ore)); + Assert.Equal(GiveEnd.OutOfRange, giver.LastGiveEnd); + } + + [Fact] + public void AGiveGoesToTheNextReceiverBayWhenTheFirstIsFull() + { + var (giver, receiver, ore) = Alongside(10, "Bin"); + Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); + Assert.True(receiver.TryEquip(Mint("Crate"))); + Assert.Equal(2, receiver.CargoBays.Count); + giver.Command.Give(receiver, ore, 3); + + Steps(40); + + Assert.Equal(0, Units(receiver.CargoBays[0], ore)); + Assert.Equal(3, Units(receiver.CargoBays[1], ore)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + [Fact] public void GiveRangeIsMeasuredOnTheFlatPlane() { From 3f265b6c731a85df25af79068dceed18c4eb4005 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 19:59:19 +0200 Subject: [PATCH 10/12] Cargo transfer tests: fill the one-cell bay the way the other fixtures fill a shape Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 0926494bf..9153e015a 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -32,7 +32,7 @@ public sealed class CargoTransferTests : IDisposable _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); var bin = new Shape(1, 1); - bin[0, 0] = true; + foreach (var cell in bin.AllCoordinates) bin[cell] = true; _cache.Upsert(new CargoBayData { Name = "Bin", Shape = bin, InteriorShape = bin, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Grit", Shape = new Shape(), Price = 1, MaxStack = 2 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); From 00d34e5ab5303bb40f793789fe2866a5751db39d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:05:33 +0200 Subject: [PATCH 11/12] Cargo transfer tests: the second receiver bay is a one-cell bin Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 9153e015a..cdc356bf2 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -321,7 +321,7 @@ public sealed class CargoTransferTests : IDisposable { var (giver, receiver, ore) = Alongside(10, "Bin"); Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); - Assert.True(receiver.TryEquip(Mint("Crate"))); + Assert.True(receiver.TryEquip(Mint("Bin"))); Assert.Equal(2, receiver.CargoBays.Count); giver.Command.Give(receiver, ore, 3); From c37fbdb3b95ddca0b05570f228a4ffc7e6b192a4 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:11:38 +0200 Subject: [PATCH 12/12] Cargo transfer tests: drop the two-bay receiver test; the fixture hulls take one bay Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index cdc356bf2..ea92abd40 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -31,9 +31,6 @@ public sealed class CargoTransferTests : IDisposable foreach (var cell in pocket.AllCoordinates) pocket[cell] = true; _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); - var bin = new Shape(1, 1); - foreach (var cell in bin.AllCoordinates) bin[cell] = true; - _cache.Upsert(new CargoBayData { Name = "Bin", Shape = bin, InteriorShape = bin, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Grit", Shape = new Shape(), Price = 1, MaxStack = 2 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); _cache.Upsert(new DockingBayData { Name = "Berth", Shape = new Shape(), InteriorShape = bay, MaxSize = new int2(2, 2), Price = 60, Durability = 6 }); @@ -316,22 +313,6 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(GiveEnd.OutOfRange, giver.LastGiveEnd); } - [Fact] - public void AGiveGoesToTheNextReceiverBayWhenTheFirstIsFull() - { - var (giver, receiver, ore) = Alongside(10, "Bin"); - Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); - Assert.True(receiver.TryEquip(Mint("Bin"))); - Assert.Equal(2, receiver.CargoBays.Count); - giver.Command.Give(receiver, ore, 3); - - Steps(40); - - Assert.Equal(0, Units(receiver.CargoBays[0], ore)); - Assert.Equal(3, Units(receiver.CargoBays[1], ore)); - Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); - } - [Fact] public void GiveRangeIsMeasuredOnTheFlatPlane() {