diff --git a/docs/Packets/C2-45-AddTransformedCharacterToScopeExtended_by-server.md b/docs/Packets/C2-45-AddTransformedCharacterToScopeExtended_by-server.md new file mode 100644 index 000000000..e4a81d0ef --- /dev/null +++ b/docs/Packets/C2-45-AddTransformedCharacterToScopeExtended_by-server.md @@ -0,0 +1,42 @@ +# C2 45 - AddTransformedCharacterToScopeExtended (by server) + +## Is sent when + +The player wears a monster transformation ring (extended client). + +## Causes the following actions on the client side + +The character appears as monster, defined by the Skin property. + +## Structure + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 1 | Byte | 0xC2 | [Packet type](PacketTypes.md) | +| 1 | 2 | Short | | Packet header - length of the packet | +| 3 | 1 | Byte | 0x45 | Packet header - packet type identifier | +| 4 | 1 | Byte | 1 | CharacterCount; The number of characters in this packet. This packet contains only one character, because the size of the appearance data depends on the used appearance serializer. | +| 5 | 2 | ShortBigEndian | | Id | +| 7 | 1 | Byte | | CurrentPositionX | +| 8 | 1 | Byte | | CurrentPositionY | +| 9 | 2 | ShortBigEndian | | Skin | +| 11 | 10 | String | | Name | +| 21 | 1 | Byte | | TargetPositionX | +| 22 | 1 | Byte | | TargetPositionY | +| 23 | 4 bit | Byte | | Rotation | +| 23 << 0 | 4 bit | CharacterHeroState | | HeroState | +| 24 | | Binary | | AppearanceAndEffects; The appearance data, followed by the number of effects and the effect ids. | + +### CharacterHeroState Enum + +Defines the hero state of a character. + +| Value | Name | Description | +|-------|------|-------------| +| 0 | New | The character is new and has the highest state. | +| 1 | Hero | The character is a hero. | +| 2 | LightHero | The character is a hero, but the state is almost gone. | +| 3 | Normal | The character is in a neutral state. | +| 4 | PlayerKillWarning | The character killed another character, and has a kill warning. | +| 5 | PlayerKiller1stStage | The character killed two characters, and has some restrictions. | +| 6 | PlayerKiller2ndStage | The character killed more than two characters, and has hard restrictions. | \ No newline at end of file diff --git a/docs/Packets/ServerToClient.md b/docs/Packets/ServerToClient.md index 1a6e9b30c..433a61409 100644 --- a/docs/Packets/ServerToClient.md +++ b/docs/Packets/ServerToClient.md @@ -91,6 +91,7 @@ * [C1 44 - PartyHealthUpdate (by server)](C1-44-PartyHealthUpdate_by-server.md) * [C2 45 - AddTransformedCharactersToScope075 (by server)](C2-45-AddTransformedCharactersToScope075_by-server.md) * [C2 45 - AddTransformedCharactersToScope (by server)](C2-45-AddTransformedCharactersToScope_by-server.md) + * [C2 45 - AddTransformedCharacterToScopeExtended (by server)](C2-45-AddTransformedCharacterToScopeExtended_by-server.md) * [C1 46 - ChangeTerrainAttributes (by server)](C1-46-ChangeTerrainAttributes_by-server.md) * [C1 48 - ShowEffect (by server)](C1-48-ShowEffect_by-server.md) * [C3 4A - RageAttack (by server)](C3-4A-RageAttack_by-server.md) diff --git a/src/GameServer/RemoteView/World/NewPlayersInScopeExtendedPlugIn.cs b/src/GameServer/RemoteView/World/NewPlayersInScopeExtendedPlugIn.cs index 0a6a62239..744a95070 100644 --- a/src/GameServer/RemoteView/World/NewPlayersInScopeExtendedPlugIn.cs +++ b/src/GameServer/RemoteView/World/NewPlayersInScopeExtendedPlugIn.cs @@ -102,4 +102,67 @@ int Write() await connection.SendAsync(Write).ConfigureAwait(false); } + + /// + protected override async ValueTask SendTransformedCharacterAsync(Player newPlayer, bool isSpawned) + { + var connection = this.Player.Connection; + if (connection is null) + { + return; + } + + var selectedCharacter = newPlayer.SelectedCharacter; + if (selectedCharacter is null) + { + return; + } + + int Write() + { + var appearanceSerializer = this.Player.AppearanceSerializer; + var activeEffects = newPlayer.MagicEffectList.VisibleEffects; + var requiredSize = AddTransformedCharacterToScopeExtendedRef.GetRequiredSize(appearanceSerializer.NeededSpace + activeEffects.Count + 1); + + var span = connection.Output.GetSpan(requiredSize)[..requiredSize]; + var packet = new AddTransformedCharacterToScopeExtendedRef(span); + + packet.Id = newPlayer.GetId(this.Player); + if (isSpawned) + { + packet.Id |= 0x8000; + } + + packet.CurrentPositionX = newPlayer.Position.X; + packet.CurrentPositionY = newPlayer.Position.Y; + packet.Skin = (ushort)newPlayer.Attributes![Stats.TransformationSkin]; + packet.Name = selectedCharacter.Name; + if (newPlayer.IsWalking) + { + packet.TargetPositionX = newPlayer.WalkTarget.X; + packet.TargetPositionY = newPlayer.WalkTarget.Y; + } + else + { + packet.TargetPositionX = newPlayer.Position.X; + packet.TargetPositionY = newPlayer.Position.Y; + } + + packet.Rotation = newPlayer.Rotation.ToPacketByte(); + packet.HeroState = selectedCharacter.State.Convert(); + + appearanceSerializer.WriteAppearanceData(packet.AppearanceAndEffects, newPlayer.AppearanceData, true); + + var effectsStartIndex = appearanceSerializer.NeededSpace; + packet.AppearanceAndEffects[effectsStartIndex] = (byte)activeEffects.Count; + for (int e = 0; e < activeEffects.Count; ++e) + { + packet.AppearanceAndEffects[effectsStartIndex + 1 + e] = (byte)activeEffects[e].Id; + } + + return span.Length; + } + + await connection.SendAsync(Write).ConfigureAwait(false); + } } \ No newline at end of file diff --git a/src/GameServer/RemoteView/World/NewPlayersInScopePlugIn.cs b/src/GameServer/RemoteView/World/NewPlayersInScopePlugIn.cs index f415d762e..096722c18 100644 --- a/src/GameServer/RemoteView/World/NewPlayersInScopePlugIn.cs +++ b/src/GameServer/RemoteView/World/NewPlayersInScopePlugIn.cs @@ -135,44 +135,13 @@ int Write() await connection.SendAsync(Write).ConfigureAwait(false); } - private async ValueTask<(IList? ShopPlayers, IList? GuildPlayers)> SendCharactersAsync(IEnumerable newPlayers, bool isSpawned) - { - IList? shopPlayers = null; - IList? guildPlayers = null; - - var connection = this.Player.Connection; - if (connection is null) - { - return (shopPlayers, guildPlayers); - } - - var newPlayerList = newPlayers.ToList(); - foreach (var newPlayer in newPlayerList) - { - if (newPlayer.Attributes?[Stats.TransformationSkin] == 0) - { - await this.SendCharacterAsync(newPlayer, isSpawned).ConfigureAwait(false); - } - else - { - await this.SendTransformedCharacterAsync(newPlayer, isSpawned).ConfigureAwait(false); - } - - if (newPlayer.ShopStorage?.StoreOpen ?? false) - { - (shopPlayers ??= new List()).Add(newPlayer); - } - - if (newPlayer.GuildStatus != null) - { - (guildPlayers ??= new List()).Add(newPlayer); - } - } - - return (shopPlayers, guildPlayers); - } - - private async ValueTask SendTransformedCharacterAsync(Player newPlayer, bool isSpawned) + /// + /// Sends information about a new transformed player which has come into view. + /// + /// The new player. + /// If the player has spawned. + /// A . + protected virtual async ValueTask SendTransformedCharacterAsync(Player newPlayer, bool isSpawned) { var connection = this.Player.Connection; if (connection is null) @@ -242,4 +211,43 @@ int Write() await connection.SendAsync(Write).ConfigureAwait(false); } + + private async ValueTask<(IList? ShopPlayers, IList? GuildPlayers)> SendCharactersAsync(IEnumerable newPlayers, bool isSpawned) + { + IList? shopPlayers = null; + IList? guildPlayers = null; + + var connection = this.Player.Connection; + if (connection is null) + { + return (shopPlayers, guildPlayers); + } + + var newPlayerList = newPlayers.ToList(); + foreach (var newPlayer in newPlayerList) + { + if (newPlayer.Attributes?[Stats.TransformationSkin] == 0) + { + await this.SendCharacterAsync(newPlayer, isSpawned).ConfigureAwait(false); + } + else + { + await this.SendTransformedCharacterAsync(newPlayer, isSpawned).ConfigureAwait(false); + } + + if (newPlayer.ShopStorage?.StoreOpen ?? false) + { + shopPlayers ??= new List(); + shopPlayers.Add(newPlayer); + } + + if (newPlayer.GuildStatus != null) + { + guildPlayers ??= new List(); + guildPlayers.Add(newPlayer); + } + } + + return (shopPlayers, guildPlayers); + } } \ No newline at end of file diff --git a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs index 258dd1309..c75de1161 100644 --- a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs +++ b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs @@ -2494,6 +2494,54 @@ int WritePacket() await connection.SendAsync(WritePacket).ConfigureAwait(false); } + /// + /// Sends a to this connection. + /// + /// The connection. + /// The id. + /// The current position x. + /// The current position y. + /// The skin. + /// The name. + /// The target position x. + /// The target position y. + /// The rotation. + /// The hero state. + /// The appearance data, followed by the number of effects and the effect ids. + /// The number of characters in this packet. This packet contains only one character, because the size of the appearance data depends on the used appearance serializer. + /// + /// Is sent by the server when: The player wears a monster transformation ring (extended client). + /// Causes reaction on client side: The character appears as monster, defined by the Skin property. + /// + public static async ValueTask SendAddTransformedCharacterToScopeExtendedAsync(this IConnection? connection, ushort @id, byte @currentPositionX, byte @currentPositionY, ushort @skin, string @name, byte @targetPositionX, byte @targetPositionY, byte @rotation, CharacterHeroState @heroState, Memory @appearanceAndEffects, byte @characterCount = 1) + { + if (connection is null) + { + return; + } + + int WritePacket() + { + var length = AddTransformedCharacterToScopeExtendedRef.GetRequiredSize(appearanceAndEffects.Length); + var packet = new AddTransformedCharacterToScopeExtendedRef(connection.Output.GetSpan(length)[..length]); + packet.CharacterCount = @characterCount; + packet.Id = @id; + packet.CurrentPositionX = @currentPositionX; + packet.CurrentPositionY = @currentPositionY; + packet.Skin = @skin; + packet.Name = @name; + packet.TargetPositionX = @targetPositionX; + packet.TargetPositionY = @targetPositionY; + packet.Rotation = @rotation; + packet.HeroState = @heroState; + @appearanceAndEffects.Span.CopyTo(packet.AppearanceAndEffects); + + return packet.Header.Length; + } + + await connection.SendAsync(WritePacket).ConfigureAwait(false); + } + /// /// Sends a to this connection. /// diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs index 9e6e6cfb7..4eb75f484 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs @@ -12869,6 +12869,177 @@ public byte Id } +/// +/// Is sent by the server when: The player wears a monster transformation ring (extended client). +/// Causes reaction on client side: The character appears as monster, defined by the Skin property. +/// +public readonly struct AddTransformedCharacterToScopeExtended +{ + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public AddTransformedCharacterToScopeExtended(Memory data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private AddTransformedCharacterToScopeExtended(Memory data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (ushort)data.Length; + this.CharacterCount = 1; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC2; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0x45; + + /// + /// Gets the header of this packet. + /// + public C2Header Header => new (this._data); + + /// + /// Gets or sets the number of characters in this packet. This packet contains only one character, because the size of the appearance data depends on the used appearance serializer. + /// + public byte CharacterCount + { + get => this._data.Span[4]; + set => this._data.Span[4] = value; + } + + /// + /// Gets or sets the id. + /// + public ushort Id + { + get => ReadUInt16BigEndian(this._data.Span[5..]); + set => WriteUInt16BigEndian(this._data.Span[5..], value); + } + + /// + /// Gets or sets the current position x. + /// + public byte CurrentPositionX + { + get => this._data.Span[7]; + set => this._data.Span[7] = value; + } + + /// + /// Gets or sets the current position y. + /// + public byte CurrentPositionY + { + get => this._data.Span[8]; + set => this._data.Span[8] = value; + } + + /// + /// Gets or sets the skin. + /// + public ushort Skin + { + get => ReadUInt16BigEndian(this._data.Span[9..]); + set => WriteUInt16BigEndian(this._data.Span[9..], value); + } + + /// + /// Gets or sets the name. + /// + public string Name + { + get => this._data.Span.ExtractString(11, 10, System.Text.Encoding.UTF8); + set => this._data.Slice(11, 10).Span.WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets or sets the target position x. + /// + public byte TargetPositionX + { + get => this._data.Span[21]; + set => this._data.Span[21] = value; + } + + /// + /// Gets or sets the target position y. + /// + public byte TargetPositionY + { + get => this._data.Span[22]; + set => this._data.Span[22] = value; + } + + /// + /// Gets or sets the rotation. + /// + public byte Rotation + { + get => this._data.Span[23..].GetByteValue(4, 4); + set => this._data.Span[23..].SetByteValue(value, 4, 4); + } + + /// + /// Gets or sets the hero state. + /// + public CharacterHeroState HeroState + { + get => (CharacterHeroState)this._data.Span[23..].GetByteValue(4, 0); + set => this._data.Span[23..].SetByteValue((byte)value, 4, 0); + } + + /// + /// Gets or sets the appearance data, followed by the number of effects and the effect ids. + /// + public Span AppearanceAndEffects + { + get => this._data.Slice(24).Span; + } + + /// + /// Performs an implicit conversion from a Memory of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator AddTransformedCharacterToScopeExtended(Memory packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Memory of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Memory(AddTransformedCharacterToScopeExtended packet) => packet._data; + + /// + /// Calculates the size of the packet for the specified length of . + /// + /// The length in bytes of on which the required size depends. + + public static int GetRequiredSize(int appearanceAndEffectsLength) => appearanceAndEffectsLength + 24; +} + + /// /// Is sent by the server when: The server wants to alter the terrain attributes of a map at runtime. /// Causes reaction on client side: The client updates the terrain attributes on its side. diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml index f52c483db..c3361acfe 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml @@ -4817,6 +4817,80 @@ + + C2Header + 45 + AddTransformedCharacterToScopeExtended + ServerToClient + The player wears a monster transformation ring (extended client). + The character appears as monster, defined by the Skin property. + + + 4 + Byte + CharacterCount + The number of characters in this packet. This packet contains only one character, because the size of the appearance data depends on the used appearance serializer. + 1 + + + 5 + ShortBigEndian + Id + + + 7 + Byte + CurrentPositionX + + + 8 + Byte + CurrentPositionY + + + 9 + ShortBigEndian + Skin + + + 11 + String + Name + 10 + + + 21 + Byte + TargetPositionX + + + 22 + Byte + TargetPositionY + + + 23 + 4 + Byte + Rotation + 4 + + + 23 + 0 + Enum + CharacterHeroState + HeroState + 4 + + + 24 + Binary + AppearanceAndEffects + The appearance data, followed by the number of effects and the effect ids. + + + C1Header 46 diff --git a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs index 741cf448c..7756e4137 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs @@ -12090,6 +12090,177 @@ public byte Id } +/// +/// Is sent by the server when: The player wears a monster transformation ring (extended client). +/// Causes reaction on client side: The character appears as monster, defined by the Skin property. +/// +public readonly ref struct AddTransformedCharacterToScopeExtendedRef +{ + private readonly Span _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public AddTransformedCharacterToScopeExtendedRef(Span data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private AddTransformedCharacterToScopeExtendedRef(Span data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (ushort)data.Length; + this.CharacterCount = 1; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC2; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0x45; + + /// + /// Gets the header of this packet. + /// + public C2HeaderRef Header => new (this._data); + + /// + /// Gets or sets the number of characters in this packet. This packet contains only one character, because the size of the appearance data depends on the used appearance serializer. + /// + public byte CharacterCount + { + get => this._data[4]; + set => this._data[4] = value; + } + + /// + /// Gets or sets the id. + /// + public ushort Id + { + get => ReadUInt16BigEndian(this._data[5..]); + set => WriteUInt16BigEndian(this._data[5..], value); + } + + /// + /// Gets or sets the current position x. + /// + public byte CurrentPositionX + { + get => this._data[7]; + set => this._data[7] = value; + } + + /// + /// Gets or sets the current position y. + /// + public byte CurrentPositionY + { + get => this._data[8]; + set => this._data[8] = value; + } + + /// + /// Gets or sets the skin. + /// + public ushort Skin + { + get => ReadUInt16BigEndian(this._data[9..]); + set => WriteUInt16BigEndian(this._data[9..], value); + } + + /// + /// Gets or sets the name. + /// + public string Name + { + get => this._data.ExtractString(11, 10, System.Text.Encoding.UTF8); + set => this._data.Slice(11, 10).WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets or sets the target position x. + /// + public byte TargetPositionX + { + get => this._data[21]; + set => this._data[21] = value; + } + + /// + /// Gets or sets the target position y. + /// + public byte TargetPositionY + { + get => this._data[22]; + set => this._data[22] = value; + } + + /// + /// Gets or sets the rotation. + /// + public byte Rotation + { + get => this._data[23..].GetByteValue(4, 4); + set => this._data[23..].SetByteValue(value, 4, 4); + } + + /// + /// Gets or sets the hero state. + /// + public CharacterHeroState HeroState + { + get => (CharacterHeroState)this._data[23..].GetByteValue(4, 0); + set => this._data[23..].SetByteValue((byte)value, 4, 0); + } + + /// + /// Gets or sets the appearance data, followed by the number of effects and the effect ids. + /// + public Span AppearanceAndEffects + { + get => this._data.Slice(24); + } + + /// + /// Performs an implicit conversion from a Span of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator AddTransformedCharacterToScopeExtendedRef(Span packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Span of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Span(AddTransformedCharacterToScopeExtendedRef packet) => packet._data; + + /// + /// Calculates the size of the packet for the specified length of . + /// + /// The length in bytes of on which the required size depends. + + public static int GetRequiredSize(int appearanceAndEffectsLength) => appearanceAndEffectsLength + 24; +} + + /// /// Is sent by the server when: The server wants to alter the terrain attributes of a map at runtime. /// Causes reaction on client side: The client updates the terrain attributes on its side. diff --git a/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs b/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs index 13f7f7853..77fbbbc55 100644 --- a/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs +++ b/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs @@ -2466,6 +2466,22 @@ public void AddTransformedCharactersToScope_PacketSizeValidation() "Field 'CharacterCount' has invalid negative index"); } + /// + /// Tests the packet size calculation for AddTransformedCharacterToScopeExtended. + /// + [Test] + public void AddTransformedCharacterToScopeExtended_PacketSizeValidation() + { + // Variable-length packet validation + // Test GetRequiredSize method with sample data + const int testBinaryLength = 10; + var calculatedSize = AddTransformedCharacterToScopeExtendedRef.GetRequiredSize(testBinaryLength); + var expectedMinSize = testBinaryLength + 24; + + Assert.That(calculatedSize, Is.GreaterThanOrEqualTo(expectedMinSize), + "GetRequiredSize calculation incorrect for binary field"); + } + /// /// Tests the packet size calculation for ChangeTerrainAttributes. /// diff --git a/tests/MUnique.OpenMU.Tests/NewPlayersInScopeRemoteViewTests.cs b/tests/MUnique.OpenMU.Tests/NewPlayersInScopeRemoteViewTests.cs new file mode 100644 index 000000000..4123c4bf2 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/NewPlayersInScopeRemoteViewTests.cs @@ -0,0 +1,191 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using System.Buffers.Binary; +using System.IO; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using MUnique.OpenMU.AttributeSystem; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.GameServer; +using MUnique.OpenMU.GameServer.RemoteView; +using MUnique.OpenMU.GameServer.RemoteView.World; +using MUnique.OpenMU.Network.Packets.ServerToClient; +using MUnique.OpenMU.Network.PlugIns; +using MUnique.OpenMU.Pathfinding; +using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.PlugIns; + +/// +/// Tests the packets which are sent when players come into the scope of a player. +/// +[TestFixture] +public class NewPlayersInScopeRemoteViewTests +{ + private const ushort EliteSkeletonSkin = 372; + + /// + /// The offset of the skin of the first character in . + /// + private const int SkinOffsetOfFirstCharacter = 9; + + private static ClientVersion ExtendedClient { get; } = new(106, 3, ClientLanguage.Invariant); + + /// + /// A transformed player is sent to a client with extended appearance data in the extended layout of 0x45: + /// the appearance data of the extended serializer, followed by the effects. + /// + [Test] + public async ValueTask TransformedPlayerIsSentWithExtendedAppearanceAsync() + { + var (observer, output) = CreateObserver(ExtendedClient); + var added = await CreateTransformedPlayerAsync().ConfigureAwait(false); + + await new NewPlayersInScopeExtendedPlugIn(observer).NewPlayersInScopeAsync([added]).ConfigureAwait(false); + + var packets = SplitPackets(output.ToArray()); + Assert.That(packets, Has.Count.EqualTo(1)); + AddTransformedCharacterToScopeExtended packet = packets[0].AsMemory(); + var serializer = observer.AppearanceSerializer; + var expectedAppearance = new byte[serializer.NeededSpace]; + serializer.WriteAppearanceData(expectedAppearance, added.AppearanceData, true); + Assert.Multiple(() => + { + Assert.That(serializer, Is.InstanceOf()); + Assert.That(packet.Header.Type, Is.EqualTo(0xC2)); + Assert.That(packet.Header.Code, Is.EqualTo(0x45)); + Assert.That(packet.Header.Length, Is.EqualTo(packets[0].Length)); + Assert.That(packet.CharacterCount, Is.EqualTo(1)); + Assert.That(packet.Id & 0x7FFF, Is.EqualTo(added.GetId(observer))); + Assert.That(packet.Skin, Is.EqualTo(EliteSkeletonSkin)); + Assert.That(packet.Name, Is.EqualTo(added.SelectedCharacter!.Name)); + Assert.That(packet.AppearanceAndEffects[..serializer.NeededSpace].ToArray(), Is.EqualTo(expectedAppearance)); + Assert.That(packet.AppearanceAndEffects[serializer.NeededSpace], Is.EqualTo(0), "effect count"); + Assert.That(packet.AppearanceAndEffects.Length, Is.EqualTo(serializer.NeededSpace + 1)); + }); + } + + /// + /// The bytes of a transformed player in the extended layout of 0x45 are at the offsets where the + /// client reads them (PCREATE_TRANSFORM_EXTENDED), independent of the generated packet accessors. + /// + /// If set to true, the player is sent as spawned. + [TestCase(true)] + [TestCase(false)] + public async ValueTask TransformedPlayerBytesMatchTheClientLayoutAsync(bool isSpawned) + { + var (observer, output) = CreateObserver(ExtendedClient); + var added = await CreateTransformedPlayerAsync().ConfigureAwait(false); + added.Id = 0x1234; + added.Position = new Point(130, 140); + added.Rotation = Direction.South; + await added.MagicEffectList.AddEffectAsync(new MagicEffect(TimeSpan.FromMinutes(1), new MagicEffectDefinition { Number = 5, InformObservers = true })).ConfigureAwait(false); + await added.MagicEffectList.AddEffectAsync(new MagicEffect(TimeSpan.FromMinutes(1), new MagicEffectDefinition { Number = 9, InformObservers = true })).ConfigureAwait(false); + + await new NewPlayersInScopeExtendedPlugIn(observer).NewPlayersInScopeAsync([added], isSpawned).ConfigureAwait(false); + + var packet = SplitPackets(output.ToArray()).Single(); + var id = added.GetId(observer); + var effects = packet[52..].OrderBy(e => e).ToArray(); + Assert.Multiple(() => + { + Assert.That(packet, Has.Length.EqualTo(52 + 2), "5 header bytes, 47 fixed entry bytes and 2 effects"); + Assert.That(packet[..5], Is.EqualTo(new byte[] { 0xC2, 0, 54, 0x45, 1 })); + Assert.That(packet[5], Is.EqualTo((byte)((id >> 8) | (isSpawned ? 0x80 : 0))), "KeyH"); + Assert.That(packet[6], Is.EqualTo((byte)id), "KeyL"); + Assert.That(packet[7..9], Is.EqualTo(new byte[] { 130, 140 }), "position"); + Assert.That(packet[9..11], Is.EqualTo(new byte[] { EliteSkeletonSkin >> 8, EliteSkeletonSkin & 0xFF }), "TypeH, TypeL"); + Assert.That(packet[11..21], Is.EqualTo("Skeleton\0\0"u8.ToArray()), "name"); + Assert.That(packet[21..23], Is.EqualTo(new byte[] { 130, 140 }), "target"); + Assert.That(packet[23] >> 4, Is.EqualTo(Direction.South.ToPacketByte()), "rotation"); + Assert.That(packet[51], Is.EqualTo(2), "effect count"); + Assert.That(effects, Is.EqualTo(new byte[] { 5, 9 }), "effects"); + }); + } + + /// + /// A transformed player doesn't prevent the players after it from being sent. + /// + [Test] + public async ValueTask PlayersAfterTransformedPlayerAreSentAsync() + { + var (observer, output) = CreateObserver(ExtendedClient); + var transformed = await CreateTransformedPlayerAsync().ConfigureAwait(false); + var normal = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + normal.SelectedCharacter!.Name = "Normal"; + + await new NewPlayersInScopeExtendedPlugIn(observer).NewPlayersInScopeAsync([transformed, normal]).ConfigureAwait(false); + + var packets = SplitPackets(output.ToArray()); + Assert.That(packets.Select(p => p[3]), Is.EqualTo(new byte[] { AddTransformedCharacterToScopeExtendedRef.Code, AddCharacterToScopeExtendedRef.Code })); + } + + /// + /// A transformed player is still sent to a client with the standard appearance data in the standard layout of 0x45. + /// + [Test] + public async ValueTask TransformedPlayerIsSentWithStandardAppearanceAsync() + { + var (observer, output) = CreateObserver(new ClientVersion(6, 3, ClientLanguage.Invariant)); + var added = await CreateTransformedPlayerAsync().ConfigureAwait(false); + + await new NewPlayersInScopePlugIn(observer).NewPlayersInScopeAsync([added]).ConfigureAwait(false); + + var packets = SplitPackets(output.ToArray()); + Assert.That(packets, Has.Count.EqualTo(1)); + AddTransformedCharactersToScope packet = packets[0].AsMemory(); + Assert.Multiple(() => + { + Assert.That(observer.AppearanceSerializer, Is.InstanceOf()); + Assert.That(packet.Header.Code, Is.EqualTo(0x45)); + Assert.That(packet.CharacterCount, Is.EqualTo(1)); + Assert.That(BinaryPrimitives.ReadUInt16BigEndian(packets[0].AsSpan(SkinOffsetOfFirstCharacter)), Is.EqualTo(EliteSkeletonSkin)); + }); + } + + private static async ValueTask CreateTransformedPlayerAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + player.SelectedCharacter!.Name = "Skeleton"; + player.Attributes!.GetOrCreateAttribute(Stats.TransformationSkin); + player.Attributes.GetComposableAttribute(Stats.TransformationSkin)!.AddElement(new SimpleElement(EliteSkeletonSkin, AggregateType.AddRaw)); + return player; + } + + private static (RemotePlayer Player, MemoryStream Output) CreateObserver(ClientVersion clientVersion) + { + var manager = new PlugInManager(null, new NullLoggerFactory(), null, null); + manager.RegisterPlugIn(); + manager.RegisterPlugIn(); + var gameContext = new Mock(); + gameContext.Setup(c => c.PersistenceContextProvider).Returns(new Mock().Object); + gameContext.Setup(c => c.Configuration).Returns(new GameConfiguration()); + gameContext.Setup(c => c.PlugInManager).Returns(manager); + gameContext.Setup(c => c.LoggerFactory).Returns(new NullLoggerFactory()); + var (player, output) = CastleSiegeRemoteViewTestHelper.CreatePlayer(gameContext.Object); + player.ClientVersion = clientVersion; + return (player, output); + } + + private static List SplitPackets(byte[] data) + { + var result = new List(); + var offset = 0; + while (offset < data.Length) + { + var length = data[offset] is 0xC1 or 0xC3 + ? data[offset + 1] + : (data[offset + 1] << 8) | data[offset + 2]; + result.Add(data[offset..(offset + length)]); + offset += length; + } + + return result; + } +}