fix(net): conform character entry and session shutdown
This commit is contained in:
parent
bacc7e45a9
commit
aea957f845
12 changed files with 1016 additions and 95 deletions
|
|
@ -347,7 +347,10 @@ public class LiveHandshakeTests
|
|||
|
||||
Assert.NotNull(charList);
|
||||
Assert.NotEmpty(charList!.Characters);
|
||||
var chosen = charList.Characters[0];
|
||||
Assert.True(
|
||||
CharacterList.TrySelectFirstAvailable(charList, out var selection),
|
||||
"CharacterList contains no active, non-greyed character");
|
||||
var chosen = selection.Character;
|
||||
Console.WriteLine($"[live] choosing character: 0x{chosen.Id:X8} {chosen.Name}");
|
||||
|
||||
// ---- Step 5: send CharacterEnterWorldRequest (UIQueue, encrypted checksum) ----
|
||||
|
|
@ -403,7 +406,7 @@ public class LiveHandshakeTests
|
|||
|
||||
// ---- Step 7: send CharacterEnterWorld with the chosen GUID. ----
|
||||
SendGameMessage(
|
||||
CharacterEnterWorld.BuildEnterWorldBody(chosen.Id, user),
|
||||
CharacterEnterWorld.BuildEnterWorldBody(chosen.Id, charList.AccountName),
|
||||
$"CharacterEnterWorld(guid=0x{chosen.Id:X8})");
|
||||
|
||||
// ---- Step 8: receive the CreateObject flood + parse bodies. ----
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Net.Packets;
|
||||
|
||||
|
|
@ -41,6 +42,67 @@ public class CharacterEnterWorldTests
|
|||
Assert.Equal(0, body[pos++]);
|
||||
Assert.Equal(4 + 4 + 16, body.Length); // opcode + guid + padded string
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionSelection_UsesCanonicalCharacterListAccountInF657()
|
||||
{
|
||||
CharacterList.Parsed characters = MakeCharacters(
|
||||
[new CharacterList.Character(0x50000001u, "Ready", 0)],
|
||||
accountName: "CanonicalCase");
|
||||
|
||||
WorldSession.EnterWorldSelection selection =
|
||||
WorldSession.SelectCharacterForEnterWorld(characters, 0);
|
||||
|
||||
ReadOnlySpan<byte> body = selection.EnterWorldBody;
|
||||
Assert.Equal(CharacterEnterWorld.EnterWorldOpcode,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body));
|
||||
Assert.Equal(0x50000001u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body[4..]));
|
||||
ushort length = BinaryPrimitives.ReadUInt16LittleEndian(body[8..]);
|
||||
Assert.Equal("CanonicalCase",
|
||||
System.Text.Encoding.ASCII.GetString(body.Slice(10, length)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0u, 0u)]
|
||||
[InlineData(0x50000001u, 1u)]
|
||||
[InlineData(0x50000001u, 30u)]
|
||||
public void SessionSelection_RejectsUnavailableActiveIdentity(
|
||||
uint guid,
|
||||
uint secondsGreyedOut)
|
||||
{
|
||||
CharacterList.Parsed characters = MakeCharacters(
|
||||
[new CharacterList.Character(guid, "Unavailable", secondsGreyedOut)],
|
||||
accountName: "Account");
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
WorldSession.SelectCharacterForEnterWorld(characters, 0));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1)]
|
||||
[InlineData(1)]
|
||||
public void SessionSelection_RejectsIndexOutsideActiveCollection(int index)
|
||||
{
|
||||
CharacterList.Parsed characters = MakeCharacters(
|
||||
[new CharacterList.Character(0x50000001u, "Ready", 0)],
|
||||
accountName: "Account");
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
WorldSession.SelectCharacterForEnterWorld(characters, index));
|
||||
}
|
||||
|
||||
private static CharacterList.Parsed MakeCharacters(
|
||||
IReadOnlyList<CharacterList.Character> active,
|
||||
string accountName) =>
|
||||
new(
|
||||
Status: 0,
|
||||
Characters: active,
|
||||
DeletedCharacters: [],
|
||||
SlotCount: 11,
|
||||
AccountName: accountName,
|
||||
UseTurbineChat: true,
|
||||
HasThroneOfDestiny: true);
|
||||
}
|
||||
|
||||
public class GameMessageFragmentTests
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ public class CharacterListTests
|
|||
[Fact]
|
||||
public void Parse_SingleCharacter_ExtractsGuidAndName()
|
||||
{
|
||||
// Hand-assemble a CharacterList body matching ACE's writer format:
|
||||
// u32 opcode, u32 0, u32 count=1,
|
||||
// u32 guid, String16L name, u32 deleteDelta,
|
||||
// u32 0, u32 slotCount, String16L accountName,
|
||||
// Hand-assemble retail CharacterSet wire layout as emitted by ACE:
|
||||
// u32 opcode, u32 status=0, u32 activeCount=1,
|
||||
// u32 guid, String16L name, u32 secondsGreyedOut,
|
||||
// u32 deletedCount=0, i32 numAllowed, String16L accountName,
|
||||
// u32 useTurbineChat, u32 hasToD
|
||||
var w = new PacketWriter(128);
|
||||
w.WriteUInt32(CharacterList.Opcode);
|
||||
|
|
@ -29,11 +29,13 @@ public class CharacterListTests
|
|||
|
||||
var parsed = CharacterList.Parse(w.ToArray());
|
||||
|
||||
Assert.Equal(0u, parsed.Status);
|
||||
Assert.Single(parsed.Characters);
|
||||
Assert.Empty(parsed.DeletedCharacters);
|
||||
Assert.Equal(0x50000001u, parsed.Characters[0].Id);
|
||||
Assert.Equal("+Acdream", parsed.Characters[0].Name);
|
||||
Assert.Equal(0u, parsed.Characters[0].DeleteTimeDelta);
|
||||
Assert.Equal(11u, parsed.SlotCount);
|
||||
Assert.Equal(0u, parsed.Characters[0].SecondsGreyedOut);
|
||||
Assert.Equal(11, parsed.SlotCount);
|
||||
Assert.Equal("testaccount", parsed.AccountName);
|
||||
Assert.True(parsed.UseTurbineChat);
|
||||
Assert.True(parsed.HasThroneOfDestiny);
|
||||
|
|
@ -69,6 +71,152 @@ public class CharacterListTests
|
|||
Assert.Equal("Carol", parsed.Characters[2].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_NonzeroStatusAndDeletedCollection_PreservesRetailLayout()
|
||||
{
|
||||
var w = new PacketWriter(160);
|
||||
w.WriteUInt32(CharacterList.Opcode);
|
||||
w.WriteUInt32(7); // status
|
||||
w.WriteUInt32(1); // active count
|
||||
WriteIdentity(w, 0x50000001u, "Active", 0);
|
||||
w.WriteUInt32(2); // deleted count, not padding
|
||||
WriteIdentity(w, 0x50000002u, "Deleted One", 90);
|
||||
WriteIdentity(w, 0x50000003u, "Deleted Two", 180);
|
||||
WriteTail(w, 13, "CanonicalAccount", useTurbineChat: true, hasTod: false);
|
||||
|
||||
CharacterList.Parsed parsed = CharacterList.Parse(w.ToArray());
|
||||
|
||||
Assert.Equal(7u, parsed.Status);
|
||||
Assert.Single(parsed.Characters);
|
||||
Assert.Equal("Active", parsed.Characters[0].Name);
|
||||
Assert.Collection(
|
||||
parsed.DeletedCharacters,
|
||||
character =>
|
||||
{
|
||||
Assert.Equal(0x50000002u, character.Id);
|
||||
Assert.Equal("Deleted One", character.Name);
|
||||
Assert.Equal(90u, character.SecondsGreyedOut);
|
||||
},
|
||||
character =>
|
||||
{
|
||||
Assert.Equal(0x50000003u, character.Id);
|
||||
Assert.Equal("Deleted Two", character.Name);
|
||||
Assert.Equal(180u, character.SecondsGreyedOut);
|
||||
});
|
||||
Assert.Equal(13, parsed.SlotCount);
|
||||
Assert.Equal("CanonicalAccount", parsed.AccountName);
|
||||
Assert.True(parsed.UseTurbineChat);
|
||||
Assert.False(parsed.HasThroneOfDestiny);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrySelectFirstAvailable_SkipsZeroGuidAndGreyedActiveCharacters()
|
||||
{
|
||||
CharacterList.Parsed parsed = BuildParsed(
|
||||
[
|
||||
new CharacterList.Character(0, "Empty", 0),
|
||||
new CharacterList.Character(0x50000001u, "Deleting", 30),
|
||||
new CharacterList.Character(0x50000002u, "Ready", 0),
|
||||
],
|
||||
[new CharacterList.Character(0x50000003u, "Deleted", 0)]);
|
||||
|
||||
bool found = CharacterList.TrySelectFirstAvailable(parsed, out var selection);
|
||||
|
||||
Assert.True(found);
|
||||
Assert.Equal(2, selection.ActiveIndex);
|
||||
Assert.Equal(0x50000002u, selection.Character.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrySelectFirstAvailable_RejectsAllGreyedAndNeverReadsDeletedCollection()
|
||||
{
|
||||
CharacterList.Parsed parsed = BuildParsed(
|
||||
[new CharacterList.Character(0x50000001u, "Deleting", 1)],
|
||||
[new CharacterList.Character(0x50000002u, "Deleted Ready", 0)]);
|
||||
|
||||
Assert.False(CharacterList.TrySelectFirstAvailable(parsed, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_EmptyCollectionsAndNegativeAllowedSentinel_ArePreserved()
|
||||
{
|
||||
var w = new PacketWriter(64);
|
||||
w.WriteUInt32(CharacterList.Opcode);
|
||||
w.WriteUInt32(0);
|
||||
w.WriteUInt32(0); // active count
|
||||
w.WriteUInt32(0); // deleted count
|
||||
w.WriteUInt32(uint.MaxValue); // retail signed -1 sentinel
|
||||
w.WriteString16L("Account");
|
||||
w.WriteUInt32(2); // every nonzero retail int is true
|
||||
w.WriteUInt32(uint.MaxValue);
|
||||
|
||||
CharacterList.Parsed parsed = CharacterList.Parse(w.ToArray());
|
||||
|
||||
Assert.Empty(parsed.Characters);
|
||||
Assert.Empty(parsed.DeletedCharacters);
|
||||
Assert.Equal(-1, parsed.SlotCount);
|
||||
Assert.True(parsed.UseTurbineChat);
|
||||
Assert.True(parsed.HasThroneOfDestiny);
|
||||
Assert.False(CharacterList.TrySelectFirstAvailable(parsed, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_CountThatCannotFitRemainingPayload_IsRejected()
|
||||
{
|
||||
var active = new PacketWriter(16);
|
||||
active.WriteUInt32(CharacterList.Opcode);
|
||||
active.WriteUInt32(0);
|
||||
active.WriteUInt32(256);
|
||||
Assert.Throws<FormatException>(() => CharacterList.Parse(active.ToArray()));
|
||||
|
||||
var deleted = new PacketWriter(20);
|
||||
deleted.WriteUInt32(CharacterList.Opcode);
|
||||
deleted.WriteUInt32(0);
|
||||
deleted.WriteUInt32(0);
|
||||
deleted.WriteUInt32(256);
|
||||
Assert.Throws<FormatException>(() => CharacterList.Parse(deleted.ToArray()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_255MinimumSizeIdentities_UsesPayloadBoundNotArbitraryCap()
|
||||
{
|
||||
var w = new PacketWriter(4096);
|
||||
w.WriteUInt32(CharacterList.Opcode);
|
||||
w.WriteUInt32(0);
|
||||
w.WriteUInt32(255);
|
||||
for (uint i = 1; i <= 255; i++)
|
||||
WriteIdentity(w, i, string.Empty, 0);
|
||||
w.WriteUInt32(0);
|
||||
WriteTail(w, 255, "Account", useTurbineChat: true, hasTod: true);
|
||||
|
||||
CharacterList.Parsed parsed = CharacterList.Parse(w.ToArray());
|
||||
|
||||
Assert.Equal(255, parsed.Characters.Count);
|
||||
Assert.Equal(255, parsed.SlotCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_EveryTruncatedPrefixOfRetailPayload_Throws()
|
||||
{
|
||||
var w = new PacketWriter(160);
|
||||
w.WriteUInt32(CharacterList.Opcode);
|
||||
w.WriteUInt32(3);
|
||||
w.WriteUInt32(1);
|
||||
WriteIdentity(w, 0x50000001u, "A", 0);
|
||||
w.WriteUInt32(1);
|
||||
WriteIdentity(w, 0x50000002u, "Deleted", 22);
|
||||
WriteTail(w, 11, "Account", useTurbineChat: true, hasTod: true);
|
||||
byte[] valid = w.ToArray();
|
||||
|
||||
for (int length = 0; length < valid.Length; length++)
|
||||
{
|
||||
byte[] prefix = valid.AsSpan(0, length).ToArray();
|
||||
Assert.Throws<FormatException>(() => CharacterList.Parse(prefix));
|
||||
}
|
||||
|
||||
_ = CharacterList.Parse(valid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WrongOpcode_Throws()
|
||||
{
|
||||
|
|
@ -84,4 +232,40 @@ public class CharacterListTests
|
|||
BinaryPrimitives.WriteUInt32LittleEndian(bytes, CharacterList.Opcode);
|
||||
Assert.Throws<FormatException>(() => CharacterList.Parse(bytes));
|
||||
}
|
||||
|
||||
private static void WriteIdentity(
|
||||
PacketWriter writer,
|
||||
uint id,
|
||||
string name,
|
||||
uint secondsGreyedOut)
|
||||
{
|
||||
writer.WriteUInt32(id);
|
||||
writer.WriteString16L(name);
|
||||
writer.WriteUInt32(secondsGreyedOut);
|
||||
}
|
||||
|
||||
private static void WriteTail(
|
||||
PacketWriter writer,
|
||||
int slotCount,
|
||||
string accountName,
|
||||
bool useTurbineChat,
|
||||
bool hasTod)
|
||||
{
|
||||
writer.WriteUInt32(unchecked((uint)slotCount));
|
||||
writer.WriteString16L(accountName);
|
||||
writer.WriteUInt32(useTurbineChat ? 1u : 0u);
|
||||
writer.WriteUInt32(hasTod ? 1u : 0u);
|
||||
}
|
||||
|
||||
private static CharacterList.Parsed BuildParsed(
|
||||
IReadOnlyList<CharacterList.Character> active,
|
||||
IReadOnlyList<CharacterList.Character> deleted) =>
|
||||
new(
|
||||
Status: 0,
|
||||
Characters: active,
|
||||
DeletedCharacters: deleted,
|
||||
SlotCount: 11,
|
||||
AccountName: "Account",
|
||||
UseTurbineChat: true,
|
||||
HasThroneOfDestiny: true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,4 +33,12 @@ public sealed class CharacterLogOffTests
|
|||
Assert.False(CharacterLogOff.IsConfirmation([0x53, 0xF6, 0x00]));
|
||||
Assert.False(CharacterLogOff.IsConfirmation(BitConverter.GetBytes(0xF654u)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsConfirmation_RejectsRequestShapedBodyWithCharacterId()
|
||||
{
|
||||
byte[] request = CharacterLogOff.BuildRequestBody(0x5000000Au);
|
||||
|
||||
Assert.False(CharacterLogOff.IsConfirmation(request));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using AcDream.Core.Net.Cryptography;
|
||||
using AcDream.Core.Net.Packets;
|
||||
|
||||
namespace AcDream.Core.Net.Tests;
|
||||
|
||||
public sealed class WorldSessionNegotiationShutdownTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ThrowingServerTimeSubscriber_StillDisposesWithNegotiatedDisconnect()
|
||||
{
|
||||
using var server = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
var serverEndpoint = (IPEndPoint)server.Client.LocalEndPoint!;
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
Task<byte[]> serverFlow = Task.Run(async () =>
|
||||
{
|
||||
UdpReceiveResult login = await server.ReceiveAsync(timeout.Token);
|
||||
byte[] connectRequest = BuildConnectRequest(
|
||||
clientId: 0x1234,
|
||||
iteration: 7,
|
||||
serverTime: 12345.5);
|
||||
await server.SendAsync(connectRequest, login.RemoteEndPoint, timeout.Token);
|
||||
|
||||
UdpReceiveResult disconnect = await server.ReceiveAsync(timeout.Token);
|
||||
return disconnect.Buffer;
|
||||
}, timeout.Token);
|
||||
|
||||
using var session = new WorldSession(serverEndpoint);
|
||||
session.ServerTimeUpdated += _ =>
|
||||
throw new InvalidOperationException("subscriber failed");
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
session.Connect("typed-account", "password", TimeSpan.FromSeconds(3)));
|
||||
|
||||
session.Dispose();
|
||||
byte[] datagram = await serverFlow;
|
||||
PacketCodec.PacketDecodeResult decoded =
|
||||
PacketCodec.TryDecode(datagram, inboundIsaac: null);
|
||||
|
||||
Assert.True(decoded.IsOk, decoded.Error.ToString());
|
||||
Assert.True(decoded.Packet!.Header.HasFlag(PacketHeaderFlags.Disconnect));
|
||||
Assert.Equal((ushort)0x1234, decoded.Packet.Header.Id);
|
||||
Assert.Equal((ushort)7, decoded.Packet.Header.Iteration);
|
||||
Assert.Equal(WorldSession.State.Disconnected, session.CurrentState);
|
||||
}
|
||||
|
||||
private static byte[] BuildConnectRequest(
|
||||
ushort clientId,
|
||||
ushort iteration,
|
||||
double serverTime)
|
||||
{
|
||||
byte[] body = new byte[32];
|
||||
BinaryPrimitives.WriteInt64LittleEndian(
|
||||
body,
|
||||
BitConverter.DoubleToInt64Bits(serverTime));
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(8), 0xFEEDFACECAFEBABEUL);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), clientId);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), 0xAABBCCDDu);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), 0x01020304u);
|
||||
|
||||
var header = new PacketHeader
|
||||
{
|
||||
Flags = PacketHeaderFlags.ConnectRequest,
|
||||
DataSize = (ushort)body.Length,
|
||||
Iteration = iteration,
|
||||
};
|
||||
header.Checksum = header.CalculateHeaderHash32() + Hash32.Calculate(body);
|
||||
|
||||
byte[] datagram = new byte[PacketHeader.Size + body.Length];
|
||||
header.Pack(datagram);
|
||||
body.CopyTo(datagram.AsSpan(PacketHeader.Size));
|
||||
return datagram;
|
||||
}
|
||||
}
|
||||
226
tests/AcDream.Core.Net.Tests/WorldSessionShutdownTests.cs
Normal file
226
tests/AcDream.Core.Net.Tests/WorldSessionShutdownTests.cs
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Net.Packets;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace AcDream.Core.Net.Tests;
|
||||
|
||||
public sealed class WorldSessionShutdownTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(WorldSession.State.Disconnected)]
|
||||
[InlineData(WorldSession.State.Handshaking)]
|
||||
public void PreNegotiationShutdown_OnlyReleasesLocalTransport(
|
||||
WorldSession.State state)
|
||||
{
|
||||
WorldSession.SessionShutdownPlan plan = WorldSession.BuildShutdownPlan(
|
||||
state,
|
||||
transportNegotiated: false,
|
||||
activeCharacterId: 0);
|
||||
|
||||
Assert.False(plan.RequestCharacterLogOff);
|
||||
Assert.False(plan.SendTransportDisconnect);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(WorldSession.State.Handshaking)]
|
||||
[InlineData(WorldSession.State.InCharacterSelect)]
|
||||
[InlineData(WorldSession.State.EnteringWorld)]
|
||||
[InlineData(WorldSession.State.Failed)]
|
||||
[InlineData(WorldSession.State.Disconnected)]
|
||||
public void EveryNegotiatedNonWorldState_SendsTransportDisconnectOnly(
|
||||
WorldSession.State state)
|
||||
{
|
||||
WorldSession.SessionShutdownPlan plan = WorldSession.BuildShutdownPlan(
|
||||
state,
|
||||
transportNegotiated: true,
|
||||
activeCharacterId: 0x50000001u);
|
||||
|
||||
Assert.False(plan.RequestCharacterLogOff);
|
||||
Assert.True(plan.SendTransportDisconnect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegotiatedInWorldState_RequestsCharacterLogoffThenDisconnect()
|
||||
{
|
||||
WorldSession.SessionShutdownPlan plan = WorldSession.BuildShutdownPlan(
|
||||
WorldSession.State.InWorld,
|
||||
transportNegotiated: true,
|
||||
activeCharacterId: 0x50000001u);
|
||||
|
||||
Assert.True(plan.RequestCharacterLogOff);
|
||||
Assert.True(plan.SendTransportDisconnect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InWorldWithoutActiveCharacter_DoesNotSendMalformedF653Request()
|
||||
{
|
||||
WorldSession.SessionShutdownPlan plan = WorldSession.BuildShutdownPlan(
|
||||
WorldSession.State.InWorld,
|
||||
transportNegotiated: true,
|
||||
activeCharacterId: 0);
|
||||
|
||||
Assert.False(plan.RequestCharacterLogOff);
|
||||
Assert.True(plan.SendTransportDisconnect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImpossibleUnnegotiatedInWorldState_DoesNotAttemptWireMessages()
|
||||
{
|
||||
WorldSession.SessionShutdownPlan plan = WorldSession.BuildShutdownPlan(
|
||||
WorldSession.State.InWorld,
|
||||
transportNegotiated: false,
|
||||
activeCharacterId: 0x50000001u);
|
||||
|
||||
Assert.False(plan.RequestCharacterLogOff);
|
||||
Assert.False(plan.SendTransportDisconnect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecuteShutdownWire_SendsExactRequestWaitThenNegotiatedDisconnect()
|
||||
{
|
||||
var order = new List<string>();
|
||||
byte[]? request = null;
|
||||
byte[]? disconnect = null;
|
||||
WorldSession.SessionShutdownPlan plan = WorldSession.BuildShutdownPlan(
|
||||
WorldSession.State.InWorld,
|
||||
transportNegotiated: true,
|
||||
activeCharacterId: 0x5000000Au);
|
||||
|
||||
WorldSession.ShutdownExecutionResult result = WorldSession.ExecuteShutdownWire(
|
||||
plan,
|
||||
activeCharacterId: 0x5000000Au,
|
||||
sessionClientId: 0x1234,
|
||||
sessionIteration: 7,
|
||||
sendGameMessage: body =>
|
||||
{
|
||||
order.Add("F653");
|
||||
request = body;
|
||||
},
|
||||
waitForConfirmation: timeout =>
|
||||
{
|
||||
order.Add("wait");
|
||||
Assert.Equal(TimeSpan.FromSeconds(35), timeout);
|
||||
return true;
|
||||
},
|
||||
sendTransportDatagram: datagram =>
|
||||
{
|
||||
order.Add("disconnect");
|
||||
disconnect = datagram;
|
||||
},
|
||||
confirmationTimeout: TimeSpan.FromSeconds(35));
|
||||
|
||||
Assert.Equal(["F653", "wait", "disconnect"], order);
|
||||
Assert.Equal(CharacterLogOff.BuildRequestBody(0x5000000Au), request);
|
||||
PacketCodec.PacketDecodeResult decoded =
|
||||
PacketCodec.TryDecode(disconnect!, inboundIsaac: null);
|
||||
Assert.True(decoded.IsOk, decoded.Error.ToString());
|
||||
Assert.True(decoded.Packet!.Header.HasFlag(PacketHeaderFlags.Disconnect));
|
||||
Assert.Equal((ushort)0x1234, decoded.Packet.Header.Id);
|
||||
Assert.Equal((ushort)7, decoded.Packet.Header.Iteration);
|
||||
Assert.True(result.CharacterLogOffSent);
|
||||
Assert.True(result.ConfirmationReceived);
|
||||
Assert.True(result.TransportDisconnectSent);
|
||||
Assert.Null(result.CharacterLogOffError);
|
||||
Assert.Null(result.TransportDisconnectError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecuteShutdownWire_RequestFailureStillSendsDisconnect()
|
||||
{
|
||||
var order = new List<string>();
|
||||
WorldSession.SessionShutdownPlan plan = WorldSession.BuildShutdownPlan(
|
||||
WorldSession.State.InWorld,
|
||||
transportNegotiated: true,
|
||||
activeCharacterId: 0x50000001u);
|
||||
|
||||
WorldSession.ShutdownExecutionResult result = WorldSession.ExecuteShutdownWire(
|
||||
plan,
|
||||
activeCharacterId: 0x50000001u,
|
||||
sessionClientId: 1,
|
||||
sessionIteration: 2,
|
||||
sendGameMessage: _ =>
|
||||
{
|
||||
order.Add("F653");
|
||||
throw new IOException("send failed");
|
||||
},
|
||||
waitForConfirmation: _ =>
|
||||
{
|
||||
order.Add("wait");
|
||||
return true;
|
||||
},
|
||||
sendTransportDatagram: _ => order.Add("disconnect"),
|
||||
confirmationTimeout: TimeSpan.FromSeconds(1));
|
||||
|
||||
Assert.Equal(["F653", "disconnect"], order);
|
||||
Assert.False(result.CharacterLogOffSent);
|
||||
Assert.False(result.ConfirmationReceived);
|
||||
Assert.True(result.TransportDisconnectSent);
|
||||
Assert.IsType<IOException>(result.CharacterLogOffError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WaitForConfirmation_CompletedEmptyChannelReturnsWithoutSpinning()
|
||||
{
|
||||
Channel<byte[]> channel = Channel.CreateUnbounded<byte[]>();
|
||||
channel.Writer.TryComplete();
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
bool confirmed = WorldSession.WaitForCharacterLogOffConfirmation(
|
||||
channel.Reader,
|
||||
TimeSpan.FromSeconds(2),
|
||||
_ => false);
|
||||
|
||||
Assert.False(confirmed);
|
||||
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WaitForConfirmation_IgnoresMalformedBodiesUntilExactOpcodeOnlyBody()
|
||||
{
|
||||
Channel<byte[]> channel = Channel.CreateUnbounded<byte[]>();
|
||||
channel.Writer.TryWrite([0x53, 0xF6, 0x00]);
|
||||
channel.Writer.TryWrite(BitConverter.GetBytes(0xF654u));
|
||||
channel.Writer.TryWrite(CharacterLogOff.BuildRequestBody(0x50000001u));
|
||||
channel.Writer.TryWrite(BitConverter.GetBytes(CharacterLogOff.Opcode));
|
||||
channel.Writer.TryComplete();
|
||||
int processed = 0;
|
||||
|
||||
bool confirmed = WorldSession.WaitForCharacterLogOffConfirmation(
|
||||
channel.Reader,
|
||||
TimeSpan.FromSeconds(1),
|
||||
body =>
|
||||
{
|
||||
processed++;
|
||||
return CharacterLogOff.IsConfirmation(body);
|
||||
});
|
||||
|
||||
Assert.True(confirmed);
|
||||
Assert.Equal(4, processed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WaitForConfirmation_TimeoutWinsDuringContinuousUnrelatedDrain()
|
||||
{
|
||||
Channel<byte[]> channel = Channel.CreateUnbounded<byte[]>();
|
||||
for (int i = 0; i < 100; i++)
|
||||
channel.Writer.TryWrite(BitConverter.GetBytes(0xF654u));
|
||||
int processed = 0;
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
bool confirmed = WorldSession.WaitForCharacterLogOffConfirmation(
|
||||
channel.Reader,
|
||||
TimeSpan.FromMilliseconds(25),
|
||||
_ =>
|
||||
{
|
||||
processed++;
|
||||
Thread.Sleep(2);
|
||||
return false;
|
||||
});
|
||||
|
||||
Assert.False(confirmed);
|
||||
Assert.True(processed < 100);
|
||||
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(1));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue