using System.Buffers.Binary; using System.Net; using System.Reflection; using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; namespace AcDream.Core.Net.Tests; public sealed class WorldSessionCharacterSelectionTests { private sealed class NullTransport : IWorldSessionTransport { public void Send(ReadOnlySpan datagram) { } public void Send(IPEndPoint remote, ReadOnlySpan datagram) { } public int Receive( Span destination, TimeSpan timeout, out IPEndPoint? from) { from = null; return -1; } public ValueTask ReceiveAsync( Memory destination, CancellationToken cancellationToken) => ValueTask.FromCanceled(cancellationToken); public void Dispose() { } } [Fact] public void CharacterManagementSends_UseRetailQueuesAndExactBodies() { using var session = CreateSession(); var sent = new List<(byte[] Body, GameMessageGroup Queue)>(); session.GameMessageCapture = (body, queue) => sent.Add((body, queue)); session.SendDeleteCharacter("Canonical", activeIndex: 3); session.SendRestoreCharacter(0x50000001u); Assert.Collection( sent, delete => { Assert.Equal(GameMessageGroup.LoginQueue, delete.Queue); Assert.Equal( CharacterDelete.BuildRequestBody("Canonical", 3u), delete.Body); }, restore => { Assert.Equal(GameMessageGroup.ControlQueue, restore.Queue); Assert.Equal( CharacterRestore.BuildRequestBody(0x50000001u), restore.Body); }); } [Fact] public void UiQueueReplies_DispatchInWireOrderAndRosterRefreshReplacesCharacters() { using var session = CreateSession(); var events = new List(); session.CharacterListReceived += roster => events.Add($"roster:{roster.Characters[0].SecondsGreyedOut}"); session.CharacterDeleteAcknowledged += () => events.Add("delete"); session.CharacterRestoreReceived += restore => events.Add($"restore:{restore.Guid:X8}"); session.CharacterErrorReceived += error => events.Add($"error:{error.RawErrorCode:X}"); byte[] packet = BuildPacket( BuildRoster(secondsGreyedOut: 0u), BitConverter.GetBytes(CharacterDelete.Opcode), BuildRestoreResponse(), BuildCharacterError(CharacterError.Code.Delete), BuildRoster(secondsGreyedOut: 1u)); InvokeProcessDatagram(session, packet); Assert.Equal( [ "roster:0", "delete", "restore:50000001", "error:6", "roster:1", ], events); CharacterList.Character current = Assert.Single(session.Characters!.Characters); Assert.Equal(1u, current.SecondsGreyedOut); } private static WorldSession CreateSession() => new( new IPEndPoint(IPAddress.Loopback, 9000), new NullTransport()); private static byte[] BuildRoster(uint secondsGreyedOut) { var writer = new PacketWriter(96); writer.WriteUInt32(CharacterList.Opcode); writer.WriteUInt32(0u); writer.WriteUInt32(1u); writer.WriteUInt32(0x50000001u); writer.WriteString16L("Character"); writer.WriteUInt32(secondsGreyedOut); writer.WriteUInt32(0u); writer.WriteUInt32(11u); writer.WriteString16L("Canonical"); writer.WriteUInt32(1u); writer.WriteUInt32(1u); return writer.ToArray(); } private static byte[] BuildRestoreResponse() { var writer = new PacketWriter(64); writer.WriteUInt32(CharacterRestore.ResponseOpcode); writer.WriteUInt32(1u); writer.WriteUInt32(0x50000001u); writer.WriteString16L("Character"); writer.WriteUInt32(0u); return writer.ToArray(); } private static byte[] BuildCharacterError(CharacterError.Code error) { byte[] body = new byte[8]; BinaryPrimitives.WriteUInt32LittleEndian( body, CharacterError.Opcode); BinaryPrimitives.WriteUInt32LittleEndian( body.AsSpan(4), (uint)error); return body; } private static byte[] BuildPacket(params byte[][] messages) { int length = messages.Sum(message => MessageFragmentHeader.Size + message.Length); var fragments = new byte[length]; int position = 0; uint sequence = 1u; foreach (byte[] message in messages) { position += GameMessageFragment.WriteSingleFragment( fragments.AsSpan(position), sequence++, GameMessageGroup.UIQueue, message); } return PacketCodec.Encode( new PacketHeader { Sequence = 1u, Flags = PacketHeaderFlags.BlobFragments, }, fragments, outboundIsaac: null); } private static void InvokeProcessDatagram( WorldSession session, byte[] datagram) { MethodInfo method = typeof(WorldSession).GetMethod( "ProcessDatagram", BindingFlags.NonPublic | BindingFlags.Instance)!; method.Invoke( session, [new ReadOnlyMemory(datagram), null, true]); } }