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() }} 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/NetObjs/Character.cs b/ScrapServer.Core/NetObjs/Character.cs index 86f94a4..baee9de 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,86 +178,7 @@ 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; - } - - public BlobData BlobData(uint tick) - { - var playerData = new PlayerData - { - CharacterID = 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 { 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() }); + }.Serialize(ref writer); } public class Builder diff --git a/ScrapServer.Core/NetObjs/Container.cs b/ScrapServer.Core/NetObjs/Container.cs new file mode 100644 index 0000000..41f6675 --- /dev/null +++ b/ScrapServer.Core/NetObjs/Container.cs @@ -0,0 +1,189 @@ +using ScrapServer.Networking; +using ScrapServer.Networking.Data; +using ScrapServer.Utility.Serialization; + +namespace ScrapServer.Core.NetObjs; + +public class Container : INetObj +{ + 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) + { + 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, this.MaximumStackSize); + for (int i = 0; i < this.Items.Length; i++) + { + clone.Items[i] = this.Items[i]; + } + + clone.Filter.UnionWith(this.Filter); + + 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 + { + StackSize = this.MaximumStackSize, + Items = this.Items.Select(item => new CreateContainer.ItemStack + { + Uuid = item.Uuid, + InstanceId = item.InstanceId, + Quantity = item.Quantity + }).ToArray(), + Filter = [.. this.Filter], + }.Serialize(ref writer); + } + + /// + /// Serialize the update of the container with no changes. + /// + /// + public void SerializeUpdate(ref BitWriter writer) + { + 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/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/ContainerService.cs b/ScrapServer.Core/Services/ContainerService.cs new file mode 100644 index 0000000..b8bd672 --- /dev/null +++ b/ScrapServer.Core/Services/ContainerService.cs @@ -0,0 +1,620 @@ +using ScrapServer.Core.NetObjs; +using ScrapServer.Core.Utils; +using ScrapServer.Networking.Data; +using static ScrapServer.Core.NetObjs.Container; + +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 = []; + + /// + /// 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. + /// + /// 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; + } + + /// + /// 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; + } + + /// + /// 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. + /// + /// 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. + /// + /// + /// 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 , 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 + ) + { + 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})"); + } + + var currentItemStackInSlot = containerCopyOnWrite.Items[slot]; + + if (!currentItemStackInSlot.IsStackableWith(itemStack)) + { + return (0, OperationResult.NotStackable); + } + + int remainingSpace = this.GetRemainingSpace(containerCopyOnWrite, slot, itemStack); + if (remainingSpace <= 0) + { + return (0, OperationResult.NotEnoughSpace); + } + + if (mustCollectAll && remainingSpace < itemStack.Quantity) + { + return (0, OperationResult.NotEnoughSpaceForAll); + } + + 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); + } + + /// + /// 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; + } + + var itemStackFrom = containerFromCopyOnWrite.Items[slotFrom]; + if (itemStackFrom.IsEmpty) + { + return; + } + + // Attempt to fill existing stacks first + foreach (var (slotTo, itemStackTo) in containerToCopyOnWrite.FindAllSlotsWithUuid(itemStackFrom.Uuid)) + { + if (this.GetRemainingSpace(containerTo, slotTo, itemStackTo) <= 0) + { + continue; + } + + 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 (!itemStackFrom.IsEmpty) + { + // Attempt to fill empty slots + foreach (var slotTo in containerToCopyOnWrite.FindAllEmptySlots()) + { + 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 (containerFromCopyOnWrite.Items[slotFrom].Quantity == 0) + { + containerFromCopyOnWrite.Items[slotFrom] = ItemStack.Empty; + } + + 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. + /// + /// A list of tuples containing the updated containers and their network updates + /// If the transaction is not the current transaction + 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; + } + + /// + /// Aborts the transaction and discards the changes. + /// + /// If the transaction is not the current transaction + public void AbortTransaction() + { + if (containerService.CurrentTransaction != this) + { + throw new InvalidOperationException("Attempted to abort a transaction that is not the current transaction"); + } + + containerService.CurrentTransaction = null; + } + + public void Dispose() + { + if (containerService.CurrentTransaction != null) + { + 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) + { + } + + public enum OperationResult + { + Success, + NotStackable, + NotEnoughSpace, + NotEnoughSpaceForAll, + } + } + + /// + /// 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) + { + throw new InvalidOperationException("Cannot start a transaction while one is already in progress"); + } + + 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 + + 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(); + var container = new Container(id, size, maximumStackSize); + + Containers[id] = container; + + 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.Core/Services/PlayerService.cs b/ScrapServer.Core/Services/PlayerService.cs index 19bb7e6..b125e5c 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; @@ -6,7 +6,7 @@ using Steamworks.Data; using System.Text; using ScrapServer.Core.NetObjs; - +using static ScrapServer.Core.NetObjs.Container; namespace ScrapServer.Core; @@ -16,11 +16,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() @@ -75,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, @@ -104,11 +108,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(ply.Character.BlobData(SchedulerService.GameTick)); + blobs.Add(player.GetPlayerData(player.Character)); } var genericInit = new GenericInitData { Data = blobs.ToArray(), GameTick = SchedulerService.GameTick }; @@ -134,20 +138,23 @@ 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); + builder.WriteCreate(player.InventoryContainer); + builder.WriteCreate(player.CarryContainer); } - 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()) { - character.SpawnPackets(client, SchedulerService.GameTick); + client.SendSpawnPackets(this, character, SchedulerService.GameTick); } Console.WriteLine("Sent ScriptInitData and NetworkInitUpdate for Client"); @@ -160,6 +167,126 @@ 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: + { + 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: + { + 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: + { + 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: + 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: + { + 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: + { + if ( + !containerService.Containers.TryGetValue(moveAllAction.ContainerFrom, out var containerFrom) || + !containerService.Containers.TryGetValue(moveAllAction.ContainerTo, out var containerTo)) + { + break; + } + transaction.MoveAll(containerFrom, containerTo); + } + break; + + default: + throw new NotImplementedException($"Container transaction action {action} not implemented"); + } + } + + 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(); @@ -174,6 +301,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(Player player, Character character, uint tick) + { + // Packet 13 - Generic Init Data + this.Send(new GenericInitData { Data = [player.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); @@ -258,7 +423,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(i % 2 == 0 ? "df953d9c-234f-4ac2-af5e-f0490b223e71" : "a6c6ce30-dd47-4587-b475-085d55c6a3b4"), 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.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..217dbd4 --- /dev/null +++ b/ScrapServer.CoreTests/Services/ContainerServiceTests.cs @@ -0,0 +1,929 @@ +using NUnit.Framework; +using ScrapServer.Core; +using static ScrapServer.Core.ContainerService.Transaction; +using static ScrapServer.Core.NetObjs.Container; + +namespace ScrapServer.CoreTests.Services; + +[TestFixture] +public class ContainerServiceTests +{ + 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() + { + // 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); + } + + [Test] + public void CollectToSlot_WoodToEmptySlot_ReturnsSuccess() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 1); + using var transaction = service.BeginTransaction(); + + // Act + var (collected, result) = transaction.CollectToSlot(container, WoodBlock, slot: 0); + transaction.EndTransaction(); + + // Assert + Assert.That((collected, result), Is.EqualTo((1, OperationResult.Success))); + Assert.That(container.Items, Is.EqualTo(new ItemStack[] { WoodBlock })); + } + + [Test] + public void CollectToSlot_SlotIndexOutOfRange_ThrowsException() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 1); + 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); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(container, ConcreteBlock, slot: 0); + + // 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] + public void CollectToSlot_NotEnoughSpace_ReturnsZero() + { + // Arrange + var service = this.CreateService(); + var container = service.CreateContainer(size: 1, maximumStackSize: 1); + using var transaction = service.BeginTransaction(); + transaction.CollectToSlot(container, WoodBlock, slot: 0); + + // 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] + public void CollectToSlot_NotEnoughSpaceForAll_ReturnsZero() + { + // 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 (collected, result) = transaction.CollectToSlot( + container, + WoodBlock with { Quantity = 3 }, + slot: 0, + mustCollectAll: true + ); + 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] + public void CollectToSlot_MustCollectAllFalse_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 (collected, result) = transaction.CollectToSlot( + container, + WoodBlock with { Quantity = 3 }, + slot: 0, + mustCollectAll: false + ); + 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] + 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(); + } + + [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 } })); + } + + [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 })); + } + + [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 })); + } + + [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 } + })); + } + + [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 + })); + } + + [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 + })); + } +} diff --git a/ScrapServer.Networking/ContainerTransaction.cs b/ScrapServer.Networking/ContainerTransaction.cs new file mode 100644 index 0000000..9507303 --- /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(ByteOrder.LittleEndian); + 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, ByteOrder.LittleEndian); + 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 { }, + _ => new MoveAction { }, // Default to move action + }; + 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/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..3732eb9 --- /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(ByteOrder.LittleEndian), + 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, ByteOrder.LittleEndian); + 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/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, } 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/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/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..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; @@ -26,4 +27,78 @@ 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; + } + + 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 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); + + writeDelegate.Invoke(ref writer); + + NetObj.WriteSize(ref writer, sizePos); + + return this; + } + + public Builder WriteCreate(TNetObj obj) where TNetObj : INetObj + { + return Write(obj, NetworkUpdateType.Create, obj.SerializeCreate); + } + + public Builder WriteUpdate(TNetObj obj) where TNetObj : INetObj + { + return Write(obj, NetworkUpdateType.Update, obj.SerializeUpdate); + } + + public Builder WriteP(TNetObj obj) where TNetObj : INetObj + { + return Write(obj, NetworkUpdateType.P, obj.SerializeP); + } + + public Builder WriteRemove(TNetObj obj) where TNetObj : INetObj + { + return Write(obj, NetworkUpdateType.Remove, obj.SerializeRemove); + } + + public NetworkUpdate Build() + { + var packet = new NetworkUpdate { GameTick = GameTick, Updates = writer.Data.ToArray() }; + writer.Dispose(); + + return packet; + } + } } 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); } /// 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); }