From 0977aa5b9d69fea6ca3658eb73749fd86ae91eb6 Mon Sep 17 00:00:00 2001 From: TechnologicNick Date: Sun, 18 Aug 2024 22:53:15 +0200 Subject: [PATCH 01/18] Add ContainerService with CollectToSlot Not tested nor integrated yet --- ScrapServer.Core/NetObjs/Container.cs | 65 ++++++++++ ScrapServer.Core/Services/ContainerService.cs | 113 ++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 ScrapServer.Core/NetObjs/Container.cs create mode 100644 ScrapServer.Core/Services/ContainerService.cs diff --git a/ScrapServer.Core/NetObjs/Container.cs b/ScrapServer.Core/NetObjs/Container.cs new file mode 100644 index 0000000..a00b65c --- /dev/null +++ b/ScrapServer.Core/NetObjs/Container.cs @@ -0,0 +1,65 @@ +using System.Drawing; + +namespace ScrapServer.Core.NetObjs; + +public class Container +{ + public readonly uint Id; + public readonly ushort MaximumStackSize; + public readonly ItemStack[] Items; + + public record ItemStack(Guid Uuid, uint InstanceId, ushort Quantity) + { + public const uint NoInstanceId = uint.MaxValue; + + public static ItemStack Empty => new(Uuid: Guid.Empty, InstanceId: NoInstanceId, Quantity: 0); + + public bool IsEmpty => Uuid == Guid.Empty || Quantity == 0; + + public bool IsStackableWith(ItemStack other) + { + if (other == null) return false; + + if (this.IsEmpty || other.IsEmpty) return true; + + return this.Uuid == other.Uuid && this.InstanceId == other.InstanceId; + } + + public static ItemStack Combine(ItemStack a, ItemStack b) + { + if (!a.IsStackableWith(b)) + { + throw new InvalidOperationException("Cannot add two ItemStacks that are not stackable"); + } + + var quantity = a.Quantity + b.Quantity; + + if (quantity > ushort.MaxValue) + { + throw new InvalidOperationException("Cannot add two ItemStacks that would overflow the quantity"); + } + + var baseItemStack = !a.IsEmpty ? a : b; + + return baseItemStack with { Quantity = (ushort)quantity }; + } + } + + public Container(uint id, ushort size, ushort maximumStackSize = ushort.MaxValue) + { + this.Id = id; + this.MaximumStackSize = maximumStackSize; + this.Items = Enumerable.Repeat(ItemStack.Empty, size).ToArray(); + } + + public Container Clone() + { + var clone = new Container(this.Id, (ushort)Items.Length); + for (int i = 0; i < this.Items.Length; i++) + { + clone.Items[i] = this.Items[i]; + } + + return clone; + } +} diff --git a/ScrapServer.Core/Services/ContainerService.cs b/ScrapServer.Core/Services/ContainerService.cs new file mode 100644 index 0000000..1c32485 --- /dev/null +++ b/ScrapServer.Core/Services/ContainerService.cs @@ -0,0 +1,113 @@ +using ScrapServer.Core.NetObjs; +using static ScrapServer.Core.NetObjs.Container; + +namespace ScrapServer.Core; + +public class ContainerService +{ + public Dictionary Containers = []; + + private volatile bool IsInTransaction = false; + + public class Transaction(ContainerService containerService) : IDisposable + { + private readonly Dictionary modified = []; + + /// + /// Collects items into a specific slot of a container. + /// + /// The container to collect the items into + /// The item stack, including quantity, to collect + /// + /// If true, only collect items if the full item stack fits in the remaining space of the slot. + /// If false, collect as many items that fit into the remaining space, and do so without overflowing into other slots. + /// + /// A tuple containing the number of items collected and the result of the operation + public (ushort Collected, OperationResult Result) CollectToSlot(Container container, ItemStack itemStack, ushort slot, bool mustCollectAll = true) + { + var containerCopyOnWrite = modified[container.Id] ?? container.Clone(); + + if (slot < 0 || slot > containerCopyOnWrite.Items.Length) + { + throw new SlotIndexOutOfRangeException($"Slot {slot} is out of range [0, {containerCopyOnWrite.Items.Length})"); + } + + var currentItemStackInSlot = containerCopyOnWrite.Items[slot]; + + if (!currentItemStackInSlot.IsStackableWith(itemStack)) + { + return (0, OperationResult.NotStackable); + } + + int max = containerService.GetMaximumStackSize(container, itemStack.Uuid); + int remainingSpace = max - currentItemStackInSlot.Quantity; + + int quantityToCollect = Math.Min(remainingSpace, itemStack.Quantity); + if (quantityToCollect <= 0) + { + return (0, OperationResult.NotEnoughSpace); + } + + if (mustCollectAll && quantityToCollect < itemStack.Quantity) + { + return (0, OperationResult.NotEnoughSpaceForAll); + } + + containerCopyOnWrite.Items[slot] = ItemStack.Combine(itemStack, currentItemStackInSlot); + + modified[container.Id] = containerCopyOnWrite; + + return ((ushort)quantityToCollect, OperationResult.Success); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + if (!containerService.IsInTransaction) + { + throw new InvalidOperationException("Transaction was not committed or rolled back"); + } + } + + public class SlotIndexOutOfRangeException(string message) : Exception(message) + { + } + + public enum OperationResult + { + Success, + NotStackable, + NotEnoughSpace, + NotEnoughSpaceForAll, + } + } + + public Transaction BeginTransaction() + { + if (this.IsInTransaction) + { + throw new InvalidOperationException("Cannot start a transaction while one is already in progress"); + } + + this.IsInTransaction = true; + + return new Transaction(this); + } + + public ushort GetMaximumStackSize(Container container, Guid uuid) + { + // TODO: Return the minimum of the maximum stack size of the item and the container + + return container.MaximumStackSize; + } +} From 1ad310d61ed5feedd37e65894e6c2ed199413bbb Mon Sep 17 00:00:00 2001 From: TechnologicNick Date: Mon, 19 Aug 2024 03:13:24 +0200 Subject: [PATCH 02/18] Refactor NetworkUpdate and NetObjs Now uses a builder pattern :) --- ScrapServer.Core/NetObjs/Character.cs | 71 +++++---------- ScrapServer.Core/NetObjs/Container.cs | 35 +++++++- ScrapServer.Core/Services/CharacterService.cs | 14 +-- ScrapServer.Core/Services/PlayerService.cs | 11 +-- .../Data/CreateCharacter.cs | 3 - .../Data/CreateContainer.cs | 66 ++++++++++++++ ScrapServer.Networking/Data/CreateNetObj.cs | 23 ----- ScrapServer.Networking/Data/NetObj.cs | 19 ++-- .../Data/UpdateCharacter.cs | 7 +- ScrapServer.Networking/INetObj.cs | 29 ++++++ ScrapServer.Networking/NetworkUpdate.cs | 88 +++++++++++++++++++ 11 files changed, 259 insertions(+), 107 deletions(-) create mode 100644 ScrapServer.Networking/Data/CreateContainer.cs delete mode 100644 ScrapServer.Networking/Data/CreateNetObj.cs create mode 100644 ScrapServer.Networking/INetObj.cs diff --git a/ScrapServer.Core/NetObjs/Character.cs b/ScrapServer.Core/NetObjs/Character.cs index 86f94a4..241bede 100644 --- a/ScrapServer.Core/NetObjs/Character.cs +++ b/ScrapServer.Core/NetObjs/Character.cs @@ -5,11 +5,14 @@ namespace ScrapServer.Core.NetObjs; -public class Character +public class Character : INetObj { - private static int _idCounter = 1; + public NetObjType NetObjType => NetObjType.Character; + public ControllerType ControllerType => ControllerType.Unknown; - public int Id { get; private set; } + private static uint _idCounter = 1; + + public uint Id { get; private set; } public ulong OwnerId { get; set; } public uint InventoryContainerId { get; set; } = 0; public uint CarryContainerId { get; set; } = 0; @@ -144,39 +147,25 @@ public void HandleMovement(PlayerMovement movement) BodyRotation = matrixYaw; HeadRotation = matrixYaw * matrixPitch; } - - public byte[] InitNetworkPacket(uint tick) + public void SerializeCreate(ref BitWriter writer) { - - // Packet 22 - Network Update - var stream = BitWriter.WithSharedPool(); - var anglesLook = HeadRotation.ExtractRotation().ToEulerAngles(); - var netObj = new Networking.Data.NetObj { UpdateType = NetworkUpdateType.Create, ObjectType = NetObjType.Character, Size = 0 }; - var createUpdate = new CreateNetObj { ControllerType = ControllerType.Unknown }; - var characterCreate = new CreateCharacter + new CreateCharacter { - NetObjId = (uint)Id, SteamId = OwnerId, Position = Position, CharacterUUID = Guid.Empty, Pitch = ConvertAngleToBinary(anglesLook.X), Yaw = ConvertAngleToBinary(anglesLook.Z), WorldId = 1 - }; - - netObj.Serialize(ref stream); - createUpdate.Serialize(ref stream); - characterCreate.Serialize(ref stream); - Networking.Data.NetObj.WriteSize(ref stream, 0); - - var streamPos = stream.ByteIndex; + }.Serialize(ref writer); + } - netObj = new Networking.Data.NetObj { UpdateType = NetworkUpdateType.Update, ObjectType = NetObjType.Character, Size = 0 }; - var updateCharacter = new UpdateCharacter + public void SerializeUpdate(ref BitWriter writer) + { + new UpdateCharacter { - NetObjId = (uint)Id, Color = new Color4(1, 1, 1, 1), Movement = new MovementState { @@ -189,25 +178,14 @@ public byte[] InitNetworkPacket(uint tick) }, SelectedItem = new Item { Uuid = Guid.Empty, InstanceId = -1 }, PlayerInfo = new PlayerId { IsPlayer = true, UnitId = PlayerId } - }; - - stream.GoToNearestByte(); - netObj.Serialize(ref stream); - updateCharacter.Serialize(ref stream); - Networking.Data.NetObj.WriteSize(ref stream, streamPos); - - var data = stream.Data.ToArray(); - - stream.Dispose(); - - return data; + }.Serialize(ref writer); } public BlobData BlobData(uint tick) { var playerData = new PlayerData { - CharacterID = Id, + CharacterID = (int)Id, SteamID = OwnerId, InventoryContainerID = InventoryContainerId, CarryContainer = CarryContainerId, @@ -257,18 +235,13 @@ public void SpawnPackets(Player player, uint tick) player.Send(new GenericInitData { Data = [BlobData(tick)], GameTick = tick }); // Packet 22 - Network Update - player.Send(new NetworkUpdate { GameTick = tick + 1, Updates = InitNetworkPacket(tick) }); - } - - public void RemovePackets(Player player, uint tick) - { - var netObj = new RemoveNetObj - { - Header = new Networking.Data.NetObj { UpdateType = NetworkUpdateType.Remove, ObjectType = NetObjType.Character, Size = 0 }, - NetObjId = (uint)Id - }; - - player.Send(new NetworkUpdate { GameTick = tick, Updates = netObj.ToBytes() }); + player.Send( + new NetworkUpdate.Builder() + .WithGameTick(tick + 1) + .WriteCreate(this) + .WriteUpdate(this) + .Build() + ); } public class Builder diff --git a/ScrapServer.Core/NetObjs/Container.cs b/ScrapServer.Core/NetObjs/Container.cs index a00b65c..317c72d 100644 --- a/ScrapServer.Core/NetObjs/Container.cs +++ b/ScrapServer.Core/NetObjs/Container.cs @@ -1,12 +1,19 @@ -using System.Drawing; +using ScrapServer.Networking; +using ScrapServer.Networking.Data; +using ScrapServer.Utility.Serialization; namespace ScrapServer.Core.NetObjs; -public class Container +public class Container : INetObj { - public readonly uint Id; + public NetObjType NetObjType => NetObjType.Container; + public ControllerType ControllerType => ControllerType.Unknown; + + public uint Id { get; private set; } public readonly ushort MaximumStackSize; public readonly ItemStack[] Items; + public readonly ISet Filter = new HashSet(); + public record ItemStack(Guid Uuid, uint InstanceId, ushort Quantity) { @@ -60,6 +67,28 @@ public Container Clone() clone.Items[i] = this.Items[i]; } + clone.Filter.UnionWith(this.Filter); + return clone; } + + public void SerializeCreate(ref BitWriter writer) + { + var createContainer = new CreateContainer + { + StackSize = this.MaximumStackSize, + Items = this.Items.Select(item => new CreateContainer.ItemStack + { + Uuid = item.Uuid, + InstanceId = item.InstanceId, + Quantity = item.Quantity + }).ToArray(), + Filter = [.. this.Filter], + }; + } + + public void SerializeUpdate(ref BitWriter writer) + { + throw new NotImplementedException(); + } } diff --git a/ScrapServer.Core/Services/CharacterService.cs b/ScrapServer.Core/Services/CharacterService.cs index d3429d4..f199198 100644 --- a/ScrapServer.Core/Services/CharacterService.cs +++ b/ScrapServer.Core/Services/CharacterService.cs @@ -42,14 +42,16 @@ public static Character GetCharacter(Player player) public static void RemoveCharacter(Player player) { - Character? character; - _ = Characters.TryGetValue(player, out character); - - if (character is Character chara) + if (Characters.TryGetValue(player, out Character? character)) { + var networkUpdate = new NetworkUpdate.Builder() + .WithGameTick(SchedulerService.GameTick) + .WriteRemove(character) + .Build(); + foreach (var player2 in PlayerService.GetPlayers()) { - chara.RemovePackets(player2, 0); + player2.Send(networkUpdate); } Characters.Remove(player); @@ -72,7 +74,7 @@ public static void Tick() character.Velocity = 0.8f * character.Velocity + character.TargetVelocity * (1 - 0.8f); character.Position += character.Velocity * 0.025f * character.BodyRotation; - var updateCharacter = new UpdateUnreliableCharacter { CharacterId = character.Id, IsTumbling = false }; + var updateCharacter = new UpdateUnreliableCharacter { CharacterId = (int)character.Id, IsTumbling = false }; double angleMove = Math.Atan2(character.Velocity.Y, character.Velocity.X); var anglesLook = character.HeadRotation.ExtractRotation().ToEulerAngles(); diff --git a/ScrapServer.Core/Services/PlayerService.cs b/ScrapServer.Core/Services/PlayerService.cs index 19bb7e6..49e7d4d 100644 --- a/ScrapServer.Core/Services/PlayerService.cs +++ b/ScrapServer.Core/Services/PlayerService.cs @@ -134,15 +134,16 @@ public void Receive(ReadOnlySpan data) character.Customization = info.Customization; // Send Initialization Network Update - List bytes = []; - foreach (var ply in PlayerService.GetPlayers()) + var builder = new NetworkUpdate.Builder(); + foreach (var player in PlayerService.GetPlayers()) { - if (ply.Character == null) continue; + if (player.Character == null) continue; - bytes.AddRange(ply.Character.InitNetworkPacket(SchedulerService.GameTick)); + builder.WriteCreate(player.Character); + builder.WriteUpdate(player.Character); } - Send(new InitNetworkUpdate { GameTick = SchedulerService.GameTick, Updates = bytes.ToArray() }); + Send(new InitNetworkUpdate { GameTick = SchedulerService.GameTick, Updates = builder.Build().Updates }); Send(new ScriptDataS2C { GameTick = SchedulerService.GameTick, Data = [] }); foreach (var client in PlayerService.GetPlayers()) diff --git a/ScrapServer.Networking/Data/CreateCharacter.cs b/ScrapServer.Networking/Data/CreateCharacter.cs index 13d36e3..0b2d1f3 100644 --- a/ScrapServer.Networking/Data/CreateCharacter.cs +++ b/ScrapServer.Networking/Data/CreateCharacter.cs @@ -6,7 +6,6 @@ namespace ScrapServer.Networking.Data; public class CreateCharacter { - public UInt32 NetObjId; public SteamId SteamId; public Vector3 Position; public UInt16 WorldId; @@ -16,7 +15,6 @@ public class CreateCharacter public void Deserialize(ref BitReader reader) { - NetObjId = reader.ReadUInt32(); SteamId = reader.ReadUInt64(); Position = reader.ReadVector3ZYX(); WorldId = reader.ReadUInt16(); @@ -27,7 +25,6 @@ public void Deserialize(ref BitReader reader) public void Serialize(ref BitWriter writer) { - writer.WriteUInt32(NetObjId); writer.WriteUInt64(SteamId.Value); writer.WriteVector3ZYX(Position); writer.WriteUInt16(WorldId); diff --git a/ScrapServer.Networking/Data/CreateContainer.cs b/ScrapServer.Networking/Data/CreateContainer.cs new file mode 100644 index 0000000..88c8cea --- /dev/null +++ b/ScrapServer.Networking/Data/CreateContainer.cs @@ -0,0 +1,66 @@ +using ScrapServer.Utility.Serialization; + +namespace ScrapServer.Networking.Data; + +public class CreateContainer : IBitSerializable +{ + public UInt16 StackSize; + public ItemStack[]? Items; + public Guid[]? Filter; + + public struct ItemStack + { + public Guid Uuid; + public UInt32 InstanceId; + public UInt16 Quantity; + } + + public void Deserialize(ref BitReader reader) + { + StackSize = reader.ReadUInt16(); + Items = new ItemStack[reader.ReadUInt16()]; + + for (int i = 0; i < Items.Length; i++) + { + Items[i] = new ItemStack + { + Uuid = reader.ReadGuid(), + InstanceId = reader.ReadUInt32(), + Quantity = reader.ReadUInt16() + }; + } + + Filter = new Guid[reader.ReadUInt16()]; + + for (int i = 0; i < Filter.Length; i++) + { + Filter[i] = reader.ReadGuid(); + } + } + + public void Serialize(ref BitWriter writer) + { + writer.WriteUInt16((UInt16)(Items?.Length ?? 0)); + writer.WriteUInt16(StackSize); + + if (Items != null) + { + foreach (var item in Items) + { + writer.WriteGuid(item.Uuid); + writer.WriteUInt32(item.InstanceId); + writer.WriteUInt16(item.Quantity); + } + } + + writer.WriteUInt16((UInt16)(Filter?.Length ?? 0)); + + if (Filter != null) + { + foreach (var item in Filter) + { + writer.WriteGuid(item); + } + } + } +} \ No newline at end of file diff --git a/ScrapServer.Networking/Data/CreateNetObj.cs b/ScrapServer.Networking/Data/CreateNetObj.cs deleted file mode 100644 index 39f6e05..0000000 --- a/ScrapServer.Networking/Data/CreateNetObj.cs +++ /dev/null @@ -1,23 +0,0 @@ -using ScrapServer.Utility.Serialization; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace ScrapServer.Networking.Data; - -public struct CreateNetObj : IBitSerializable -{ - public ControllerType ControllerType; - - public void Deserialize(ref BitReader reader) - { - ControllerType = (ControllerType)reader.ReadByte(); - } - - public void Serialize(ref BitWriter writer) - { - writer.WriteByte((byte)ControllerType); - } -} \ No newline at end of file diff --git a/ScrapServer.Networking/Data/NetObj.cs b/ScrapServer.Networking/Data/NetObj.cs index 635a948..d584f2d 100644 --- a/ScrapServer.Networking/Data/NetObj.cs +++ b/ScrapServer.Networking/Data/NetObj.cs @@ -131,26 +131,21 @@ public static void WriteSize(ref BitWriter writer, int position) } } - -public struct RemoveNetObj : IBitSerializable +public struct CreateNetObj : IBitSerializable { - public NetObj Header; - public uint NetObjId; + public ControllerType ControllerType; + public UInt32 NetObjId; public void Deserialize(ref BitReader reader) { - Header.Deserialize(ref reader); + ControllerType = (ControllerType)reader.ReadByte(); NetObjId = reader.ReadUInt32(); } public void Serialize(ref BitWriter writer) { - writer.GoToNearestByte(); - var pos = writer.ByteIndex; - - Header.Serialize(ref writer); + writer.WriteByte((byte)ControllerType); writer.WriteUInt32(NetObjId); - - NetObj.WriteSize(ref writer, pos); } -} \ No newline at end of file +} + diff --git a/ScrapServer.Networking/Data/UpdateCharacter.cs b/ScrapServer.Networking/Data/UpdateCharacter.cs index b712147..6a42d7b 100644 --- a/ScrapServer.Networking/Data/UpdateCharacter.cs +++ b/ScrapServer.Networking/Data/UpdateCharacter.cs @@ -26,9 +26,8 @@ public struct MovementState public bool IsTumbling; } -public struct UpdateCharacter +public ref struct UpdateCharacter { - public UInt32 NetObjId; public MovementState? Movement; public Color4? Color; public Item? SelectedItem; @@ -36,8 +35,6 @@ public struct UpdateCharacter public void Deserialize(ref BitReader reader) { - NetObjId = reader.ReadUInt32(); - bool updateMovementState = reader.ReadBit(); bool updateColor = reader.ReadBit(); bool updateSelectedItem = reader.ReadBit(); @@ -82,8 +79,6 @@ public void Deserialize(ref BitReader reader) public void Serialize(ref BitWriter writer) { - writer.WriteUInt32(NetObjId); - writer.WriteBit(Movement != null); writer.WriteBit(Color != null); writer.WriteBit(SelectedItem != null); diff --git a/ScrapServer.Networking/INetObj.cs b/ScrapServer.Networking/INetObj.cs new file mode 100644 index 0000000..2fa9d56 --- /dev/null +++ b/ScrapServer.Networking/INetObj.cs @@ -0,0 +1,29 @@ +using ScrapServer.Networking.Data; +using ScrapServer.Utility.Serialization; + +namespace ScrapServer.Networking; + +public interface INetObj +{ + public abstract NetObjType NetObjType { get; } + public abstract ControllerType ControllerType { get; } + public uint Id { get; } + + public abstract void SerializeCreate(ref BitWriter writer); + + public void SerializeP(ref BitWriter writer) + { + if (this.NetObjType != NetObjType.Joint) + { + throw new NotSupportedException("Only Joints can be serialized as P updates"); + } + throw new NotImplementedException(); + } + + public abstract void SerializeUpdate(ref BitWriter writer); + + public void SerializeRemove(ref BitWriter writer) + { + // Empty for most if not all NetObjs + } +} diff --git a/ScrapServer.Networking/NetworkUpdate.cs b/ScrapServer.Networking/NetworkUpdate.cs index d80202c..fd86f6a 100644 --- a/ScrapServer.Networking/NetworkUpdate.cs +++ b/ScrapServer.Networking/NetworkUpdate.cs @@ -26,4 +26,92 @@ public void Serialize(ref BitWriter writer) writer.WriteUInt32(GameTick); writer.WriteBytes(Updates); } + + public ref struct Builder + { + private BitWriter writer; + private UInt32 GameTick; + + public Builder() + { + writer = BitWriter.WithSharedPool(); + } + + public Builder WithGameTick(UInt32 gameTick) + { + GameTick = gameTick; + return this; + } + + private Builder Write(TNetObj netObj, NetworkUpdateType updateType) where TNetObj : INetObj + { + writer.GoToNearestByte(); + var sizePos = writer.ByteIndex; + + var header = new Networking.Data.NetObj + { + UpdateType = updateType, + ObjectType = netObj.NetObjType, + }; + header.Serialize(ref writer); + + // The controller type is only sent on create + if (updateType == NetworkUpdateType.Create) + { + writer.WriteByte((byte)netObj.ControllerType); + } + + writer.WriteUInt32(netObj.Id); + + switch(updateType) + { + case NetworkUpdateType.Create: + netObj.SerializeCreate(ref writer); + break; + case NetworkUpdateType.P: + netObj.SerializeP(ref writer); + break; + case NetworkUpdateType.Update: + netObj.SerializeUpdate(ref writer); + break; + case NetworkUpdateType.Remove: + netObj.SerializeRemove(ref writer); + break; + default: + throw new InvalidDataException($"Invalid update type: {updateType}"); + } + + Networking.Data.NetObj.WriteSize(ref writer, sizePos); + + return this; + } + + public Builder WriteCreate(TNetObj obj) where TNetObj : INetObj + { + return Write(obj, NetworkUpdateType.Create); + } + + public Builder WriteUpdate(TNetObj obj) where TNetObj : INetObj + { + return Write(obj, NetworkUpdateType.Update); + } + + public Builder WriteP(TNetObj obj) where TNetObj : INetObj + { + return Write(obj, NetworkUpdateType.P); + } + + public Builder WriteRemove(TNetObj obj) where TNetObj : INetObj + { + return Write(obj, NetworkUpdateType.Remove); + } + + public NetworkUpdate Build() + { + var packet = new NetworkUpdate { GameTick = GameTick, Updates = writer.Data.ToArray() }; + writer.Dispose(); + + return packet; + } + } } From 5c2c56c62d5629357527bc449fb75815c3c3fbd6 Mon Sep 17 00:00:00 2001 From: TechnologicNick Date: Mon, 19 Aug 2024 05:07:57 +0200 Subject: [PATCH 03/18] Fix writing Guids in little endian --- ScrapServer.Utility/Serialization/BitWriter.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/ScrapServer.Utility/Serialization/BitWriter.cs b/ScrapServer.Utility/Serialization/BitWriter.cs index 494fc17..e13bf91 100644 --- a/ScrapServer.Utility/Serialization/BitWriter.cs +++ b/ScrapServer.Utility/Serialization/BitWriter.cs @@ -311,7 +311,16 @@ public void WriteDouble(double value, ByteOrder byteOrder = ByteOrder.BigEndian) public void WriteGuid(Guid value, ByteOrder byteOrder = ByteOrder.BigEndian) { Span bytes = stackalloc byte[16]; - value.TryWriteBytes(bytes, byteOrder == ByteOrder.BigEndian, out _); + + // Always write the Guid in big endian, and later reverse if needed, + // as serializing a Guid in little endian does not produce a valid RFC 4122 Uuid. + value.TryWriteBytes(bytes, true, out _); + + if (byteOrder == ByteOrder.LittleEndian) + { + bytes.Reverse(); + } + WriteBytes(bytes); } From 16f6617627e7b1840ad9154cb23e337570e51ee9 Mon Sep 17 00:00:00 2001 From: TechnologicNick Date: Mon, 19 Aug 2024 05:08:21 +0200 Subject: [PATCH 04/18] Add creating containers for players --- ScrapServer.Core/NetObjs/Character.cs | 63 ---------------- ScrapServer.Core/NetObjs/Container.cs | 4 +- ScrapServer.Core/Services/ContainerService.cs | 64 +++++++++++++--- ScrapServer.Core/Services/PlayerService.cs | 73 ++++++++++++++++++- ScrapServer.Core/Utils/UniqueIdProvider.cs | 11 +++ .../Data/CreateContainer.cs | 4 +- 6 files changed, 139 insertions(+), 80 deletions(-) create mode 100644 ScrapServer.Core/Utils/UniqueIdProvider.cs diff --git a/ScrapServer.Core/NetObjs/Character.cs b/ScrapServer.Core/NetObjs/Character.cs index 241bede..baee9de 100644 --- a/ScrapServer.Core/NetObjs/Character.cs +++ b/ScrapServer.Core/NetObjs/Character.cs @@ -181,69 +181,6 @@ public void SerializeUpdate(ref BitWriter writer) }.Serialize(ref writer); } - public BlobData BlobData(uint tick) - { - var playerData = new PlayerData - { - CharacterID = (int)Id, - SteamID = OwnerId, - InventoryContainerID = InventoryContainerId, - CarryContainer = CarryContainerId, - CarryColor = uint.MaxValue, - PlayerID = (byte)(PlayerId-1), - Name = Name, - CharacterCustomization = Customization, - }; - - return new BlobData - { - Uid = Guid.Parse("51868883-d2d2-4953-9135-1ab0bdc2a47e"), - Key = BitConverter.GetBytes((uint)PlayerId), - WorldID = 65534, - Flags = 13, - Data = playerData.ToBytes() - }; - } - - public BlobData BlobDataNeg(uint tick) - { - var playerData = new PlayerData - { - CharacterID = -1, - SteamID = OwnerId, - InventoryContainerID = InventoryContainerId, - CarryContainer = CarryContainerId, - CarryColor = uint.MaxValue, - PlayerID = (byte)(PlayerId - 1), - Name = Name, - CharacterCustomization = Customization, - }; - - return new BlobData - { - Uid = Guid.Parse("51868883-d2d2-4953-9135-1ab0bdc2a47e"), - Key = BitConverter.GetBytes((uint)PlayerId), - WorldID = 65534, - Flags = 13, - Data = playerData.ToBytes() - }; - } - - public void SpawnPackets(Player player, uint tick) - { - // Packet 13 - Generic Init Data - player.Send(new GenericInitData { Data = [BlobData(tick)], GameTick = tick }); - - // Packet 22 - Network Update - player.Send( - new NetworkUpdate.Builder() - .WithGameTick(tick + 1) - .WriteCreate(this) - .WriteUpdate(this) - .Build() - ); - } - public class Builder { private Character _character = new Character(); diff --git a/ScrapServer.Core/NetObjs/Container.cs b/ScrapServer.Core/NetObjs/Container.cs index 317c72d..a616cc7 100644 --- a/ScrapServer.Core/NetObjs/Container.cs +++ b/ScrapServer.Core/NetObjs/Container.cs @@ -74,7 +74,7 @@ public Container Clone() public void SerializeCreate(ref BitWriter writer) { - var createContainer = new CreateContainer + new CreateContainer { StackSize = this.MaximumStackSize, Items = this.Items.Select(item => new CreateContainer.ItemStack @@ -84,7 +84,7 @@ public void SerializeCreate(ref BitWriter writer) Quantity = item.Quantity }).ToArray(), Filter = [.. this.Filter], - }; + }.Serialize(ref writer); } public void SerializeUpdate(ref BitWriter writer) diff --git a/ScrapServer.Core/Services/ContainerService.cs b/ScrapServer.Core/Services/ContainerService.cs index 1c32485..f21b0eb 100644 --- a/ScrapServer.Core/Services/ContainerService.cs +++ b/ScrapServer.Core/Services/ContainerService.cs @@ -1,12 +1,18 @@ using ScrapServer.Core.NetObjs; +using ScrapServer.Core.Utils; using static ScrapServer.Core.NetObjs.Container; namespace ScrapServer.Core; + public class ContainerService { + public static readonly ContainerService Instance = new(); + public Dictionary Containers = []; + public readonly UniqueIdProvider UniqueIdProvider = new(); + private volatile bool IsInTransaction = false; public class Transaction(ContainerService containerService) : IDisposable @@ -25,7 +31,7 @@ public class Transaction(ContainerService containerService) : IDisposable /// A tuple containing the number of items collected and the result of the operation public (ushort Collected, OperationResult Result) CollectToSlot(Container container, ItemStack itemStack, ushort slot, bool mustCollectAll = true) { - var containerCopyOnWrite = modified[container.Id] ?? container.Clone(); + var containerCopyOnWrite = modified.TryGetValue(container.Id, out Container? found) ? found : container.Clone(); if (slot < 0 || slot > containerCopyOnWrite.Items.Length) { @@ -60,23 +66,53 @@ public class Transaction(ContainerService containerService) : IDisposable return ((ushort)quantityToCollect, OperationResult.Success); } - public void Dispose() + public void EndTransaction() { - Dispose(true); - GC.SuppressFinalize(this); + if (!containerService.IsInTransaction) + { + throw new InvalidOperationException("Transaction was not started"); + } + + foreach (var (id, container) in modified) + { + if (!containerService.Containers.TryGetValue(id, out Container? target)) + { + throw new InvalidOperationException($"Container with ID {id} was not found"); + } + + Array.Copy(container.Items, target.Items, container.Items.Length); + + + target.Filter.Clear(); + target.Filter.UnionWith(container.Filter); + } + + containerService.IsInTransaction = false; } - protected virtual void Dispose(bool disposing) + public void AbortTransaction() { - if (!disposing) + if (!containerService.IsInTransaction) { - return; + throw new InvalidOperationException("Transaction was not started"); } - if (!containerService.IsInTransaction) + this.modified.Clear(); + + containerService.IsInTransaction = false; + } + + public void Dispose() + { + if (containerService.IsInTransaction) { - throw new InvalidOperationException("Transaction was not committed or rolled back"); + this.AbortTransaction(); + Console.WriteLine("Transaction was not committed or rolled back, aborting..."); + + // Cannot throw an exception here, or it will be silently swallowed + // https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1065#dispose-methods } + GC.SuppressFinalize(this); } public class SlotIndexOutOfRangeException(string message) : Exception(message) @@ -110,4 +146,14 @@ public ushort GetMaximumStackSize(Container container, Guid uuid) return container.MaximumStackSize; } + + public Container CreateContainer(ushort size, ushort maximumStackSize = ushort.MaxValue) + { + var id = UniqueIdProvider.GetNextId(); + var container = new Container(id, size, maximumStackSize); + + Containers[id] = container; + + return container; + } } diff --git a/ScrapServer.Core/Services/PlayerService.cs b/ScrapServer.Core/Services/PlayerService.cs index 49e7d4d..7630692 100644 --- a/ScrapServer.Core/Services/PlayerService.cs +++ b/ScrapServer.Core/Services/PlayerService.cs @@ -6,6 +6,8 @@ using Steamworks.Data; using System.Text; using ScrapServer.Core.NetObjs; +using static ScrapServer.Core.NetObjs.Container; +using System.Xml.Linq; namespace ScrapServer.Core; @@ -16,11 +18,15 @@ public class Player public ulong SteamId; public string Username = ""; public Character? Character { get; private set; } + public Container InventoryContainer { get; private set; } + public Container CarryContainer { get; private set; } public Connection? SteamConn { get; private set; } - public Player(Connection conn) + public Player(Connection conn, Container inventoryContainer, Container carryContainer) { SteamConn = conn; + InventoryContainer = inventoryContainer; + CarryContainer = carryContainer; } public void Kick() @@ -108,7 +114,7 @@ public void Receive(ReadOnlySpan data) { if (ply.Character == null) continue; - blobs.Add(ply.Character.BlobData(SchedulerService.GameTick)); + blobs.Add(GetPlayerData(ply.Character)); } var genericInit = new GenericInitData { Data = blobs.ToArray(), GameTick = SchedulerService.GameTick }; @@ -141,6 +147,8 @@ public void Receive(ReadOnlySpan data) builder.WriteCreate(player.Character); builder.WriteUpdate(player.Character); + builder.WriteCreate(player.InventoryContainer); + builder.WriteCreate(player.CarryContainer); } Send(new InitNetworkUpdate { GameTick = SchedulerService.GameTick, Updates = builder.Build().Updates }); @@ -148,7 +156,7 @@ public void Receive(ReadOnlySpan data) foreach (var client in PlayerService.GetPlayers()) { - character.SpawnPackets(client, SchedulerService.GameTick); + client.SendSpawnPackets(character, SchedulerService.GameTick); } Console.WriteLine("Sent ScriptInitData and NetworkInitUpdate for Client"); @@ -175,6 +183,44 @@ public void Receive(ReadOnlySpan data) } } + public BlobData GetPlayerData(Character character) + { + var playerData = new PlayerData + { + CharacterID = (int)character.Id, + SteamID = this.SteamId, + InventoryContainerID = this.InventoryContainer.Id, + CarryContainer = this.CarryContainer.Id, + CarryColor = uint.MaxValue, + PlayerID = (byte)(this.Id - 1), + Name = this.Username, + CharacterCustomization = character.Customization, + }; + + return new BlobData + { + Uid = Guid.Parse("51868883-d2d2-4953-9135-1ab0bdc2a47e"), + Key = BitConverter.GetBytes((uint)this.Id), + WorldID = 65534, + Flags = 13, + Data = playerData.ToBytes() + }; + } + public void SendSpawnPackets(Character character, uint tick) + { + // Packet 13 - Generic Init Data + this.Send(new GenericInitData { Data = [this.GetPlayerData(character)], GameTick = tick }); + + // Packet 22 - Network Update + this.Send( + new NetworkUpdate.Builder() + .WithGameTick(tick + 1) + .WriteCreate(character) + .WriteUpdate(character) + .Build() + ); + } + public Character CreateCharacter() { Character = CharacterService.GetCharacter(this); @@ -259,7 +305,26 @@ private static Player CreatePlayer(Connection conn, NetIdentity identity) // ... // If it still cannot be found, we make a new one - var player = new Player(conn); + var inventoryContainer = ContainerService.Instance.CreateContainer(30); + Console.WriteLine("Created inventory container with ID {0}", inventoryContainer.Id); + using (var transaction = ContainerService.Instance.BeginTransaction()) + { + for (int i = 0; i < 30; i++) + { + transaction.CollectToSlot( + inventoryContainer, + new ItemStack(Guid.Parse("df953d9c-234f-4ac2-af5e-f0490b223e71"), ItemStack.NoInstanceId, (ushort)(i + 1)), + (ushort)i + ); + } + Console.WriteLine("Collected items into inventory container"); + transaction.EndTransaction(); + } + + var carryContainer = ContainerService.Instance.CreateContainer(1); + Console.WriteLine("Created carry container with ID {0}", carryContainer.Id); + var player = new Player(conn, inventoryContainer, carryContainer); + Console.WriteLine("Created player with ID {0}", player.Id); player.Id = NextPlayerID; player.SteamId = identity.SteamId.Value; diff --git a/ScrapServer.Core/Utils/UniqueIdProvider.cs b/ScrapServer.Core/Utils/UniqueIdProvider.cs new file mode 100644 index 0000000..a69e83e --- /dev/null +++ b/ScrapServer.Core/Utils/UniqueIdProvider.cs @@ -0,0 +1,11 @@ +namespace ScrapServer.Core.Utils; + +public class UniqueIdProvider +{ + private volatile uint currentId = 0; + + public uint GetNextId() + { + return Interlocked.Increment(ref this.currentId); + } +} diff --git a/ScrapServer.Networking/Data/CreateContainer.cs b/ScrapServer.Networking/Data/CreateContainer.cs index 88c8cea..3732eb9 100644 --- a/ScrapServer.Networking/Data/CreateContainer.cs +++ b/ScrapServer.Networking/Data/CreateContainer.cs @@ -24,7 +24,7 @@ public void Deserialize(ref BitReader reader) { Items[i] = new ItemStack { - Uuid = reader.ReadGuid(), + Uuid = reader.ReadGuid(ByteOrder.LittleEndian), InstanceId = reader.ReadUInt32(), Quantity = reader.ReadUInt16() }; @@ -47,7 +47,7 @@ public void Serialize(ref BitWriter writer) { foreach (var item in Items) { - writer.WriteGuid(item.Uuid); + writer.WriteGuid(item.Uuid, ByteOrder.LittleEndian); writer.WriteUInt32(item.InstanceId); writer.WriteUInt16(item.Quantity); } From 1f23bb7be197ae9365ce4c55657da4ea92733222 Mon Sep 17 00:00:00 2001 From: TechnologicNick Date: Mon, 19 Aug 2024 15:41:24 +0200 Subject: [PATCH 05/18] Fix multiplayer --- ScrapServer.Core/Services/PlayerService.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/ScrapServer.Core/Services/PlayerService.cs b/ScrapServer.Core/Services/PlayerService.cs index 7630692..470d9e9 100644 --- a/ScrapServer.Core/Services/PlayerService.cs +++ b/ScrapServer.Core/Services/PlayerService.cs @@ -1,4 +1,4 @@ -using ScrapServer.Networking; +using ScrapServer.Networking; using ScrapServer.Networking.Data; using ScrapServer.Core.Utils; using ScrapServer.Utility.Serialization; @@ -110,11 +110,11 @@ public void Receive(ReadOnlySpan data) ]; - foreach (var ply in PlayerService.GetPlayers()) + foreach (var player in PlayerService.GetPlayers()) { - if (ply.Character == null) continue; + if (player.Character == null) continue; - blobs.Add(GetPlayerData(ply.Character)); + blobs.Add(player.GetPlayerData(player.Character)); } var genericInit = new GenericInitData { Data = blobs.ToArray(), GameTick = SchedulerService.GameTick }; @@ -156,7 +156,7 @@ public void Receive(ReadOnlySpan data) foreach (var client in PlayerService.GetPlayers()) { - client.SendSpawnPackets(character, SchedulerService.GameTick); + client.SendSpawnPackets(this, character, SchedulerService.GameTick); } Console.WriteLine("Sent ScriptInitData and NetworkInitUpdate for Client"); @@ -206,10 +206,10 @@ public BlobData GetPlayerData(Character character) Data = playerData.ToBytes() }; } - public void SendSpawnPackets(Character character, uint tick) + public void SendSpawnPackets(Player player, Character character, uint tick) { // Packet 13 - Generic Init Data - this.Send(new GenericInitData { Data = [this.GetPlayerData(character)], GameTick = tick }); + this.Send(new GenericInitData { Data = [player.GetPlayerData(character)], GameTick = tick }); // Packet 22 - Network Update this.Send( From 28eff2a30fb8efac67a7db45441c56c4de8276db Mon Sep 17 00:00:00 2001 From: TechnologicNick Date: Tue, 20 Aug 2024 23:11:36 +0200 Subject: [PATCH 06/18] Add ContainerService tests --- SM_Server.sln | 8 +- ScrapServer.Core/Services/ContainerService.cs | 24 ++-- .../ScrapServer.CoreTests.csproj | 32 +++++ .../Services/ContainerServiceTests.cs | 115 ++++++++++++++++++ 4 files changed, 164 insertions(+), 15 deletions(-) create mode 100644 ScrapServer.CoreTests/ScrapServer.CoreTests.csproj create mode 100644 ScrapServer.CoreTests/Services/ContainerServiceTests.cs diff --git a/SM_Server.sln b/SM_Server.sln index 04e43a9..885c300 100644 --- a/SM_Server.sln +++ b/SM_Server.sln @@ -9,7 +9,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ScrapServer.Utility", "Scra EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ScrapServer.Vanilla", "ScrapServer.Vanilla\ScrapServer.Vanilla.csproj", "{420F4578-DED4-473D-8524-47B59A1D312E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScrapServer.Core", "ScrapServer.Core\ScrapServer.Core.csproj", "{C8C723D4-F703-4E3A-80FB-9423F38C9BD0}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ScrapServer.Core", "ScrapServer.Core\ScrapServer.Core.csproj", "{C8C723D4-F703-4E3A-80FB-9423F38C9BD0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ScrapServer.CoreTests", "ScrapServer.CoreTests\ScrapServer.CoreTests.csproj", "{3FA34FDC-926F-445B-B315-64A2872436CD}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -33,6 +35,10 @@ Global {C8C723D4-F703-4E3A-80FB-9423F38C9BD0}.Debug|Any CPU.Build.0 = Debug|Any CPU {C8C723D4-F703-4E3A-80FB-9423F38C9BD0}.Release|Any CPU.ActiveCfg = Release|Any CPU {C8C723D4-F703-4E3A-80FB-9423F38C9BD0}.Release|Any CPU.Build.0 = Release|Any CPU + {3FA34FDC-926F-445B-B315-64A2872436CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3FA34FDC-926F-445B-B315-64A2872436CD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3FA34FDC-926F-445B-B315-64A2872436CD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3FA34FDC-926F-445B-B315-64A2872436CD}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ScrapServer.Core/Services/ContainerService.cs b/ScrapServer.Core/Services/ContainerService.cs index f21b0eb..e87411a 100644 --- a/ScrapServer.Core/Services/ContainerService.cs +++ b/ScrapServer.Core/Services/ContainerService.cs @@ -13,7 +13,7 @@ public class ContainerService public readonly UniqueIdProvider UniqueIdProvider = new(); - private volatile bool IsInTransaction = false; + private volatile Transaction? CurrentTransaction; public class Transaction(ContainerService containerService) : IDisposable { @@ -68,9 +68,9 @@ public class Transaction(ContainerService containerService) : IDisposable public void EndTransaction() { - if (!containerService.IsInTransaction) + if (containerService.CurrentTransaction != this) { - throw new InvalidOperationException("Transaction was not started"); + throw new InvalidOperationException("Attempted to end a transaction that is not the current transaction"); } foreach (var (id, container) in modified) @@ -87,24 +87,22 @@ public void EndTransaction() target.Filter.UnionWith(container.Filter); } - containerService.IsInTransaction = false; + containerService.CurrentTransaction = null; } public void AbortTransaction() { - if (!containerService.IsInTransaction) + if (containerService.CurrentTransaction != this) { - throw new InvalidOperationException("Transaction was not started"); + throw new InvalidOperationException("Attempted to abort a transaction that is not the current transaction"); } - this.modified.Clear(); - - containerService.IsInTransaction = false; + containerService.CurrentTransaction = null; } public void Dispose() { - if (containerService.IsInTransaction) + if (containerService.CurrentTransaction != null) { this.AbortTransaction(); Console.WriteLine("Transaction was not committed or rolled back, aborting..."); @@ -130,14 +128,12 @@ public enum OperationResult public Transaction BeginTransaction() { - if (this.IsInTransaction) + if (this.CurrentTransaction != null) { throw new InvalidOperationException("Cannot start a transaction while one is already in progress"); } - this.IsInTransaction = true; - - return new Transaction(this); + return this.CurrentTransaction = new Transaction(this); } public ushort GetMaximumStackSize(Container container, Guid uuid) diff --git a/ScrapServer.CoreTests/ScrapServer.CoreTests.csproj b/ScrapServer.CoreTests/ScrapServer.CoreTests.csproj new file mode 100644 index 0000000..4d4056b --- /dev/null +++ b/ScrapServer.CoreTests/ScrapServer.CoreTests.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + enable + enable + + false + true + + + + 1701;1702;CS8618 + + + + 1701;1702;CS8618 + + + + + + + + + + + + + + + diff --git a/ScrapServer.CoreTests/Services/ContainerServiceTests.cs b/ScrapServer.CoreTests/Services/ContainerServiceTests.cs new file mode 100644 index 0000000..d9f90b1 --- /dev/null +++ b/ScrapServer.CoreTests/Services/ContainerServiceTests.cs @@ -0,0 +1,115 @@ +using NUnit.Framework; +using ScrapServer.Core; + +namespace ScrapServer.CoreTests.Services; + +[TestFixture] +public class ContainerServiceTests +{ + private ContainerService CreateService() + { + return new ContainerService(); + } + + [Test] + public void BeginTransaction_CorrectUsage_ReturnsTransaction() + { + // Arrange + var service = this.CreateService(); + + // Act + using var transaction = service.BeginTransaction(); + + // Assert + Assert.That(transaction, Is.InstanceOf()); + + transaction.EndTransaction(); + } + + [Test] + public void BeginTransaction_DoubleBegin_ThrowsException() + { + // Arrange + var service = this.CreateService(); + + // Act + using var transaction = service.BeginTransaction(); + + // Assert + Assert.That(() => service.BeginTransaction(), Throws.InvalidOperationException); + + // Cleanup + transaction.EndTransaction(); + } + + [Test] + public void EndTransaction_EndPreviousTransaction_ThrowsException() + { + // Arrange + var service = this.CreateService(); + + // Act + using var transactionA = service.BeginTransaction(); + transactionA.EndTransaction(); + using var transactionB = service.BeginTransaction(); + + // Assert + Assert.That(() => transactionA.EndTransaction(), Throws.InvalidOperationException); + + // Cleanup + transactionB.EndTransaction(); + } + + [Test] + public void AbortTransaction_AbortPreviousTransaction_ThrowsException() + { + // Arrange + var service = this.CreateService(); + + // Act + using var transactionA = service.BeginTransaction(); + transactionA.EndTransaction(); + using var transactionB = service.BeginTransaction(); + + // Assert + Assert.That(() => transactionA.AbortTransaction(), Throws.InvalidOperationException); + + // Cleanup + transactionB.EndTransaction(); + } + + [Test] + public void Dispose_ForgettingToEndOrAbort_AbortsAutomatically() + { + // Arrange + var service = this.CreateService(); + + // Act + using (var transaction = service.BeginTransaction()) + { + // Transaction should be aborted automatically + } + + // Assert + Assert.That(() => service.BeginTransaction(), Throws.Nothing); + } + + [Test] + public void Dispose_ExceptionThrownInTransaction_AbortsAutomatically() + { + // Arrange + var service = this.CreateService(); + + // Act + Assert.That(() => + { + using (var transaction = service.BeginTransaction()) + { + throw new Exception("Test exception"); + } + }, Throws.Exception); + + // Assert + Assert.That(() => service.BeginTransaction(), Throws.Nothing); + } +} From 011f67360832d52359f3f21595121b7675336793 Mon Sep 17 00:00:00 2001 From: TechnologicNick Date: Tue, 20 Aug 2024 23:46:54 +0200 Subject: [PATCH 07/18] Add CI workflow --- .github/workflows/ci.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d79d486 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: [push] + +jobs: + build: + name: Build and Test + + runs-on: ubuntu-latest + strategy: + matrix: + dotnet-version: [ "8.0" ] + + steps: + - uses: actions/checkout@v4 + + - name: Setup dotnet + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ matrix.dotnet-version }} + + - name: Install dependencies + run: dotnet restore + + - name: Test with dotnet + run: dotnet test --logger trx --results-directory "TestResults-${{ matrix.dotnet-version }}" + + - name: Upload dotnet test results + uses: actions/upload-artifact@v4 + with: + name: dotnet-results-${{ matrix.dotnet-version }} + path: TestResults-${{ matrix.dotnet-version }} + # Use always() to always run this step to publish test results when there are test failures + if: ${{ always() }} From d365f85a483b7eaaeaaa2d69088f973c0fbd4b3f Mon Sep 17 00:00:00 2001 From: TechnologicNick Date: Wed, 21 Aug 2024 01:08:26 +0200 Subject: [PATCH 08/18] Add ContainerService CollectToSlot tests --- ScrapServer.Core/Services/ContainerService.cs | 58 +++++- .../Services/ContainerServiceTests.cs | 173 ++++++++++++++++++ 2 files changed, 228 insertions(+), 3 deletions(-) diff --git a/ScrapServer.Core/Services/ContainerService.cs b/ScrapServer.Core/Services/ContainerService.cs index e87411a..dadabf6 100644 --- a/ScrapServer.Core/Services/ContainerService.cs +++ b/ScrapServer.Core/Services/ContainerService.cs @@ -4,23 +4,40 @@ namespace ScrapServer.Core; - public class ContainerService { + /// + /// The singleton instance of the container service. + /// public static readonly ContainerService Instance = new(); + /// + /// The dictionary of containers, indexed by their unique ID. + /// public Dictionary Containers = []; + /// + /// The unique ID provider for containers. + /// public readonly UniqueIdProvider UniqueIdProvider = new(); + /// + /// The current transaction, null if no transaction is in progress. + /// private volatile Transaction? CurrentTransaction; + /// + /// New transactions should be created using . + /// + /// public class Transaction(ContainerService containerService) : IDisposable { private readonly Dictionary modified = []; /// /// Collects items into a specific slot of a container. + /// + /// This implementation is designed to return sensible results and does not match `sm.container.collectToSlot` exactly. /// /// The container to collect the items into /// The item stack, including quantity, to collect @@ -29,11 +46,12 @@ public class Transaction(ContainerService containerService) : IDisposable /// If false, collect as many items that fit into the remaining space, and do so without overflowing into other slots. /// /// A tuple containing the number of items collected and the result of the operation + /// If the slot index is out of range public (ushort Collected, OperationResult Result) CollectToSlot(Container container, ItemStack itemStack, ushort slot, bool mustCollectAll = true) { var containerCopyOnWrite = modified.TryGetValue(container.Id, out Container? found) ? found : container.Clone(); - if (slot < 0 || slot > containerCopyOnWrite.Items.Length) + if (slot < 0 || slot >= containerCopyOnWrite.Items.Length) { throw new SlotIndexOutOfRangeException($"Slot {slot} is out of range [0, {containerCopyOnWrite.Items.Length})"); } @@ -66,6 +84,10 @@ public class Transaction(ContainerService containerService) : IDisposable return ((ushort)quantityToCollect, OperationResult.Success); } + /// + /// Ends the transaction and applies the changes to the containers. + /// + /// If the transaction is not the current transaction public void EndTransaction() { if (containerService.CurrentTransaction != this) @@ -82,7 +104,6 @@ public void EndTransaction() Array.Copy(container.Items, target.Items, container.Items.Length); - target.Filter.Clear(); target.Filter.UnionWith(container.Filter); } @@ -90,6 +111,10 @@ public void EndTransaction() containerService.CurrentTransaction = null; } + /// + /// Aborts the transaction and discards the changes. + /// + /// If the transaction is not the current transaction public void AbortTransaction() { if (containerService.CurrentTransaction != this) @@ -126,6 +151,11 @@ public enum OperationResult } } + /// + /// Creates a new transaction and sets it as the current transaction. + /// + /// The transaction + /// If a transaction is already in progress public Transaction BeginTransaction() { if (this.CurrentTransaction != null) @@ -136,6 +166,12 @@ public Transaction BeginTransaction() return this.CurrentTransaction = new Transaction(this); } + /// + /// Gets the maximum stack size of an item in a container. + /// + /// The container + /// The UUID of the item + /// The maximum stack size public ushort GetMaximumStackSize(Container container, Guid uuid) { // TODO: Return the minimum of the maximum stack size of the item and the container @@ -143,6 +179,12 @@ public ushort GetMaximumStackSize(Container container, Guid uuid) return container.MaximumStackSize; } + /// + /// Creates a new container with a unique ID and adds it to the list of containers. + /// + /// The amount of slots in the container + /// The maximum stack size of items in the container + /// The created container public Container CreateContainer(ushort size, ushort maximumStackSize = ushort.MaxValue) { var id = UniqueIdProvider.GetNextId(); @@ -152,4 +194,14 @@ public Container CreateContainer(ushort size, ushort maximumStackSize = ushort.M return container; } + + /// + /// Removes a container from the list of containers. + /// + /// The ID of the container to remove + /// true if the container was removed; otherwise, false + public bool RemoveContainer(uint id) + { + return Containers.Remove(id); + } } diff --git a/ScrapServer.CoreTests/Services/ContainerServiceTests.cs b/ScrapServer.CoreTests/Services/ContainerServiceTests.cs index d9f90b1..8b1bdd5 100644 --- a/ScrapServer.CoreTests/Services/ContainerServiceTests.cs +++ b/ScrapServer.CoreTests/Services/ContainerServiceTests.cs @@ -1,5 +1,6 @@ using NUnit.Framework; using ScrapServer.Core; +using static ScrapServer.Core.NetObjs.Container; namespace ScrapServer.CoreTests.Services; @@ -11,6 +12,9 @@ private ContainerService CreateService() return new ContainerService(); } + private readonly ItemStack WoodBlock = new(Guid.Parse("df953d9c-234f-4ac2-af5e-f0490b223e71"), ItemStack.NoInstanceId, (ushort)1); + private readonly ItemStack ConcreteBlock = new(Guid.Parse("a6c6ce30-dd47-4587-b475-085d55c6a3b4"), ItemStack.NoInstanceId, (ushort)1); + [Test] public void BeginTransaction_CorrectUsage_ReturnsTransaction() { @@ -112,4 +116,173 @@ public void Dispose_ExceptionThrownInTransaction_AbortsAutomatically() // Assert Assert.That(() => service.BeginTransaction(), Throws.Nothing); } + + [Test] + public void CollectToSlot_WoodToEmptySlot_ReturnsSuccess() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 1); + + // Act + using var transaction = service.BeginTransaction(); + + // Assert + Assert.That( + () => transaction.CollectToSlot(container, WoodBlock, slot: 0), + Is.EqualTo((1, ContainerService.Transaction.OperationResult.Success)) + ); + + // Cleanup + transaction.EndTransaction(); + } + + [Test] + public void CollectToSlot_SlotIndexOutOfRange_ThrowsException() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 1); + + // Act + using var transaction = service.BeginTransaction(); + + // Assert + Assert.That( + () => transaction.CollectToSlot(container, WoodBlock, slot: 1), + Throws.TypeOf() + ); + + // Cleanup + transaction.EndTransaction(); + } + + [Test] + public void CollectToSlot_NotStackable_ReturnsZero() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 1); + + // Act + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(container, ConcreteBlock, slot: 0); + + // Assert + Assert.That( + () => transaction.CollectToSlot(container, WoodBlock, slot: 0), + Is.EqualTo((0, ContainerService.Transaction.OperationResult.NotStackable)) + ); + + // Cleanup + transaction.EndTransaction(); + } + + [Test] + public void CollectToSlot_NotEnoughSpace_ReturnsZero() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 1, maximumStackSize: 1); + + // Act + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(container, WoodBlock, slot: 0); + + // Assert + Assert.That( + () => transaction.CollectToSlot(container, WoodBlock, slot: 0), + Is.EqualTo((0, ContainerService.Transaction.OperationResult.NotEnoughSpace)) + ); + + // Cleanup + transaction.EndTransaction(); + } + + [Test] + public void CollectToSlot_NotEnoughSpaceForAll_ReturnsZero() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 1, maximumStackSize: 6); + + // Act + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(container, WoodBlock with { Quantity = 4 }, slot: 0); + + // Assert + Assert.That( + () => transaction.CollectToSlot( + container, + WoodBlock with { Quantity = 3 }, + slot: 0, + mustCollectAll: true + ), + Is.EqualTo((0, ContainerService.Transaction.OperationResult.NotEnoughSpaceForAll)) + ); + + // Cleanup + transaction.EndTransaction(); + } + + [Test] + public void CollectToSlot_MustCollectAllFalse_ReturnsSuccess() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 1, maximumStackSize: 6); + + // Act + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(container, WoodBlock with { Quantity = 4 }, slot: 0); + + // Assert + Assert.That( + () => transaction.CollectToSlot( + container, + WoodBlock with { Quantity = 3 }, + slot: 0, + mustCollectAll: false + ), + Is.EqualTo((2, ContainerService.Transaction.OperationResult.Success)) + ); + + // Cleanup + transaction.EndTransaction(); + } + + [Test] + public void EndTransaction_NormalUsage_UpdatesItems() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 1); + + // Act + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(container, WoodBlock, slot: 0); + transaction.EndTransaction(); + + // Assert + Assert.That(container.Items, Is.EqualTo(new ItemStack[] { WoodBlock })); + } + + [Test] + public void EndTransaction_ContainerRemovedBeforeEnd_ThrowsException() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 1); + + // Act + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(container, WoodBlock, slot: 0); + service.RemoveContainer(container.Id); + + // Assert + Assert.That(() => transaction.EndTransaction(), Throws.InvalidOperationException); + + // Cleanup + transaction.AbortTransaction(); + } } From 65f2d8b7e770285432b106429354b7964cd4b922 Mon Sep 17 00:00:00 2001 From: TechnologicNick Date: Wed, 21 Aug 2024 23:05:18 +0200 Subject: [PATCH 09/18] Add ContainerTransaction packet --- .../ContainerTransaction.cs | 429 ++++++++++++++++++ ScrapServer.Networking/Data/PacketId.cs | 1 + 2 files changed, 430 insertions(+) create mode 100644 ScrapServer.Networking/ContainerTransaction.cs diff --git a/ScrapServer.Networking/ContainerTransaction.cs b/ScrapServer.Networking/ContainerTransaction.cs new file mode 100644 index 0000000..b023e5b --- /dev/null +++ b/ScrapServer.Networking/ContainerTransaction.cs @@ -0,0 +1,429 @@ +using ScrapServer.Networking.Data; +using ScrapServer.Utility.Serialization; + +namespace ScrapServer.Networking; + +public struct ContainerTransaction : IPacket +{ + /// + public static PacketId PacketId => PacketId.ContainerTransaction; + + /// + public static bool IsCompressable => true; + + /// + /// Represents a stack of items stored in a container. + /// + public record struct StoredItemStack : IBitSerializable + { + public Guid Uuid; + public uint InstanceId; + public ushort Quantity; + public ushort Slot; + public uint ContainerId; + + public void Deserialize(ref BitReader reader) + { + this.Uuid = reader.ReadGuid(); + this.InstanceId = reader.ReadUInt32(); + this.Quantity = reader.ReadUInt16(); + this.Slot = reader.ReadUInt16(); + this.ContainerId = reader.ReadUInt32(); + } + + public readonly void Serialize(ref BitWriter writer) + { + writer.WriteGuid(this.Uuid); + writer.WriteUInt32(this.InstanceId); + writer.WriteUInt16(this.Quantity); + writer.WriteUInt16(this.Slot); + writer.WriteUInt32(this.ContainerId); + } + } + + /// + /// Represents the type of action to perform on a container. + /// + public enum ActionType : byte + { + SetItem = 0, + Swap = 1, + Collect = 2, + Spend = 3, + CollectToSlot = 4, + CollectToSlotOrCollect = 5, + SpendFromSlot = 6, + Move = 7, + MoveFromSlot = 8, + MoveAll = 9, + } + + public interface IAction : IBitSerializable + { + /// + /// The type of action to perform on the container. + /// + public abstract ActionType ActionType { get; } + } + + public struct SetItemAction : IAction + { + public readonly ActionType ActionType => ActionType.SetItem; + + /// + /// The item stack to set the slot in the container to. + /// + public StoredItemStack To; + + public void Deserialize(ref BitReader reader) + { + this.To = reader.ReadObject(); + } + + public readonly void Serialize(ref BitWriter writer) + { + writer.WriteObject(this.To); + } + } + + public struct SwapAction : IAction + { + public readonly ActionType ActionType => ActionType.Swap; + + /// + /// The item stack of the dragged item. + /// + public StoredItemStack From; + + /// + /// The item stack the dragged item is dropped onto. + /// + public StoredItemStack To; + + public void Deserialize(ref BitReader reader) + { + this.From = reader.ReadObject(); + this.To = reader.ReadObject(); + } + + public readonly void Serialize(ref BitWriter writer) + { + writer.WriteObject(this.From); + writer.WriteObject(this.To); + } + } + + public struct CollectAction : IAction + { + public readonly ActionType ActionType => ActionType.Collect; + + /// + /// The UUID of the item to collect. + /// + public Guid Uuid; + + /// + /// Most likely the tool instance ID, but it is always `0xFFFFFFFF`, even for tools. + /// + public uint ToolInstanceId; + + /// + /// The amount of items to add to the container. + /// + public ushort Quantity; + + /// + /// The ID of the container to add the items to. + /// + public uint ContainerId; + + /// + /// Most likely to be the must collect all flag. + /// + public bool MustCollectAll; + + public void Deserialize(ref BitReader reader) + { + this.Uuid = reader.ReadGuid(); + this.ToolInstanceId = reader.ReadUInt32(); + this.Quantity = reader.ReadUInt16(); + this.ContainerId = reader.ReadUInt32(); + this.MustCollectAll = reader.ReadBoolean(); + } + + public readonly void Serialize(ref BitWriter writer) + { + writer.WriteGuid(this.Uuid); + writer.WriteUInt32(this.ToolInstanceId); + writer.WriteUInt16(this.Quantity); + writer.WriteUInt32(this.ContainerId); + writer.WriteBoolean(this.MustCollectAll); + } + } + + public struct SpendAction : IAction + { + public ActionType ActionType => ActionType.Spend; + + /// + /// The UUID of the item to remove from the container. + /// + public Guid Uuid; + + /// + /// Most likely the tool instance ID, but it is always `0xFFFFFFFF`, even for tools. + /// + public uint ToolInstanceId; + + /// + /// The amount of items to remove from the container. + /// + public ushort Quantity; + + /// + /// The ID of the container to remove the items to. + /// + public uint ContainerId; + + /// + /// Most likely to be the must spend all flag. + /// + public bool MustSpendAll; + + public void Deserialize(ref BitReader reader) + { + this.Uuid = reader.ReadGuid(); + this.ToolInstanceId = reader.ReadUInt32(); + this.Quantity = reader.ReadUInt16(); + this.ContainerId = reader.ReadUInt32(); + this.MustSpendAll = reader.ReadBoolean(); + } + + public readonly void Serialize(ref BitWriter writer) + { + writer.WriteGuid(this.Uuid); + writer.WriteUInt32(this.ToolInstanceId); + writer.WriteUInt16(this.Quantity); + writer.WriteUInt32(this.ContainerId); + writer.WriteBoolean(this.MustSpendAll); + } + } + + public struct CollectToSlotAction : IAction + { + public readonly ActionType ActionType => ActionType.CollectToSlot; + + /// + /// The item stack to add to the container in the specified slot. + /// + public StoredItemStack To; + + /// + /// Most likely to be the must collect all flag. + /// + public bool MustCollectAll; + + public void Deserialize(ref BitReader reader) + { + this.To = reader.ReadObject(); + this.MustCollectAll = reader.ReadBoolean(); + } + + public readonly void Serialize(ref BitWriter writer) + { + writer.WriteObject(this.To); + writer.WriteBoolean(this.MustCollectAll); + } + } + + public struct CollectToSlotOrCollectAction : IAction + { + public readonly ActionType ActionType => ActionType.CollectToSlotOrCollect; + + /// + /// The item stack to add to the container in the specified slot. + /// + public StoredItemStack To; + + /// + /// Most likely to be the must collect all flag. + /// + public bool MustCollectAll; + + public void Deserialize(ref BitReader reader) + { + this.To = reader.ReadObject(); + this.MustCollectAll = reader.ReadBoolean(); + } + + public readonly void Serialize(ref BitWriter writer) + { + writer.WriteObject(this.To); + writer.WriteBoolean(this.MustCollectAll); + } + } + + public struct SpendFromSlotAction : IAction + { + public readonly ActionType ActionType => ActionType.SpendFromSlot; + + /// + /// The item stack to remove from the container in the specified slot. + /// + public StoredItemStack From; + + /// + /// Most likely to be the must spend all flag. + /// + public bool MustSpendAll; + + public void Deserialize(ref BitReader reader) + { + this.From = reader.ReadObject(); + this.MustSpendAll = reader.ReadBoolean(); + } + + public readonly void Serialize(ref BitWriter writer) + { + writer.WriteObject(this.From); + writer.WriteBoolean(this.MustSpendAll); + } + } + + public struct MoveAction : IAction + { + public readonly ActionType ActionType => ActionType.Move; + + /// + /// The item stack to move from the source container. + /// + public StoredItemStack From; + + /// + /// The item stack to move to the destination container. + /// + public StoredItemStack To; + + /// + /// Most likely to be the must collect all flag. + /// + public bool MustCollectAll; + + public void Deserialize(ref BitReader reader) + { + this.From = reader.ReadObject(); + this.To = reader.ReadObject(); + this.MustCollectAll = reader.ReadBoolean(); + } + + public readonly void Serialize(ref BitWriter writer) + { + writer.WriteObject(this.From); + writer.WriteObject(this.To); + writer.WriteBoolean(this.MustCollectAll); + } + } + + public struct MoveFromSlotAction : IAction + { + public readonly ActionType ActionType => ActionType.MoveFromSlot; + + /// + /// The slot to move the item stack from. + /// + public ushort SlotFrom; + + /// + /// The ID of the container to move the item stack from. + /// + public uint ContainerFrom; + + /// + /// The ID of the container to move the item stack to. + /// + public uint ContainerTo; + + public void Deserialize(ref BitReader reader) + { + this.SlotFrom = reader.ReadUInt16(); + this.ContainerFrom = reader.ReadUInt32(); + this.ContainerTo = reader.ReadUInt32(); + } + + public readonly void Serialize(ref BitWriter writer) + { + writer.WriteUInt16(this.SlotFrom); + writer.WriteUInt32(this.ContainerFrom); + writer.WriteUInt32(this.ContainerTo); + } + } + + public struct MoveAllAction : IAction + { + public readonly ActionType ActionType => ActionType.MoveAll; + + /// + /// The ID of the container to move all items from. + /// + public uint ContainerFrom; + + /// + /// The ID of the container to move all items to. + /// + public uint ContainerTo; + + public void Deserialize(ref BitReader reader) + { + this.ContainerFrom = reader.ReadUInt32(); + this.ContainerTo = reader.ReadUInt32(); + } + + public readonly void Serialize(ref BitWriter writer) + { + writer.WriteUInt32(this.ContainerFrom); + writer.WriteUInt32(this.ContainerTo); + } + } + + /// + /// The actions to perform on the container. + /// + public IAction[] Actions; + + public void Deserialize(ref BitReader reader) + { + var count = reader.ReadByte(); + this.Actions = new IAction[count]; + + for (var i = 0; i < count; i++) + { + var actionType = (ActionType)reader.ReadByte(); + this.Actions[i] = actionType switch + { + ActionType.SetItem => new SetItemAction { }, + ActionType.Swap => new SwapAction { }, + ActionType.Collect => new CollectAction { }, + ActionType.Spend => new SpendAction { }, + ActionType.CollectToSlot => new CollectToSlotAction { }, + ActionType.CollectToSlotOrCollect => new CollectToSlotOrCollectAction { }, + ActionType.SpendFromSlot => new SpendFromSlotAction { }, + ActionType.Move => new MoveAction { }, + ActionType.MoveFromSlot => new MoveFromSlotAction { }, + ActionType.MoveAll => new MoveAllAction { }, + _ => throw new InvalidOperationException($"Unknown action type: {actionType}"), + }; + this.Actions[i].Deserialize(ref reader); + } + } + + public readonly void Serialize(ref BitWriter writer) + { + writer.WriteByte((byte)this.Actions.Length); + + foreach (var action in this.Actions) + { + writer.WriteByte((byte)action.ActionType); + action.Serialize(ref writer); + } + } +} diff --git a/ScrapServer.Networking/Data/PacketId.cs b/ScrapServer.Networking/Data/PacketId.cs index 2c6a6a1..0be3813 100644 --- a/ScrapServer.Networking/Data/PacketId.cs +++ b/ScrapServer.Networking/Data/PacketId.cs @@ -26,5 +26,6 @@ public enum PacketId : byte GenericDataC2S = 28, CompoundPacket = 29, PlayerMovement = 30, + ContainerTransaction = 32, Broadcast = 123, } From 027471d1ee3f8892f3838dc4d6042e4861c008db Mon Sep 17 00:00:00 2001 From: TechnologicNick Date: Thu, 22 Aug 2024 23:41:27 +0200 Subject: [PATCH 10/18] Add ContainerService.Transaction.Move --- ScrapServer.Core/NetObjs/Container.cs | 2 +- ScrapServer.Core/Services/ContainerService.cs | 158 ++++++++- .../Services/ContainerServiceTests.cs | 318 +++++++++++++++--- 3 files changed, 409 insertions(+), 69 deletions(-) diff --git a/ScrapServer.Core/NetObjs/Container.cs b/ScrapServer.Core/NetObjs/Container.cs index a616cc7..682f52f 100644 --- a/ScrapServer.Core/NetObjs/Container.cs +++ b/ScrapServer.Core/NetObjs/Container.cs @@ -61,7 +61,7 @@ public Container(uint id, ushort size, ushort maximumStackSize = ushort.MaxValue public Container Clone() { - var clone = new Container(this.Id, (ushort)Items.Length); + var clone = new Container(this.Id, (ushort)Items.Length, this.MaximumStackSize); for (int i = 0; i < this.Items.Length; i++) { clone.Items[i] = this.Items[i]; diff --git a/ScrapServer.Core/Services/ContainerService.cs b/ScrapServer.Core/Services/ContainerService.cs index dadabf6..bd1c918 100644 --- a/ScrapServer.Core/Services/ContainerService.cs +++ b/ScrapServer.Core/Services/ContainerService.cs @@ -34,22 +34,61 @@ public class Transaction(ContainerService containerService) : IDisposable { private readonly Dictionary modified = []; + private Container GetOrCloneContainer(Container container) + { + return modified.TryGetValue(container.Id, out Container? found) ? found : container.Clone(); + } + + /// + /// Calculates the remaining space in a container slot for an item stack. + /// + /// The container to calculate the remaining space in + /// The slot to calculate the remaining space in + /// The item stack to calculate the remaining space for if it were to be combined with the item stack in the slot + /// + private ushort GetRemainingSpace(Container containerTo, ushort slotTo, ItemStack itemStack) + { + var currentItemStackInSlot = containerTo.Items[slotTo]; + + if (!currentItemStackInSlot.IsStackableWith(itemStack)) + { + return 0; + } + + int max = containerService.GetMaximumStackSize(containerTo, itemStack.Uuid); + int remainingSpace = max - currentItemStackInSlot.Quantity; + + if (remainingSpace <= 0) + { + return 0; + } + + return (ushort)remainingSpace; + } + /// /// Collects items into a specific slot of a container. - /// - /// This implementation is designed to return sensible results and does not match `sm.container.collectToSlot` exactly. /// + /// + /// This implementation is designed to return sensible results and does not match `sm.container.collectToSlot` exactly. + /// /// The container to collect the items into /// The item stack, including quantity, to collect + /// The slot to collect the items into /// - /// If true, only collect items if the full item stack fits in the remaining space of the slot. - /// If false, collect as many items that fit into the remaining space, and do so without overflowing into other slots. + /// If , only collect items if the full fits in the remaining space of the slot. + /// If , collect as many items that fit into the remaining space, and do so without overflowing into other slots. /// /// A tuple containing the number of items collected and the result of the operation /// If the slot index is out of range - public (ushort Collected, OperationResult Result) CollectToSlot(Container container, ItemStack itemStack, ushort slot, bool mustCollectAll = true) + public (ushort Collected, OperationResult Result) CollectToSlot( + Container container, + ItemStack itemStack, + ushort slot, + bool mustCollectAll = true + ) { - var containerCopyOnWrite = modified.TryGetValue(container.Id, out Container? found) ? found : container.Clone(); + var containerCopyOnWrite = this.GetOrCloneContainer(container); if (slot < 0 || slot >= containerCopyOnWrite.Items.Length) { @@ -63,27 +102,120 @@ public class Transaction(ContainerService containerService) : IDisposable return (0, OperationResult.NotStackable); } - int max = containerService.GetMaximumStackSize(container, itemStack.Uuid); - int remainingSpace = max - currentItemStackInSlot.Quantity; - - int quantityToCollect = Math.Min(remainingSpace, itemStack.Quantity); - if (quantityToCollect <= 0) + int remainingSpace = this.GetRemainingSpace(containerCopyOnWrite, slot, itemStack); + if (remainingSpace <= 0) { return (0, OperationResult.NotEnoughSpace); } - if (mustCollectAll && quantityToCollect < itemStack.Quantity) + if (mustCollectAll && remainingSpace < itemStack.Quantity) { return (0, OperationResult.NotEnoughSpaceForAll); } - containerCopyOnWrite.Items[slot] = ItemStack.Combine(itemStack, currentItemStackInSlot); + int quantityToCollect = Math.Min(remainingSpace, itemStack.Quantity); + + containerCopyOnWrite.Items[slot] = ItemStack.Combine(currentItemStackInSlot, itemStack with { + Quantity = (ushort)quantityToCollect + }); modified[container.Id] = containerCopyOnWrite; return ((ushort)quantityToCollect, OperationResult.Success); } + /// + /// Moves items from one slot to another slot in the same or different container. + /// + /// The container to move the items from + /// The slot to move the items from + /// The container to move the items to + /// The slot to move the items to + /// The quantity of items to move + /// + /// If , only move items if the full fits in the remaining space of the slot. + /// If , move as many items that fit into the remaining space, and do so without overflowing into other slots. + /// + /// A tuple containing the number of items moved and the result of the operation + /// If the slot index is out of range + public (ushort Moved, OperationResult Result) Move( + Container containerFrom, + ushort slotFrom, + Container containerTo, + ushort slotTo, + ushort quantity, + bool mustMoveAll = true + ) + { + var containerFromCopyOnWrite = this.GetOrCloneContainer(containerFrom); + var containerToCopyOnWrite = containerFrom.Equals(containerTo) + ? containerFromCopyOnWrite + : this.GetOrCloneContainer(containerTo); + + if (slotFrom < 0 || slotFrom >= containerFromCopyOnWrite.Items.Length) + { + throw new SlotIndexOutOfRangeException($"Slot {slotFrom} of source container is out of range [0, {containerFromCopyOnWrite.Items.Length})"); + } + + if (slotTo < 0 || slotTo >= containerToCopyOnWrite.Items.Length) + { + throw new SlotIndexOutOfRangeException($"Slot {slotTo} of destination container is out of range [0, {containerToCopyOnWrite.Items.Length})"); + } + + var itemStackFrom = containerFromCopyOnWrite.Items[slotFrom]; + var itemStackTo = containerToCopyOnWrite.Items[slotTo]; + + + if (!itemStackFrom.IsStackableWith(itemStackTo)) + { + return (0, OperationResult.NotStackable); + } + + int quantityToMove = Math.Min(quantity, itemStackFrom.Quantity); + if (quantityToMove <= 0) + { + return (0, OperationResult.Success); + } + + if (containerFrom == containerTo && slotFrom == slotTo) + { + if (mustMoveAll && itemStackFrom.Quantity < quantity) + { + return (0, OperationResult.NotEnoughSpaceForAll); + } + else + { + return ((ushort)quantityToMove, OperationResult.Success); + } + } + + var remainingSpace = this.GetRemainingSpace(containerToCopyOnWrite, slotTo, itemStackFrom); + quantityToMove = Math.Min(quantityToMove, remainingSpace); + + if (mustMoveAll && quantityToMove < quantity) + { + return (0, OperationResult.NotEnoughSpaceForAll); + } + + containerFromCopyOnWrite.Items[slotFrom] = itemStackFrom with { + Quantity = (ushort)(itemStackFrom.Quantity - quantityToMove) + }; + containerToCopyOnWrite.Items[slotTo] = ItemStack.Combine( + itemStackTo, + itemStackFrom with { Quantity = (ushort)quantityToMove } + ); + + if (containerFromCopyOnWrite.Items[slotFrom].Quantity == 0) + { + containerFromCopyOnWrite.Items[slotFrom] = ItemStack.Empty; + } + + modified[containerFrom.Id] = containerFromCopyOnWrite; + modified[containerTo.Id] = containerToCopyOnWrite; + + return ((ushort)quantityToMove, OperationResult.Success); + } + /// /// Ends the transaction and applies the changes to the containers. /// diff --git a/ScrapServer.CoreTests/Services/ContainerServiceTests.cs b/ScrapServer.CoreTests/Services/ContainerServiceTests.cs index 8b1bdd5..bcef861 100644 --- a/ScrapServer.CoreTests/Services/ContainerServiceTests.cs +++ b/ScrapServer.CoreTests/Services/ContainerServiceTests.cs @@ -1,5 +1,6 @@ using NUnit.Framework; using ScrapServer.Core; +using static ScrapServer.Core.ContainerService.Transaction; using static ScrapServer.Core.NetObjs.Container; namespace ScrapServer.CoreTests.Services; @@ -123,18 +124,15 @@ public void CollectToSlot_WoodToEmptySlot_ReturnsSuccess() // Arrange var service = this.CreateService(); var container = service.CreateContainer(size: 1); + using var transaction = service.BeginTransaction(); // Act - using var transaction = service.BeginTransaction(); + var (collected, result) = transaction.CollectToSlot(container, WoodBlock, slot: 0); + transaction.EndTransaction(); // Assert - Assert.That( - () => transaction.CollectToSlot(container, WoodBlock, slot: 0), - Is.EqualTo((1, ContainerService.Transaction.OperationResult.Success)) - ); - - // Cleanup - transaction.EndTransaction(); + Assert.That((collected, result), Is.EqualTo((1, OperationResult.Success))); + Assert.That(container.Items, Is.EqualTo(new ItemStack[] { WoodBlock })); } [Test] @@ -143,14 +141,12 @@ public void CollectToSlot_SlotIndexOutOfRange_ThrowsException() // Arrange var service = this.CreateService(); var container = service.CreateContainer(size: 1); - - // Act using var transaction = service.BeginTransaction(); // Assert Assert.That( () => transaction.CollectToSlot(container, WoodBlock, slot: 1), - Throws.TypeOf() + Throws.TypeOf() ); // Cleanup @@ -163,19 +159,16 @@ public void CollectToSlot_NotStackable_ReturnsZero() // Arrange var service = this.CreateService(); var container = service.CreateContainer(size: 1); - - // Act using var transaction = service.BeginTransaction(); transaction.CollectToSlot(container, ConcreteBlock, slot: 0); - // Assert - Assert.That( - () => transaction.CollectToSlot(container, WoodBlock, slot: 0), - Is.EqualTo((0, ContainerService.Transaction.OperationResult.NotStackable)) - ); - - // Cleanup + // Act + var (collected, result) = transaction.CollectToSlot(container, WoodBlock, slot: 0); transaction.EndTransaction(); + + // Assert + Assert.That((collected, result), Is.EqualTo((0, OperationResult.NotStackable))); + Assert.That(container.Items, Is.EqualTo(new ItemStack[] { ConcreteBlock })); } [Test] @@ -184,19 +177,16 @@ public void CollectToSlot_NotEnoughSpace_ReturnsZero() // Arrange var service = this.CreateService(); var container = service.CreateContainer(size: 1, maximumStackSize: 1); - - // Act using var transaction = service.BeginTransaction(); transaction.CollectToSlot(container, WoodBlock, slot: 0); - // Assert - Assert.That( - () => transaction.CollectToSlot(container, WoodBlock, slot: 0), - Is.EqualTo((0, ContainerService.Transaction.OperationResult.NotEnoughSpace)) - ); - - // Cleanup + // Act + var (collected, result) = transaction.CollectToSlot(container, WoodBlock, slot: 0); transaction.EndTransaction(); + + // Assert + Assert.That((collected, result), Is.EqualTo((0, OperationResult.NotEnoughSpace))); + Assert.That(container.Items, Is.EqualTo(new ItemStack[] { WoodBlock })); } [Test] @@ -205,24 +195,21 @@ public void CollectToSlot_NotEnoughSpaceForAll_ReturnsZero() // Arrange var service = this.CreateService(); var container = service.CreateContainer(size: 1, maximumStackSize: 6); - - // Act using var transaction = service.BeginTransaction(); transaction.CollectToSlot(container, WoodBlock with { Quantity = 4 }, slot: 0); - // Assert - Assert.That( - () => transaction.CollectToSlot( - container, - WoodBlock with { Quantity = 3 }, - slot: 0, - mustCollectAll: true - ), - Is.EqualTo((0, ContainerService.Transaction.OperationResult.NotEnoughSpaceForAll)) + // Act + var (collected, result) = transaction.CollectToSlot( + container, + WoodBlock with { Quantity = 3 }, + slot: 0, + mustCollectAll: true ); - - // Cleanup transaction.EndTransaction(); + + // Assert + Assert.That((collected, result), Is.EqualTo((0, OperationResult.NotEnoughSpaceForAll))); + Assert.That(container.Items, Is.EqualTo(new ItemStack[] { WoodBlock with { Quantity = 4 } })); } [Test] @@ -231,24 +218,21 @@ public void CollectToSlot_MustCollectAllFalse_ReturnsSuccess() // Arrange var service = this.CreateService(); var container = service.CreateContainer(size: 1, maximumStackSize: 6); - - // Act using var transaction = service.BeginTransaction(); transaction.CollectToSlot(container, WoodBlock with { Quantity = 4 }, slot: 0); - // Assert - Assert.That( - () => transaction.CollectToSlot( - container, - WoodBlock with { Quantity = 3 }, - slot: 0, - mustCollectAll: false - ), - Is.EqualTo((2, ContainerService.Transaction.OperationResult.Success)) + // Act + var (collected, result) = transaction.CollectToSlot( + container, + WoodBlock with { Quantity = 3 }, + slot: 0, + mustCollectAll: false ); - - // Cleanup transaction.EndTransaction(); + + // Assert + Assert.That((collected, result), Is.EqualTo((2, OperationResult.Success))); + Assert.That(container.Items, Is.EqualTo(new ItemStack[] { WoodBlock with { Quantity = 6 } })); } [Test] @@ -285,4 +269,228 @@ public void EndTransaction_ContainerRemovedBeforeEnd_ThrowsException() // Cleanup transaction.AbortTransaction(); } + + [Test] + public void Move_SlotIndexOutOfRange_ThrowsException() + { + // Arrange + var service = this.CreateService(); + var containerFrom = service.CreateContainer(size: 1); + var containerTo = service.CreateContainer(size: 1); + using var transaction = service.BeginTransaction(); + + // Assert + Assert.That( + () => transaction.Move(containerFrom, slotFrom: 1, containerTo, slotTo: 0, quantity: 1), + Throws.TypeOf() + ); + Assert.That( + () => transaction.Move(containerFrom, slotFrom: 0, containerTo, slotTo: 1, quantity: 1), + Throws.TypeOf() + ); + + // Cleanup + transaction.EndTransaction(); + } + + [Test] + public void Move_NotStackable_ReturnsZero() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 2); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(container, WoodBlock, slot: 0); + transaction.CollectToSlot(container, ConcreteBlock, slot: 1); + + // Act + var (moved, result) = transaction.Move(container, slotFrom: 0, container, slotTo: 1, quantity: 1); + transaction.EndTransaction(); + + // Assert + Assert.That((moved, result), Is.EqualTo((0, OperationResult.NotStackable))); + Assert.That(container.Items, Is.EqualTo(new ItemStack[] { WoodBlock, ConcreteBlock })); + } + + [Test] + public void Move_FromEmptySlot_ReturnsZero() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 2); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(container, WoodBlock, slot: 0); + + // Act + var (moved, result) = transaction.Move(container, slotFrom: 1, container, slotTo: 0, quantity: 1); + transaction.EndTransaction(); + + // Assert + Assert.That((moved, result), Is.EqualTo((0, OperationResult.Success))); + Assert.That(container.Items, Is.EqualTo(new ItemStack[] { WoodBlock, ItemStack.Empty })); + } + + [Test] + public void Move_NotEnoughSpace_ReturnsZero() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 2, maximumStackSize: 1); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(container, WoodBlock, slot: 0); + transaction.CollectToSlot(container, WoodBlock, slot: 1); + + // Act + var (moved, result) = transaction.Move(container, slotFrom: 0, container, slotTo: 1, quantity: 1); + transaction.EndTransaction(); + + // Assert + Assert.That((moved, result), Is.EqualTo((0, OperationResult.NotEnoughSpaceForAll))); + Assert.That(container.Items, Is.EqualTo(new ItemStack[] { WoodBlock, WoodBlock })); + } + + [Test] + public void Move_SameSlotMustMoveAllTrue_ReturnsNotEnoughSpaceForAll() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 1, maximumStackSize: 6); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(container, WoodBlock with { Quantity = 4 }, slot: 0); + + // Act + var (moved, result) = transaction.Move(container, slotFrom: 0, container, slotTo: 0, quantity: 5, mustMoveAll: true); + transaction.EndTransaction(); + + // Assert + Assert.That((moved, result), Is.EqualTo((0, OperationResult.NotEnoughSpaceForAll))); + Assert.That(container.Items, Is.EqualTo(new ItemStack[] { WoodBlock with { Quantity = 4 } })); + } + + [Test] + public void Move_SameSlotMustMoveAllFalse_ReturnsSuccess() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 1, maximumStackSize: 6); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(container, WoodBlock with { Quantity = 4 }, slot: 0); + + // Act + var (moved, result) = transaction.Move(container, slotFrom: 0, container, slotTo: 0, quantity: 5, mustMoveAll: false); + transaction.EndTransaction(); + + // Assert + Assert.That((moved, result), Is.EqualTo((4, OperationResult.Success))); + Assert.That(container.Items, Is.EqualTo(new ItemStack[] { WoodBlock with { Quantity = 4 } })); + } + + [Test] + public void Move_MergeItemStacksSameContainer_ReturnsSuccess() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 2, maximumStackSize: 6); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(container, WoodBlock with { Quantity = 4 }, slot: 0); + transaction.CollectToSlot(container, WoodBlock with { Quantity = 2 }, slot: 1); + + // Act + var (moved, result) = transaction.Move(container, slotFrom: 1, container, slotTo: 0, quantity: 2); + transaction.EndTransaction(); + + // Assert + Assert.That((moved, result), Is.EqualTo((2, OperationResult.Success))); + Assert.That(container.Items, Is.EqualTo(new ItemStack[] { WoodBlock with { Quantity = 6 }, ItemStack.Empty })); + } + + [Test] + public void Move_MergeItemStacksDifferentContainers_ReturnsSuccess() + { + // Arrange + var service = this.CreateService(); + var containerFrom = service.CreateContainer(size: 1, maximumStackSize: 6); + var containerTo = service.CreateContainer(size: 1, maximumStackSize: 6); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(containerFrom, WoodBlock with { Quantity = 4 }, slot: 0); + transaction.CollectToSlot(containerTo, WoodBlock with { Quantity = 2 }, slot: 0); + + // Act + var (moved, result) = transaction.Move(containerFrom, slotFrom: 0, containerTo, slotTo: 0, quantity: 2); + transaction.EndTransaction(); + + // Assert + Assert.That((moved, result), Is.EqualTo((2, OperationResult.Success))); + Assert.That(containerFrom.Items, Is.EqualTo(new ItemStack[] { WoodBlock with { Quantity = 2 } })); + Assert.That(containerTo.Items, Is.EqualTo(new ItemStack[] { WoodBlock with { Quantity = 4 } })); + } + + [Test] + public void Move_EntireStack_SetsFromToEmpty() + { + // Arrange + var service = this.CreateService(); + var containerFrom = service.CreateContainer(size: 1); + var containerTo = service.CreateContainer(size: 1); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(containerFrom, WoodBlock with { Quantity = 4 }, slot: 0); + + // Act + var (moved, result) = transaction.Move(containerFrom, slotFrom: 0, containerTo, slotTo: 0, quantity: 4); + transaction.EndTransaction(); + + // Assert + Assert.That((moved, result), Is.EqualTo((4, OperationResult.Success))); + Assert.That(containerFrom.Items, Is.EqualTo(new ItemStack[] { ItemStack.Empty })); + Assert.That(containerTo.Items, Is.EqualTo(new ItemStack[] { WoodBlock with { Quantity = 4 } })); + } + + [Test] + public void Move_VeryLargeStackMustCollectAllTrue_DoesNotOverflow() + { + // Arrange + var service = this.CreateService(); + var containerFrom = service.CreateContainer(size: 1); + var containerTo = service.CreateContainer(size: 1); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(containerFrom, WoodBlock with { Quantity = ushort.MaxValue }, slot: 0); + transaction.CollectToSlot(containerTo, WoodBlock with { Quantity = 1 }, slot: 0); + + // Act + var (moved, result) = transaction.Move(containerFrom, slotFrom: 0, containerTo, slotTo: 0, quantity: ushort.MaxValue); + transaction.EndTransaction(); + + // Assert + Assert.That((moved, result), Is.EqualTo((0, OperationResult.NotEnoughSpaceForAll))); + Assert.That(containerFrom.Items, Is.EqualTo(new ItemStack[] { WoodBlock with { Quantity = ushort.MaxValue } })); + Assert.That(containerTo.Items, Is.EqualTo(new ItemStack[] { WoodBlock with { Quantity = 1 } })); + } + + [Test] + public void Move_VeryLargeStackMustCollectAllFalse_DoesNotOverflow() + { + // Arrange + var service = this.CreateService(); + var containerFrom = service.CreateContainer(size: 1); + var containerTo = service.CreateContainer(size: 1); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(containerFrom, WoodBlock with { Quantity = ushort.MaxValue }, slot: 0); + transaction.CollectToSlot(containerTo, WoodBlock with { Quantity = 1 }, slot: 0); + + // Act + var (moved, result) = transaction.Move( + containerFrom, + slotFrom: 0, + containerTo, + slotTo: 0, + quantity: ushort.MaxValue, + mustMoveAll: false + ); + transaction.EndTransaction(); + + // Assert + Assert.That((moved, result), Is.EqualTo((ushort.MaxValue - 1, OperationResult.Success))); + Assert.That(containerFrom.Items, Is.EqualTo(new ItemStack[] { WoodBlock with { Quantity = 1 } })); + Assert.That(containerTo.Items, Is.EqualTo(new ItemStack[] { WoodBlock with { Quantity = ushort.MaxValue } })); + } } From bbc98423028a1c01f7e32e2bda71073b906812c3 Mon Sep 17 00:00:00 2001 From: TechnologicNick Date: Wed, 28 Aug 2024 22:41:01 +0200 Subject: [PATCH 11/18] Add sending NetworkUpdate packet on ContainerTransaction --- ScrapServer.Core/NetObjs/Container.cs | 58 +++++++++++- ScrapServer.Core/Services/ContainerService.cs | 12 ++- ScrapServer.Core/Services/PlayerService.cs | 60 ++++++++++++- .../Data/UpdateContainer.cs | 90 +++++++++++++++++++ ScrapServer.Networking/NetworkUpdate.cs | 35 +++----- 5 files changed, 226 insertions(+), 29 deletions(-) create mode 100644 ScrapServer.Networking/Data/UpdateContainer.cs diff --git a/ScrapServer.Core/NetObjs/Container.cs b/ScrapServer.Core/NetObjs/Container.cs index 682f52f..f127cd2 100644 --- a/ScrapServer.Core/NetObjs/Container.cs +++ b/ScrapServer.Core/NetObjs/Container.cs @@ -87,8 +87,64 @@ public void SerializeCreate(ref BitWriter writer) }.Serialize(ref writer); } + /// + /// Serialize the update of the container with no changes. + /// + /// public void SerializeUpdate(ref BitWriter writer) { - throw new NotImplementedException(); + new UpdateContainer + { + SlotChanges = [], + Filters = [], + }.Serialize(ref writer); + } + + /// + /// Create an update of the container based on the old state. + /// + /// The cloned old state of the container + /// The update of the container + /// + public UpdateContainer CreateNetworkUpdate(Container oldState) + { + ArgumentNullException.ThrowIfNull(oldState); + + if (this.Id != oldState.Id) + { + throw new InvalidOperationException("Cannot serialize update with different container ids"); + } + + if (this.Items.Length != oldState.Items.Length) + { + throw new InvalidOperationException("Cannot serialize update with different item counts"); + } + + var slotChanges = new List(); + + for (int i = 0; i < this.Items.Length; i++) + { + var item = this.Items[i]; + var oldItem = oldState.Items[i]; + + if (item != oldItem) + { + slotChanges.Add(new UpdateContainer.SlotChange + { + Uuid = item.Uuid, + InstanceId = item.InstanceId, + Quantity = item.Quantity, + Slot = (ushort)i, + }); + } + } + + Guid[] filterChanges = this.Filter.Equals(oldState.Filter) ? [] : [.. this.Filter]; + + return new UpdateContainer + { + SlotChanges = [.. slotChanges], + Filters = filterChanges, + }; } } diff --git a/ScrapServer.Core/Services/ContainerService.cs b/ScrapServer.Core/Services/ContainerService.cs index bd1c918..d2126bc 100644 --- a/ScrapServer.Core/Services/ContainerService.cs +++ b/ScrapServer.Core/Services/ContainerService.cs @@ -1,5 +1,6 @@ using ScrapServer.Core.NetObjs; using ScrapServer.Core.Utils; +using ScrapServer.Networking.Data; using static ScrapServer.Core.NetObjs.Container; namespace ScrapServer.Core; @@ -219,28 +220,37 @@ private ushort GetRemainingSpace(Container containerTo, ushort slotTo, ItemStack /// /// Ends the transaction and applies the changes to the containers. /// + /// A list of tuples containing the updated containers and their network updates /// If the transaction is not the current transaction - public void EndTransaction() + public IEnumerable<(Container, UpdateContainer)> EndTransaction() { if (containerService.CurrentTransaction != this) { throw new InvalidOperationException("Attempted to end a transaction that is not the current transaction"); } + List<(Container, UpdateContainer)> updates = []; + foreach (var (id, container) in modified) { if (!containerService.Containers.TryGetValue(id, out Container? target)) { throw new InvalidOperationException($"Container with ID {id} was not found"); } + + var update = container.CreateNetworkUpdate(target); Array.Copy(container.Items, target.Items, container.Items.Length); target.Filter.Clear(); target.Filter.UnionWith(container.Filter); + + updates.Add((target, update)); } containerService.CurrentTransaction = null; + + return updates; } /// diff --git a/ScrapServer.Core/Services/PlayerService.cs b/ScrapServer.Core/Services/PlayerService.cs index 470d9e9..fbc8aab 100644 --- a/ScrapServer.Core/Services/PlayerService.cs +++ b/ScrapServer.Core/Services/PlayerService.cs @@ -7,8 +7,6 @@ using System.Text; using ScrapServer.Core.NetObjs; using static ScrapServer.Core.NetObjs.Container; -using System.Xml.Linq; - namespace ScrapServer.Core; @@ -81,7 +79,7 @@ public void Receive(ReadOnlySpan data) Send(new ScrapServer.Networking.ServerInfo { - Version = 729, + Version = 723, Gamemode = Gamemode.FlatTerrain, Seed = 1023853875, GameTick = SchedulerService.GameTick, @@ -169,6 +167,62 @@ public void Receive(ReadOnlySpan data) Character?.HandleMovement(packet); } + else if (id == PacketId.ContainerTransaction) + { + var packet = reader.ReadPacket(); + var containerService = ContainerService.Instance; + + using var transaction = containerService.BeginTransaction(); + + foreach (var action in packet.Actions) + { + switch (action) + { + case ContainerTransaction.SetItemAction setItemAction: + case ContainerTransaction.SwapAction swapAction: + case ContainerTransaction.CollectAction collectAction: + case ContainerTransaction.SpendAction spendAction: + case ContainerTransaction.CollectToSlotAction collectToSlotAction: + case ContainerTransaction.CollectToSlotOrCollectAction collectToSlotOrCollectAction: + case ContainerTransaction.SpendFromSlotAction spendFromSlotAction: + throw new NotImplementedException($"Container transaction action {action} not implemented"); + + case ContainerTransaction.MoveAction moveAction: + if ( + !containerService.Containers.TryGetValue(moveAction.From.ContainerId, out var containerFrom) || + !containerService.Containers.TryGetValue(moveAction.To.ContainerId, out var containerTo) || + moveAction.From.Slot >= containerFrom.Items.Length || + moveAction.To.Slot >= containerTo.Items.Length) + { + break; + } + transaction.Move(containerFrom, moveAction.From.Slot, containerTo, moveAction.To.Slot, moveAction.From.Quantity, moveAction.MustCollectAll); + break; + + case ContainerTransaction.MoveFromSlotAction moveFromSlotAction: + case ContainerTransaction.MoveAllAction moveAllAction: + throw new NotImplementedException($"Container transaction action {action} not implemented"); + + default: + return; + } + } + + var networkUpdate = new NetworkUpdate.Builder() + .WithGameTick(SchedulerService.GameTick); + + foreach (var (container, update) in transaction.EndTransaction()) + { + Console.WriteLine("Sending container update for container {0}", container.Id); + networkUpdate.Write(container, NetworkUpdateType.Update, (ref BitWriter writer) => update.Serialize(ref writer)); + } + + var updatePacket = networkUpdate.Build(); + foreach (var player in PlayerService.GetPlayers()) + { + player.Send(updatePacket); + } + } else if (id == PacketId.Broadcast) { var packet = reader.ReadPacket(); diff --git a/ScrapServer.Networking/Data/UpdateContainer.cs b/ScrapServer.Networking/Data/UpdateContainer.cs new file mode 100644 index 0000000..92797be --- /dev/null +++ b/ScrapServer.Networking/Data/UpdateContainer.cs @@ -0,0 +1,90 @@ +using ScrapServer.Utility.Serialization; + +namespace ScrapServer.Networking.Data; + +public class UpdateContainer : IBitSerializable +{ + public struct SlotChange : IBitSerializable + { + public Guid Uuid; + public UInt32 InstanceId; + public UInt16 Quantity; + public UInt16 Slot; + + public void Deserialize(ref BitReader reader) + { + Uuid = reader.ReadGuid(ByteOrder.LittleEndian); + InstanceId = reader.ReadUInt32(); + Quantity = reader.ReadUInt16(); + Slot = reader.ReadUInt16(); + } + + public readonly void Serialize(ref BitWriter writer) + { + writer.WriteGuid(Uuid, ByteOrder.LittleEndian); + writer.WriteUInt32(InstanceId); + writer.WriteUInt16(Quantity); + writer.WriteUInt16(Slot); + } + } + + public SlotChange[]? SlotChanges; + public Guid[]? Filters; + + public void Deserialize(ref BitReader reader) + { + var slotChangeCount = reader.ReadInt16(); + this.SlotChanges = new SlotChange[slotChangeCount]; + + for (int i = 0; i < slotChangeCount; i++) + { + this.SlotChanges[i] = new SlotChange(); + this.SlotChanges[i].Deserialize(ref reader); + } + + if (reader.ReadBit()) + { + var filterCount = reader.ReadInt16(); + this.Filters = new Guid[filterCount]; + + reader.GoToNearestByte(); + + for (int i = 0; i < filterCount; i++) + { + this.Filters[i] = reader.ReadGuid(); + } + } + else + { + this.Filters = []; + } + } + + public void Serialize(ref BitWriter writer) + { + writer.WriteInt16((Int16)(SlotChanges?.Length ?? 0)); + + if (SlotChanges != null) + { + foreach (var slotChange in SlotChanges) + { + slotChange.Serialize(ref writer); + } + } + + bool hasFilters = Filters != null && Filters.Length > 0; + writer.WriteBit(hasFilters); + + if (hasFilters) + { + writer.WriteInt16((Int16)(Filters!.Length)); + + writer.GoToNearestByte(); + + foreach (var filter in Filters) + { + writer.WriteGuid(filter); + } + } + } +} diff --git a/ScrapServer.Networking/NetworkUpdate.cs b/ScrapServer.Networking/NetworkUpdate.cs index fd86f6a..4a8bf96 100644 --- a/ScrapServer.Networking/NetworkUpdate.cs +++ b/ScrapServer.Networking/NetworkUpdate.cs @@ -1,5 +1,6 @@ using ScrapServer.Networking.Data; using ScrapServer.Utility.Serialization; +using static ScrapServer.Networking.NetworkUpdate.Builder; namespace ScrapServer.Networking; @@ -43,12 +44,14 @@ public Builder WithGameTick(UInt32 gameTick) return this; } - private Builder Write(TNetObj netObj, NetworkUpdateType updateType) where TNetObj : INetObj + public delegate void WriteDelegate(ref BitWriter writer); + + public Builder Write(TNetObj netObj, NetworkUpdateType updateType, WriteDelegate writeDelegate) where TNetObj : INetObj { writer.GoToNearestByte(); var sizePos = writer.ByteIndex; - var header = new Networking.Data.NetObj + var header = new NetObj { UpdateType = updateType, ObjectType = netObj.NetObjType, @@ -63,47 +66,31 @@ private Builder Write(TNetObj netObj, NetworkUpdateType updateType) whe writer.WriteUInt32(netObj.Id); - switch(updateType) - { - case NetworkUpdateType.Create: - netObj.SerializeCreate(ref writer); - break; - case NetworkUpdateType.P: - netObj.SerializeP(ref writer); - break; - case NetworkUpdateType.Update: - netObj.SerializeUpdate(ref writer); - break; - case NetworkUpdateType.Remove: - netObj.SerializeRemove(ref writer); - break; - default: - throw new InvalidDataException($"Invalid update type: {updateType}"); - } + writeDelegate.Invoke(ref writer); - Networking.Data.NetObj.WriteSize(ref writer, sizePos); + NetObj.WriteSize(ref writer, sizePos); return this; } public Builder WriteCreate(TNetObj obj) where TNetObj : INetObj { - return Write(obj, NetworkUpdateType.Create); + return Write(obj, NetworkUpdateType.Create, obj.SerializeCreate); } public Builder WriteUpdate(TNetObj obj) where TNetObj : INetObj { - return Write(obj, NetworkUpdateType.Update); + return Write(obj, NetworkUpdateType.Update, obj.SerializeUpdate); } public Builder WriteP(TNetObj obj) where TNetObj : INetObj { - return Write(obj, NetworkUpdateType.P); + return Write(obj, NetworkUpdateType.P, obj.SerializeP); } public Builder WriteRemove(TNetObj obj) where TNetObj : INetObj { - return Write(obj, NetworkUpdateType.Remove); + return Write(obj, NetworkUpdateType.Remove, obj.SerializeRemove); } public NetworkUpdate Build() From c6b761e71a48603bdd4a00fbf7441a85326324b5 Mon Sep 17 00:00:00 2001 From: TechnologicNick Date: Thu, 29 Aug 2024 00:16:54 +0200 Subject: [PATCH 12/18] Add ContainerService.Transaction.Swap --- ScrapServer.Core/Services/ContainerService.cs | 51 ++++++++ ScrapServer.Core/Services/PlayerService.cs | 33 +++-- .../Services/ContainerServiceTests.cs | 123 ++++++++++++++++++ 3 files changed, 199 insertions(+), 8 deletions(-) diff --git a/ScrapServer.Core/Services/ContainerService.cs b/ScrapServer.Core/Services/ContainerService.cs index d2126bc..4d02bb7 100644 --- a/ScrapServer.Core/Services/ContainerService.cs +++ b/ScrapServer.Core/Services/ContainerService.cs @@ -67,6 +67,57 @@ private ushort GetRemainingSpace(Container containerTo, ushort slotTo, ItemStack return (ushort)remainingSpace; } + /// + /// Swaps items between two slots in the same or different containers. + /// + /// The container to swap the items from + /// The slot to swap the items from + /// The container to swap the items to + /// The slot to swap the items to + /// If the swap was successful + /// + public bool Swap( + Container containerFrom, + ushort slotFrom, + Container containerTo, + ushort slotTo + ) + { + var containerFromCopyOnWrite = this.GetOrCloneContainer(containerFrom); + var containerToCopyOnWrite = containerFrom.Equals(containerTo) + ? containerFromCopyOnWrite + : this.GetOrCloneContainer(containerTo); + + if (slotFrom < 0 || slotFrom >= containerFromCopyOnWrite.Items.Length) + { + throw new SlotIndexOutOfRangeException($"Slot {slotFrom} of source container is out of range [0, {containerFromCopyOnWrite.Items.Length})"); + } + + if (slotTo < 0 || slotTo >= containerToCopyOnWrite.Items.Length) + { + throw new SlotIndexOutOfRangeException($"Slot {slotTo} of destination container is out of range [0, {containerToCopyOnWrite.Items.Length})"); + } + + var itemStackFrom = containerFromCopyOnWrite.Items[slotFrom]; + var itemStackTo = containerToCopyOnWrite.Items[slotTo]; + + if ( + itemStackFrom.Quantity > containerService.GetMaximumStackSize(containerTo, itemStackFrom.Uuid) || + itemStackTo.Quantity > containerService.GetMaximumStackSize(containerFrom, itemStackTo.Uuid) + ) + { + return false; + } + + containerFromCopyOnWrite.Items[slotFrom] = itemStackTo; + containerToCopyOnWrite.Items[slotTo] = itemStackFrom; + + modified[containerFrom.Id] = containerFromCopyOnWrite; + modified[containerTo.Id] = containerToCopyOnWrite; + + return true; + } + /// /// Collects items into a specific slot of a container. /// diff --git a/ScrapServer.Core/Services/PlayerService.cs b/ScrapServer.Core/Services/PlayerService.cs index fbc8aab..8dd5523 100644 --- a/ScrapServer.Core/Services/PlayerService.cs +++ b/ScrapServer.Core/Services/PlayerService.cs @@ -179,7 +179,22 @@ public void Receive(ReadOnlySpan data) switch (action) { case ContainerTransaction.SetItemAction setItemAction: + throw new NotImplementedException($"Container transaction action {action} not implemented"); + case ContainerTransaction.SwapAction swapAction: + { + if ( + !containerService.Containers.TryGetValue(swapAction.From.ContainerId, out var containerFrom) || + !containerService.Containers.TryGetValue(swapAction.To.ContainerId, out var containerTo) || + swapAction.From.Slot >= containerFrom.Items.Length || + swapAction.To.Slot >= containerTo.Items.Length) + { + break; + } + transaction.Swap(containerFrom, swapAction.From.Slot, containerTo, swapAction.To.Slot); + } + break; + case ContainerTransaction.CollectAction collectAction: case ContainerTransaction.SpendAction spendAction: case ContainerTransaction.CollectToSlotAction collectToSlotAction: @@ -188,15 +203,17 @@ public void Receive(ReadOnlySpan data) throw new NotImplementedException($"Container transaction action {action} not implemented"); case ContainerTransaction.MoveAction moveAction: - if ( - !containerService.Containers.TryGetValue(moveAction.From.ContainerId, out var containerFrom) || - !containerService.Containers.TryGetValue(moveAction.To.ContainerId, out var containerTo) || - moveAction.From.Slot >= containerFrom.Items.Length || - moveAction.To.Slot >= containerTo.Items.Length) { - break; + if ( + !containerService.Containers.TryGetValue(moveAction.From.ContainerId, out var containerFrom) || + !containerService.Containers.TryGetValue(moveAction.To.ContainerId, out var containerTo) || + moveAction.From.Slot >= containerFrom.Items.Length || + moveAction.To.Slot >= containerTo.Items.Length) + { + break; + } + transaction.Move(containerFrom, moveAction.From.Slot, containerTo, moveAction.To.Slot, moveAction.From.Quantity, moveAction.MustCollectAll); } - transaction.Move(containerFrom, moveAction.From.Slot, containerTo, moveAction.To.Slot, moveAction.From.Quantity, moveAction.MustCollectAll); break; case ContainerTransaction.MoveFromSlotAction moveFromSlotAction: @@ -367,7 +384,7 @@ private static Player CreatePlayer(Connection conn, NetIdentity identity) { transaction.CollectToSlot( inventoryContainer, - new ItemStack(Guid.Parse("df953d9c-234f-4ac2-af5e-f0490b223e71"), ItemStack.NoInstanceId, (ushort)(i + 1)), + new ItemStack(Guid.Parse(i % 2 == 0 ? "df953d9c-234f-4ac2-af5e-f0490b223e71" : "a6c6ce30-dd47-4587-b475-085d55c6a3b4"), ItemStack.NoInstanceId, (ushort)(i + 1)), (ushort)i ); } diff --git a/ScrapServer.CoreTests/Services/ContainerServiceTests.cs b/ScrapServer.CoreTests/Services/ContainerServiceTests.cs index bcef861..f46f043 100644 --- a/ScrapServer.CoreTests/Services/ContainerServiceTests.cs +++ b/ScrapServer.CoreTests/Services/ContainerServiceTests.cs @@ -493,4 +493,127 @@ public void Move_VeryLargeStackMustCollectAllFalse_DoesNotOverflow() Assert.That(containerFrom.Items, Is.EqualTo(new ItemStack[] { WoodBlock with { Quantity = 1 } })); Assert.That(containerTo.Items, Is.EqualTo(new ItemStack[] { WoodBlock with { Quantity = ushort.MaxValue } })); } + + [Test] + public void Swap_SlotIndexOutOfRange_ThrowsException() + { + // Arrange + var service = this.CreateService(); + var containerFrom = service.CreateContainer(size: 1); + var containerTo = service.CreateContainer(size: 1); + using var transaction = service.BeginTransaction(); + + // Assert + Assert.That( + () => transaction.Swap(containerFrom, slotFrom: 1, containerTo, slotTo: 0), + Throws.TypeOf() + ); + Assert.That( + () => transaction.Swap(containerFrom, slotFrom: 0, containerTo, slotTo: 1), + Throws.TypeOf() + ); + + // Cleanup + transaction.EndTransaction(); + } + + [Test] + public void Swap_ToMaximumStackSizeTooSmall_ReturnsFalse() + { + // Arrange + var service = this.CreateService(); + var containerFrom = service.CreateContainer(size: 1, maximumStackSize: 2); + var containerTo = service.CreateContainer(size: 1, maximumStackSize: 1); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(containerFrom, WoodBlock with { Quantity = 2 }, slot: 0); + transaction.CollectToSlot(containerTo, ConcreteBlock, slot: 0); + + // Act + var result = transaction.Swap(containerFrom, slotFrom: 0, containerTo, slotTo: 0); + transaction.EndTransaction(); + + // Assert + Assert.That(result, Is.False); + Assert.That(containerFrom.Items, Is.EqualTo(new ItemStack[] { WoodBlock with { Quantity = 2 } })); + Assert.That(containerTo.Items, Is.EqualTo(new ItemStack[] { ConcreteBlock })); + } + + [Test] + public void Swap_FromMaximumStackSizeTooSmall_ReturnsFalse() + { + // Arrange + var service = this.CreateService(); + var containerFrom = service.CreateContainer(size: 1, maximumStackSize: 1); + var containerTo = service.CreateContainer(size: 1, maximumStackSize: 2); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(containerFrom, ConcreteBlock, slot: 0); + transaction.CollectToSlot(containerTo, WoodBlock with { Quantity = 2 }, slot: 0); + + // Act + var result = transaction.Swap(containerFrom, slotFrom: 0, containerTo, slotTo: 0); + transaction.EndTransaction(); + + // Assert + Assert.That(result, Is.False); + Assert.That(containerFrom.Items, Is.EqualTo(new ItemStack[] { ConcreteBlock })); + Assert.That(containerTo.Items, Is.EqualTo(new ItemStack[] { WoodBlock with { Quantity = 2 } })); + } + + [Test] + public void Swap_SameSlot_ReturnsTrue() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 1); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(container, WoodBlock, slot: 0); + + // Act + var result = transaction.Swap(container, slotFrom: 0, container, slotTo: 0); + transaction.EndTransaction(); + + // Assert + Assert.That(result, Is.True); + Assert.That(container.Items, Is.EqualTo(new ItemStack[] { WoodBlock })); + } + + [Test] + public void Swap_SameContainer_ReturnsTrue() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 2); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(container, WoodBlock, slot: 0); + transaction.CollectToSlot(container, ConcreteBlock, slot: 1); + + // Act + var result = transaction.Swap(container, slotFrom: 0, container, slotTo: 1); + transaction.EndTransaction(); + + // Assert + Assert.That(result, Is.True); + Assert.That(container.Items, Is.EqualTo(new ItemStack[] { ConcreteBlock, WoodBlock })); + } + + [Test] + public void Swap_DifferentContainers_ReturnsTrue() + { + // Arrange + var service = this.CreateService(); + var containerFrom = service.CreateContainer(size: 1); + var containerTo = service.CreateContainer(size: 1); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(containerFrom, WoodBlock, slot: 0); + transaction.CollectToSlot(containerTo, ConcreteBlock, slot: 0); + + // Act + var result = transaction.Swap(containerFrom, slotFrom: 0, containerTo, slotTo: 0); + transaction.EndTransaction(); + + // Assert + Assert.That(result, Is.True); + Assert.That(containerFrom.Items, Is.EqualTo(new ItemStack[] { ConcreteBlock })); + Assert.That(containerTo.Items, Is.EqualTo(new ItemStack[] { WoodBlock })); + } } From 7abd0334f28b7ab19a077f3abebb3381081e0f7c Mon Sep 17 00:00:00 2001 From: TechnologicNick Date: Thu, 29 Aug 2024 21:42:29 +0200 Subject: [PATCH 13/18] Fix `BitReader.ReadGuid(ByteOrder.LittleEndian)` not parsing RFC4122 UUIDs correctly --- ScrapServer.Utility/Serialization/BitReader.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ScrapServer.Utility/Serialization/BitReader.cs b/ScrapServer.Utility/Serialization/BitReader.cs index 2959454..0bdad90 100644 --- a/ScrapServer.Utility/Serialization/BitReader.cs +++ b/ScrapServer.Utility/Serialization/BitReader.cs @@ -267,7 +267,13 @@ public Guid ReadGuid(ByteOrder byteOrder = ByteOrder.BigEndian) { Span bytes = stackalloc byte[16]; ReadBytes(bytes); - return new Guid(bytes, byteOrder == ByteOrder.BigEndian); + + if (byteOrder == ByteOrder.LittleEndian) + { + bytes.Reverse(); + } + + return new Guid(bytes, bigEndian: true); } /// From 3cc36b74db44ed966bc1f2ec3d67d41754d0bcd8 Mon Sep 17 00:00:00 2001 From: TechnologicNick Date: Thu, 29 Aug 2024 21:43:35 +0200 Subject: [PATCH 14/18] Add ContainerService.Transaction.SetItem --- ScrapServer.Core/Services/ContainerService.cs | 24 +++++++++++++ ScrapServer.Core/Services/PlayerService.cs | 15 +++++++- .../Services/ContainerServiceTests.cs | 34 +++++++++++++++++++ .../ContainerTransaction.cs | 6 ++-- 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/ScrapServer.Core/Services/ContainerService.cs b/ScrapServer.Core/Services/ContainerService.cs index 4d02bb7..1c5ff8c 100644 --- a/ScrapServer.Core/Services/ContainerService.cs +++ b/ScrapServer.Core/Services/ContainerService.cs @@ -67,6 +67,30 @@ private ushort GetRemainingSpace(Container containerTo, ushort slotTo, ItemStack return (ushort)remainingSpace; } + /// + /// Sets an item stack in a container slot. + /// + /// + /// Only use this method if you know what you are doing, as it does not perform any checks. + /// + /// The container + /// The slot + /// The item stack + /// If the slot index is out of range + public void SetItem(Container container, ushort slot, ItemStack itemStack) + { + var containerCopyOnWrite = this.GetOrCloneContainer(container); + + if (slot < 0 || slot >= containerCopyOnWrite.Items.Length) + { + throw new SlotIndexOutOfRangeException($"Slot {slot} is out of range [0, {containerCopyOnWrite.Items.Length})"); + } + + containerCopyOnWrite.Items[slot] = itemStack; + + modified[container.Id] = containerCopyOnWrite; + } + /// /// Swaps items between two slots in the same or different containers. /// diff --git a/ScrapServer.Core/Services/PlayerService.cs b/ScrapServer.Core/Services/PlayerService.cs index 8dd5523..449d748 100644 --- a/ScrapServer.Core/Services/PlayerService.cs +++ b/ScrapServer.Core/Services/PlayerService.cs @@ -179,7 +179,20 @@ public void Receive(ReadOnlySpan data) switch (action) { case ContainerTransaction.SetItemAction setItemAction: - throw new NotImplementedException($"Container transaction action {action} not implemented"); + { + if ( + !containerService.Containers.TryGetValue(setItemAction.To.ContainerId, out var containerTo) || + setItemAction.To.Slot >= containerTo.Items.Length) + { + break; + } + transaction.SetItem(containerTo, setItemAction.To.Slot, new ItemStack( + setItemAction.To.Uuid, + setItemAction.To.InstanceId, + setItemAction.To.Quantity + )); + } + break; case ContainerTransaction.SwapAction swapAction: { diff --git a/ScrapServer.CoreTests/Services/ContainerServiceTests.cs b/ScrapServer.CoreTests/Services/ContainerServiceTests.cs index f46f043..60edb3f 100644 --- a/ScrapServer.CoreTests/Services/ContainerServiceTests.cs +++ b/ScrapServer.CoreTests/Services/ContainerServiceTests.cs @@ -616,4 +616,38 @@ public void Swap_DifferentContainers_ReturnsTrue() Assert.That(containerFrom.Items, Is.EqualTo(new ItemStack[] { ConcreteBlock })); Assert.That(containerTo.Items, Is.EqualTo(new ItemStack[] { WoodBlock })); } + + [Test] + public void SetItem_SlotIndexOutOfRange_ThrowsException() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 1); + using var transaction = service.BeginTransaction(); + + // Assert + Assert.That( + () => transaction.SetItem(container, slot: 1, WoodBlock), + Throws.TypeOf() + ); + + // Cleanup + transaction.EndTransaction(); + } + + [Test] + public void SetItem_NormalUsage_SetsItem() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 1); + using var transaction = service.BeginTransaction(); + + // Act + transaction.SetItem(container, slot: 0, WoodBlock); + transaction.EndTransaction(); + + // Assert + Assert.That(container.Items, Is.EqualTo(new ItemStack[] { WoodBlock })); + } } diff --git a/ScrapServer.Networking/ContainerTransaction.cs b/ScrapServer.Networking/ContainerTransaction.cs index b023e5b..9507303 100644 --- a/ScrapServer.Networking/ContainerTransaction.cs +++ b/ScrapServer.Networking/ContainerTransaction.cs @@ -24,7 +24,7 @@ public record struct StoredItemStack : IBitSerializable public void Deserialize(ref BitReader reader) { - this.Uuid = reader.ReadGuid(); + this.Uuid = reader.ReadGuid(ByteOrder.LittleEndian); this.InstanceId = reader.ReadUInt32(); this.Quantity = reader.ReadUInt16(); this.Slot = reader.ReadUInt16(); @@ -33,7 +33,7 @@ public void Deserialize(ref BitReader reader) public readonly void Serialize(ref BitWriter writer) { - writer.WriteGuid(this.Uuid); + writer.WriteGuid(this.Uuid, ByteOrder.LittleEndian); writer.WriteUInt32(this.InstanceId); writer.WriteUInt16(this.Quantity); writer.WriteUInt16(this.Slot); @@ -410,7 +410,7 @@ public void Deserialize(ref BitReader reader) ActionType.Move => new MoveAction { }, ActionType.MoveFromSlot => new MoveFromSlotAction { }, ActionType.MoveAll => new MoveAllAction { }, - _ => throw new InvalidOperationException($"Unknown action type: {actionType}"), + _ => new MoveAction { }, // Default to move action }; this.Actions[i].Deserialize(ref reader); } From 18b14b62ec3471b3ea2d94dd80d787c34980c0d1 Mon Sep 17 00:00:00 2001 From: TechnologicNick Date: Fri, 30 Aug 2024 00:49:44 +0200 Subject: [PATCH 15/18] Add ContainerService.Transaction.MoveFromSlot --- ScrapServer.Core/Services/ContainerService.cs | 95 ++++++++++++++- ScrapServer.Core/Services/PlayerService.cs | 12 ++ .../Services/ContainerServiceTests.cs | 114 ++++++++++++++++++ 3 files changed, 216 insertions(+), 5 deletions(-) diff --git a/ScrapServer.Core/Services/ContainerService.cs b/ScrapServer.Core/Services/ContainerService.cs index 1c5ff8c..afa94d5 100644 --- a/ScrapServer.Core/Services/ContainerService.cs +++ b/ScrapServer.Core/Services/ContainerService.cs @@ -1,4 +1,4 @@ -using ScrapServer.Core.NetObjs; +using ScrapServer.Core.NetObjs; using ScrapServer.Core.Utils; using ScrapServer.Networking.Data; using static ScrapServer.Core.NetObjs.Container; @@ -58,7 +58,7 @@ private ushort GetRemainingSpace(Container containerTo, ushort slotTo, ItemStack int max = containerService.GetMaximumStackSize(containerTo, itemStack.Uuid); int remainingSpace = max - currentItemStackInSlot.Quantity; - + if (remainingSpace <= 0) { return 0; @@ -191,7 +191,8 @@ ushort slotTo int quantityToCollect = Math.Min(remainingSpace, itemStack.Quantity); - containerCopyOnWrite.Items[slot] = ItemStack.Combine(currentItemStackInSlot, itemStack with { + containerCopyOnWrite.Items[slot] = ItemStack.Combine(currentItemStackInSlot, itemStack with + { Quantity = (ushort)quantityToCollect }); @@ -273,7 +274,8 @@ ushort slotTo return (0, OperationResult.NotEnoughSpaceForAll); } - containerFromCopyOnWrite.Items[slotFrom] = itemStackFrom with { + containerFromCopyOnWrite.Items[slotFrom] = itemStackFrom with + { Quantity = (ushort)(itemStackFrom.Quantity - quantityToMove) }; containerToCopyOnWrite.Items[slotTo] = ItemStack.Combine( @@ -292,6 +294,89 @@ ushort slotTo return ((ushort)quantityToMove, OperationResult.Success); } + /// + /// Attempts to move items from one slot to another slot. + /// Fills existing stacks first, then empty slots. + /// + /// The container to move the items from + /// The slot to move the items from + /// The container to move the items to + /// If the slot index is out of range + public void MoveFromSlot(Container containerFrom, ushort slotFrom, Container containerTo) + { + var containerFromCopyOnWrite = this.GetOrCloneContainer(containerFrom); + var containerToCopyOnWrite = containerFrom.Equals(containerTo) + ? containerFromCopyOnWrite + : this.GetOrCloneContainer(containerTo); + + if (slotFrom < 0 || slotFrom >= containerFromCopyOnWrite.Items.Length) + { + throw new SlotIndexOutOfRangeException($"Slot {slotFrom} of source container is out of range [0, {containerFromCopyOnWrite.Items.Length})"); + } + + if (containerFromCopyOnWrite == containerToCopyOnWrite) + { + return; + } + + // Attempt to fill existing stacks first + for (ushort slotTo = 0; slotTo < containerToCopyOnWrite.Items.Length; slotTo++) + { + var itemStackFrom = containerFromCopyOnWrite.Items[slotFrom]; + var itemStackTo = containerToCopyOnWrite.Items[slotTo]; + + if (itemStackFrom.IsEmpty) + { + break; + } + + if (itemStackTo.Uuid == itemStackFrom.Uuid && this.GetRemainingSpace(containerTo, slotTo, itemStackTo) > 0) + { + var (moved, _) = this.Move( + containerFromCopyOnWrite, + slotFrom, + containerToCopyOnWrite, + slotTo, + itemStackFrom.Quantity, + mustMoveAll: false + ); + containerFromCopyOnWrite.Items[slotFrom] = itemStackFrom with { Quantity = (ushort)(itemStackFrom.Quantity - moved) }; + } + } + + // Attempt to fill empty slots + for (ushort slotTo = 0; slotTo < containerToCopyOnWrite.Items.Length; slotTo++) + { + var itemStackFrom = containerFromCopyOnWrite.Items[slotFrom]; + var itemStackTo = containerToCopyOnWrite.Items[slotTo]; + + if (itemStackFrom.IsEmpty) + { + break; + } + + if (itemStackTo.IsEmpty) + { + var (moved, _) = this.Move( + containerFromCopyOnWrite, + slotFrom, + containerToCopyOnWrite, + slotTo, + itemStackFrom.Quantity, + mustMoveAll: false + ); + containerFromCopyOnWrite.Items[slotFrom] = itemStackFrom with { Quantity = (ushort)(itemStackFrom.Quantity - moved) }; + } + } + + if (containerFromCopyOnWrite.Items[slotFrom].Quantity == 0) + { + containerFromCopyOnWrite.Items[slotFrom] = ItemStack.Empty; + } + + modified[containerFrom.Id] = containerFromCopyOnWrite; + } + /// /// Ends the transaction and applies the changes to the containers. /// @@ -314,7 +399,7 @@ ushort slotTo } var update = container.CreateNetworkUpdate(target); - + Array.Copy(container.Items, target.Items, container.Items.Length); target.Filter.Clear(); diff --git a/ScrapServer.Core/Services/PlayerService.cs b/ScrapServer.Core/Services/PlayerService.cs index 449d748..a96428a 100644 --- a/ScrapServer.Core/Services/PlayerService.cs +++ b/ScrapServer.Core/Services/PlayerService.cs @@ -230,6 +230,18 @@ public void Receive(ReadOnlySpan data) break; case ContainerTransaction.MoveFromSlotAction moveFromSlotAction: + { + if ( + !containerService.Containers.TryGetValue(moveFromSlotAction.ContainerFrom, out var containerFrom) || + !containerService.Containers.TryGetValue(moveFromSlotAction.ContainerTo, out var containerTo) || + moveFromSlotAction.SlotFrom >= containerFrom.Items.Length) + { + break; + } + transaction.MoveFromSlot(containerFrom, moveFromSlotAction.SlotFrom, containerTo); + } + break; + case ContainerTransaction.MoveAllAction moveAllAction: throw new NotImplementedException($"Container transaction action {action} not implemented"); diff --git a/ScrapServer.CoreTests/Services/ContainerServiceTests.cs b/ScrapServer.CoreTests/Services/ContainerServiceTests.cs index 60edb3f..7c2f7b9 100644 --- a/ScrapServer.CoreTests/Services/ContainerServiceTests.cs +++ b/ScrapServer.CoreTests/Services/ContainerServiceTests.cs @@ -650,4 +650,118 @@ public void SetItem_NormalUsage_SetsItem() // Assert Assert.That(container.Items, Is.EqualTo(new ItemStack[] { WoodBlock })); } + + [Test] + public void MoveFromSlot_SlotIndexOutOfRange_ThrowsException() + { + // Arrange + var service = this.CreateService(); + var containerFrom = service.CreateContainer(size: 1); + var containerTo = service.CreateContainer(size: 1); + using var transaction = service.BeginTransaction(); + + // Assert + Assert.That( + () => transaction.MoveFromSlot(containerFrom, slotFrom: 1, containerTo), + Throws.TypeOf() + ); + + // Cleanup + transaction.EndTransaction(); + } + + [Test] + public void MoveFromSlot_SameContainer_RemainsUnchanged() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 2); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(container, WoodBlock, slot: 1); + + // Act + transaction.MoveFromSlot(container, slotFrom: 1, container); + transaction.EndTransaction(); + + // Assert + Assert.That(container.Items, Is.EqualTo(new ItemStack[] { ItemStack.Empty, WoodBlock })); + } + + [Test] + public void MoveFromSlot_ItemAlreadyExistsInDestination_AddsToExistingStack() + { + // Arrange + var service = this.CreateService(); + var containerFrom = service.CreateContainer(size: 1); + var containerTo = service.CreateContainer(size: 3); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(containerFrom, WoodBlock with { Quantity = 2 }, slot: 0); + transaction.CollectToSlot(containerTo, WoodBlock with { Quantity = 1 }, slot: 1); + + // Act + transaction.MoveFromSlot(containerFrom, slotFrom: 0, containerTo); + transaction.EndTransaction(); + + // Assert + Assert.That(containerFrom.Items, Is.EqualTo(new ItemStack[] { ItemStack.Empty })); + Assert.That(containerTo.Items, Is.EqualTo(new ItemStack[] { + ItemStack.Empty, + WoodBlock with { Quantity = 3 }, + ItemStack.Empty + })); + } + + [Test] + public void MoveFromSlot_MultiplePartiallyFillsDestinationStacks_OverflowsIntoNextStack() + { + // Arrange + var service = this.CreateService(); + var containerFrom = service.CreateContainer(size: 1); + var containerTo = service.CreateContainer(size: 4, maximumStackSize: 10); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(containerFrom, WoodBlock with { Quantity = 10 }, slot: 0); + transaction.CollectToSlot(containerTo, WoodBlock with { Quantity = 3 }, slot: 1); + transaction.CollectToSlot(containerTo, WoodBlock with { Quantity = 5 }, slot: 2); + + // Act + transaction.MoveFromSlot(containerFrom, slotFrom: 0, containerTo); + transaction.EndTransaction(); + + // Assert + Assert.That(containerFrom.Items, Is.EqualTo(new ItemStack[] { ItemStack.Empty })); + Assert.That(containerTo.Items, Is.EqualTo(new ItemStack[] + { + ItemStack.Empty, + WoodBlock with { Quantity = 10 }, + WoodBlock with { Quantity = 8 }, + ItemStack.Empty + })); + } + + [Test] + public void MoveFromSlot_SinglePartiallyFillsDestinationStack_OverflowsIntoEmptySlots() + { + // Arrange + var service = this.CreateService(); + var containerFrom = service.CreateContainer(size: 1); + var containerTo = service.CreateContainer(size: 4, maximumStackSize: 10); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(containerFrom, WoodBlock with { Quantity = 20 }, slot: 0); + transaction.CollectToSlot(containerTo, WoodBlock with { Quantity = 9 }, slot: 1); + transaction.CollectToSlot(containerTo, WoodBlock with { Quantity = 9 }, slot: 2); + + // Act + transaction.MoveFromSlot(containerFrom, slotFrom: 0, containerTo); + transaction.EndTransaction(); + + // Assert + Assert.That(containerFrom.Items, Is.EqualTo(new ItemStack[] { ItemStack.Empty })); + Assert.That(containerTo.Items, Is.EqualTo(new ItemStack[] + { + WoodBlock with { Quantity = 10 }, + WoodBlock with { Quantity = 10 }, + WoodBlock with { Quantity = 10 }, + WoodBlock with { Quantity = 8 } + })); + } } From d8301f359665b27cec0804ffa6bcb505f9b3f602 Mon Sep 17 00:00:00 2001 From: TechnologicNick Date: Fri, 30 Aug 2024 01:24:48 +0200 Subject: [PATCH 16/18] Add ContainerService.Transaction.MoveAll --- ScrapServer.Core/Services/ContainerService.cs | 16 +++++- ScrapServer.Core/Services/PlayerService.cs | 13 ++++- .../Services/ContainerServiceTests.cs | 56 +++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/ScrapServer.Core/Services/ContainerService.cs b/ScrapServer.Core/Services/ContainerService.cs index afa94d5..cab0e80 100644 --- a/ScrapServer.Core/Services/ContainerService.cs +++ b/ScrapServer.Core/Services/ContainerService.cs @@ -1,4 +1,4 @@ -using ScrapServer.Core.NetObjs; +using ScrapServer.Core.NetObjs; using ScrapServer.Core.Utils; using ScrapServer.Networking.Data; using static ScrapServer.Core.NetObjs.Container; @@ -377,6 +377,20 @@ public void MoveFromSlot(Container containerFrom, ushort slotFrom, Container con modified[containerFrom.Id] = containerFromCopyOnWrite; } + /// + /// Attempts to move all items from one container to another. + /// Fills existing stacks first, then empty slots. + /// + /// The container to move the items from + /// The container to move the items to + public void MoveAll(Container containerFrom, Container containerTo) + { + for (ushort slotFrom = 0; slotFrom < containerFrom.Items.Length; slotFrom++) + { + this.MoveFromSlot(containerFrom, slotFrom, containerTo); + } + } + /// /// Ends the transaction and applies the changes to the containers. /// diff --git a/ScrapServer.Core/Services/PlayerService.cs b/ScrapServer.Core/Services/PlayerService.cs index a96428a..1f0fa71 100644 --- a/ScrapServer.Core/Services/PlayerService.cs +++ b/ScrapServer.Core/Services/PlayerService.cs @@ -243,10 +243,19 @@ public void Receive(ReadOnlySpan data) break; case ContainerTransaction.MoveAllAction moveAllAction: - throw new NotImplementedException($"Container transaction action {action} not implemented"); + { + if ( + !containerService.Containers.TryGetValue(moveAllAction.ContainerFrom, out var containerFrom) || + !containerService.Containers.TryGetValue(moveAllAction.ContainerTo, out var containerTo)) + { + break; + } + transaction.MoveAll(containerFrom, containerTo); + } + break; default: - return; + throw new NotImplementedException($"Container transaction action {action} not implemented"); } } diff --git a/ScrapServer.CoreTests/Services/ContainerServiceTests.cs b/ScrapServer.CoreTests/Services/ContainerServiceTests.cs index 7c2f7b9..15db5d6 100644 --- a/ScrapServer.CoreTests/Services/ContainerServiceTests.cs +++ b/ScrapServer.CoreTests/Services/ContainerServiceTests.cs @@ -764,4 +764,60 @@ public void MoveFromSlot_SinglePartiallyFillsDestinationStack_OverflowsIntoEmpty WoodBlock with { Quantity = 8 } })); } + + [Test] + public void MoveAll_FullDestination_MovesNothing() + { + // Arrange + var service = this.CreateService(); + var containerFrom = service.CreateContainer(size: 1); + var containerTo = service.CreateContainer(size: 2, maximumStackSize: 10); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(containerFrom, WoodBlock with { Quantity = 10 }, slot: 0); + transaction.CollectToSlot(containerTo, WoodBlock with { Quantity = 10 }, slot: 0); + transaction.CollectToSlot(containerTo, WoodBlock with { Quantity = 10 }, slot: 1); + + // Act + transaction.MoveAll(containerFrom, containerTo); + transaction.EndTransaction(); + + // Assert + Assert.That(containerFrom.Items, Is.EqualTo(new ItemStack[] { WoodBlock with { Quantity = 10 } })); + Assert.That(containerTo.Items, Is.EqualTo(new ItemStack[] { + WoodBlock with { Quantity = 10 }, + WoodBlock with { Quantity = 10 } + })); + } + + [Test] + public void MoveAll_EmptyDestination_MovesAll() + { + // Arrange + var service = this.CreateService(); + var containerFrom = service.CreateContainer(size: 4); + var containerTo = service.CreateContainer(size: 4, maximumStackSize: 10); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(containerFrom, WoodBlock with { Quantity = 2 }, slot: 1); + transaction.CollectToSlot(containerFrom, ConcreteBlock with { Quantity = 5 }, slot: 2); + transaction.CollectToSlot(containerFrom, WoodBlock with { Quantity = 3 }, slot: 3); + + // Act + transaction.MoveAll(containerFrom, containerTo); + transaction.EndTransaction(); + + // Assert + Assert.That(containerFrom.Items, Is.EqualTo(new ItemStack[] { + ItemStack.Empty, + ItemStack.Empty, + ItemStack.Empty, + ItemStack.Empty + })); + Assert.That(containerTo.Items, Is.EqualTo(new ItemStack[] + { + WoodBlock with { Quantity = 5 }, + ConcreteBlock with { Quantity = 5 }, + ItemStack.Empty, + ItemStack.Empty + })); + } } From 5507745f38b215e177f3ffc3cfe2cf03819bbe38 Mon Sep 17 00:00:00 2001 From: TechnologicNick Date: Fri, 30 Aug 2024 18:33:31 +0200 Subject: [PATCH 17/18] Refactor ContainerService.Transaction.MoveFromSlot --- ScrapServer.Core/NetObjs/Container.cs | 39 +++++++++++++ ScrapServer.Core/Services/ContainerService.cs | 58 ++++++++++--------- 2 files changed, 70 insertions(+), 27 deletions(-) diff --git a/ScrapServer.Core/NetObjs/Container.cs b/ScrapServer.Core/NetObjs/Container.cs index f127cd2..41f6675 100644 --- a/ScrapServer.Core/NetObjs/Container.cs +++ b/ScrapServer.Core/NetObjs/Container.cs @@ -72,6 +72,45 @@ public Container Clone() return clone; } + /// + /// Lazily find all slots containing an item with the given UUID. + /// + /// + /// Supports mutating the container while iterating. + /// Slots behind the cursor will not be checked, while slots ahead of the cursor will be checked. + /// + /// The UUID to search for + /// An enumerable of slots containing the item + public IEnumerable<(ushort Slot, ItemStack item)> FindAllSlotsWithUuid(Guid uuid) + { + for (ushort i = 0; i < this.Items.Length; i++) + { + if (this.Items[i].Uuid == uuid) + { + yield return (i, this.Items[i]); + } + } + } + + /// + /// Lazily find all empty slots in the container. + /// + /// + /// Supports mutating the container while iterating. + /// Slots behind the cursor will not be checked, while slots ahead of the cursor will be checked. + /// + /// An enumerable of empty slots + public IEnumerable FindAllEmptySlots() + { + for (ushort i = 0; i < this.Items.Length; i++) + { + if (this.Items[i].IsEmpty) + { + yield return i; + } + } + } + public void SerializeCreate(ref BitWriter writer) { new CreateContainer diff --git a/ScrapServer.Core/Services/ContainerService.cs b/ScrapServer.Core/Services/ContainerService.cs index cab0e80..2269cf1 100644 --- a/ScrapServer.Core/Services/ContainerService.cs +++ b/ScrapServer.Core/Services/ContainerService.cs @@ -319,43 +319,41 @@ public void MoveFromSlot(Container containerFrom, ushort slotFrom, Container con return; } - // Attempt to fill existing stacks first - for (ushort slotTo = 0; slotTo < containerToCopyOnWrite.Items.Length; slotTo++) + var itemStackFrom = containerFromCopyOnWrite.Items[slotFrom]; + if (itemStackFrom.IsEmpty) { - var itemStackFrom = containerFromCopyOnWrite.Items[slotFrom]; - var itemStackTo = containerToCopyOnWrite.Items[slotTo]; - - if (itemStackFrom.IsEmpty) - { - break; - } + return; + } - if (itemStackTo.Uuid == itemStackFrom.Uuid && this.GetRemainingSpace(containerTo, slotTo, itemStackTo) > 0) + // Attempt to fill existing stacks first + foreach (var (slotTo, itemStackTo) in containerToCopyOnWrite.FindAllSlotsWithUuid(itemStackFrom.Uuid)) + { + if (this.GetRemainingSpace(containerTo, slotTo, itemStackTo) <= 0) { - var (moved, _) = this.Move( - containerFromCopyOnWrite, - slotFrom, - containerToCopyOnWrite, - slotTo, - itemStackFrom.Quantity, - mustMoveAll: false - ); - containerFromCopyOnWrite.Items[slotFrom] = itemStackFrom with { Quantity = (ushort)(itemStackFrom.Quantity - moved) }; + continue; } - } - // Attempt to fill empty slots - for (ushort slotTo = 0; slotTo < containerToCopyOnWrite.Items.Length; slotTo++) - { - var itemStackFrom = containerFromCopyOnWrite.Items[slotFrom]; - var itemStackTo = containerToCopyOnWrite.Items[slotTo]; + var (moved, _) = this.Move( + containerFromCopyOnWrite, + slotFrom, + containerToCopyOnWrite, + slotTo, + itemStackFrom.Quantity, + mustMoveAll: false + ); + itemStackFrom = itemStackFrom with { Quantity = (ushort)(itemStackFrom.Quantity - moved) }; + containerFromCopyOnWrite.Items[slotFrom] = itemStackFrom; if (itemStackFrom.IsEmpty) { break; } + } - if (itemStackTo.IsEmpty) + if (!itemStackFrom.IsEmpty) + { + // Attempt to fill empty slots + foreach (var slotTo in containerToCopyOnWrite.FindAllEmptySlots()) { var (moved, _) = this.Move( containerFromCopyOnWrite, @@ -365,7 +363,13 @@ public void MoveFromSlot(Container containerFrom, ushort slotFrom, Container con itemStackFrom.Quantity, mustMoveAll: false ); - containerFromCopyOnWrite.Items[slotFrom] = itemStackFrom with { Quantity = (ushort)(itemStackFrom.Quantity - moved) }; + itemStackFrom = itemStackFrom with { Quantity = (ushort)(itemStackFrom.Quantity - moved) }; + containerFromCopyOnWrite.Items[slotFrom] = itemStackFrom; + + if (itemStackFrom.IsEmpty) + { + break; + } } } From f4ca8925e71d9a5fa9f4a8cfbe797cea40837ed8 Mon Sep 17 00:00:00 2001 From: TechnologicNick Date: Sun, 1 Sep 2024 23:46:58 +0200 Subject: [PATCH 18/18] Add ContainerService.Transaction.Collect --- ScrapServer.Core/Services/ContainerService.cs | 93 +++++++++++++++ ScrapServer.Core/Services/PlayerService.cs | 13 +++ .../Services/ContainerServiceTests.cs | 106 ++++++++++++++++++ 3 files changed, 212 insertions(+) diff --git a/ScrapServer.Core/Services/ContainerService.cs b/ScrapServer.Core/Services/ContainerService.cs index 2269cf1..b8bd672 100644 --- a/ScrapServer.Core/Services/ContainerService.cs +++ b/ScrapServer.Core/Services/ContainerService.cs @@ -35,11 +35,30 @@ public class Transaction(ContainerService containerService) : IDisposable { private readonly Dictionary modified = []; + /// + /// If has already been modified in this transaction, + /// returns the modified container. + /// Otherwise, returns a clone of . + /// + /// The container + /// The modified container or a clone of private Container GetOrCloneContainer(Container container) { return modified.TryGetValue(container.Id, out Container? found) ? found : container.Clone(); } + /// + /// If has already been modified in this transaction, + /// returns a clone of the modified container. + /// Otherwise, returns a clone of . + /// + /// The container + /// The cloned container + private Container GetAndCloneContainer(Container container) + { + return modified.TryGetValue(container.Id, out Container? found) ? found.Clone() : container.Clone(); + } + /// /// Calculates the remaining space in a container slot for an item stack. /// @@ -91,6 +110,80 @@ public void SetItem(Container container, ushort slot, ItemStack itemStack) modified[container.Id] = containerCopyOnWrite; } + /// + /// Collects items into any slot of a container. Fills existing stacks first, then empty slots. + /// + /// The container to collect the items into + /// The item stack, including quantity, to collect + /// + /// If , only collect items if they all fit in the container. + /// If , collect as many items as possible. + /// + /// The number of items collected + public ushort Collect(Container container, ItemStack itemStack, bool mustCollectAll = true) + { + var containerCopyOnWrite = this.GetOrCloneContainer(container); + + if (itemStack.IsEmpty) + { + return 0; + } + + if (mustCollectAll && this.modified.ContainsKey(container.Id)) + { + // We need to clone to be able to abort if we already collected into one slot, + // and then discover that the remaining quantity does not fit into the remaining slots. + containerCopyOnWrite = containerCopyOnWrite.Clone(); + } + + var startQuantity = itemStack.Quantity; + + foreach (var (slot, _) in containerCopyOnWrite.FindAllSlotsWithUuid(itemStack.Uuid)) + { + var (collected, _) = this.CollectToSlot( + containerCopyOnWrite, + itemStack, + slot, + mustCollectAll: false + ); + itemStack = itemStack with { Quantity = (ushort)(itemStack.Quantity - collected) }; + + if (itemStack.IsEmpty) + { + break; + } + } + + if (!itemStack.IsEmpty) + { + foreach (var slot in containerCopyOnWrite.FindAllEmptySlots()) + { + var (collected, _) = this.CollectToSlot( + containerCopyOnWrite, + itemStack, + slot, + mustCollectAll: false + ); + itemStack = itemStack with { Quantity = (ushort)(itemStack.Quantity - collected) }; + + if (itemStack.IsEmpty) + { + break; + } + } + } + + if (mustCollectAll && !itemStack.IsEmpty) + { + // The calls to above may have modified the container. + // To abort, we need to restore the original state. + modified[container.Id] = containerCopyOnWrite; + return 0; + } + + return (ushort)(startQuantity - itemStack.Quantity); + } + /// /// Swaps items between two slots in the same or different containers. /// diff --git a/ScrapServer.Core/Services/PlayerService.cs b/ScrapServer.Core/Services/PlayerService.cs index 1f0fa71..b125e5c 100644 --- a/ScrapServer.Core/Services/PlayerService.cs +++ b/ScrapServer.Core/Services/PlayerService.cs @@ -209,6 +209,19 @@ public void Receive(ReadOnlySpan data) break; case ContainerTransaction.CollectAction collectAction: + { + if (!containerService.Containers.TryGetValue(collectAction.ContainerId, out var containerTo)) + { + break; + } + transaction.Collect( + containerTo, + new ItemStack(collectAction.Uuid, collectAction.ToolInstanceId, collectAction.Quantity), + collectAction.MustCollectAll + ); + } + break; + case ContainerTransaction.SpendAction spendAction: case ContainerTransaction.CollectToSlotAction collectToSlotAction: case ContainerTransaction.CollectToSlotOrCollectAction collectToSlotOrCollectAction: diff --git a/ScrapServer.CoreTests/Services/ContainerServiceTests.cs b/ScrapServer.CoreTests/Services/ContainerServiceTests.cs index 15db5d6..217dbd4 100644 --- a/ScrapServer.CoreTests/Services/ContainerServiceTests.cs +++ b/ScrapServer.CoreTests/Services/ContainerServiceTests.cs @@ -820,4 +820,110 @@ public void MoveAll_EmptyDestination_MovesAll() ItemStack.Empty })); } + + [Test] + public void Collect_EmptyStack_ReturnsZero() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 1); + using var transaction = service.BeginTransaction(); + + // Act + var collected = transaction.Collect(container, WoodBlock with { Quantity = 0 }); + transaction.EndTransaction(); + + // Assert + Assert.That(collected, Is.EqualTo(0)); + Assert.That(container.Items, Is.EqualTo(new ItemStack[] { ItemStack.Empty })); + } + + [Test] + public void Collect_MustCollectAllFalse_FillsUpContainer() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 1, maximumStackSize: 10); + using var transaction = service.BeginTransaction(); + + // Act + var collected = transaction.Collect(container, WoodBlock with { Quantity = 100 }, mustCollectAll: false); + transaction.EndTransaction(); + + // Assert + Assert.That(collected, Is.EqualTo(10)); + Assert.That(container.Items, Is.EqualTo(new ItemStack[] { WoodBlock with { Quantity = 10 } })); + } + + [Test] + public void Collect_NormalUsage_AddsToExistingStack() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 4, maximumStackSize: 10); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(container, WoodBlock with { Quantity = 5 }, slot: 1); + transaction.CollectToSlot(container, WoodBlock with { Quantity = 1 }, slot: 2); + + // Act + var collected = transaction.Collect(container, WoodBlock with { Quantity = 6 }); + transaction.EndTransaction(); + + // Assert + Assert.That(collected, Is.EqualTo(6)); + Assert.That(container.Items, Is.EqualTo(new ItemStack[] { + ItemStack.Empty, + WoodBlock with { Quantity = 10 }, + WoodBlock with { Quantity = 2 }, + ItemStack.Empty + })); + } + + [Test] + public void Collect_NormalUsage_OverflowsToEmptySlots() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 5, maximumStackSize: 10); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(container, WoodBlock with { Quantity = 9 }, slot: 2); + transaction.CollectToSlot(container, WoodBlock with { Quantity = 9 }, slot: 3); + + // Act + var collected = transaction.Collect(container, WoodBlock with { Quantity = 13 }); + transaction.EndTransaction(); + + // Assert + Assert.That(collected, Is.EqualTo(13)); + Assert.That(container.Items, Is.EqualTo(new ItemStack[] { + WoodBlock with { Quantity = 10 }, + WoodBlock with { Quantity = 1 }, + WoodBlock with { Quantity = 10 }, + WoodBlock with { Quantity = 10 }, + ItemStack.Empty + })); + } + + [Test] + public void Collect_MoreThanFits_AddsNothing() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 3, maximumStackSize: 10); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(container, WoodBlock with { Quantity = 9 }, slot: 1); + + // Act + var collected = transaction.Collect(container, WoodBlock with { Quantity = 22 }); + transaction.EndTransaction(); + + // Assert + Assert.That(collected, Is.EqualTo(0)); + Assert.That(container.Items, Is.EqualTo(new ItemStack[] + { + ItemStack.Empty, + WoodBlock with { Quantity = 9 }, + ItemStack.Empty + })); + } }