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;
+ }
+}