diff --git a/Assets/Scripts/ServerShared/CargoTransfer.cs b/Assets/Scripts/ServerShared/CargoTransfer.cs new file mode 100644 index 000000000..6484bace8 --- /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 (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..721ac31b0 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(); @@ -2697,10 +2666,11 @@ public class EquippedCargoBay : EquippedItem 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; + + // 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/Assets/Scripts/ServerShared/Pilot.cs b/Assets/Scripts/ServerShared/Pilot.cs index 8b04637d9..dcfa655ae 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 { } give) + { + 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) 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 fe3136464..8bc152eaa 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -388,6 +388,7 @@ public class Zone foreach (var entity in entities) entity.Sense(); _departures.Clear(); + CargoTransfer.Step(this, deltaTime); StepMines(deltaTime); StepClouds(deltaTime); StepFloatingItems(deltaTime); diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs new file mode 100644 index 000000000..ea92abd40 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -0,0 +1,521 @@ +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 = "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 }); + _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)); + } + + // ---- 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); + if (ore > 0) Assert.True(giver.CargoBays[0].TryStore(stack)); + // 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); + } + + 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 = 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 = giver.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 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 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 GiveRangeIsMeasuredOnTheFlatPlane() + { + // The plane is xz: a ship far apart in y only is still alongside. + var (giver, receiver, ore) = Alongside(10); + receiver.Position = giver.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() + { + // 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", 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); + + Steps(40); + + Assert.Equal(3, Held(receiver, grit)); + Assert.Equal(0, Held(giver, grit)); + 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 = giver.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); + } +}