using System.Net; using System.Reflection; using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; using AcDream.Core.Net.Tests.Messages; namespace AcDream.Core.Net.Tests; /// /// Campaign CC CC2: the awaiting-request latch that disambiguates the two /// message families sharing opcode 0xF643 (see /// 's doc comment) — create-then- /// response routes to the create event, a plain restore is unaffected, an /// unexpected/uncorrelated response is dropped rather than misattributed, /// and teardown clears the latch. See /// for the general /// character-management wire-order coverage this file complements. /// public sealed class WorldSessionCharacterCreationTests { 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() { } } private static WorldSession CreateSession() => new( new IPEndPoint(IPAddress.Loopback, 9000), new NullTransport()); private static CharacterCreate.Request MakeCreateRequest() => new( Heritage: 1u, Gender: 0u, Appearance: default, Template: 0u, Attributes: default, Slot: 0u, ClassId: 1u, Name: "NewChar", StartArea: 0u, IsAdmin: false, IsEnvoy: false); private static byte[] BuildVerificationResponseBody(uint code, uint guid, string name) => code == (uint)CharGenVerificationResponse.Code.Ok ? AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode) .Write(code) .WriteGuid(guid) .WriteString16L(name) .Write(0u) .ToArray() : AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode) .Write(code) .ToArray(); 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]); } private static PendingLatch ReadPendingLatch(WorldSession session) { FieldInfo field = typeof(WorldSession).GetField( "_pendingCharGenVerification", BindingFlags.NonPublic | BindingFlags.Instance)!; return (PendingLatch)field.GetValue(session)!; } // Mirrors WorldSession's private PendingCharGenVerificationRequest enum // by name/ordinal — read via reflection above so the test doesn't need // InternalsVisibleTo for a single private enum. private enum PendingLatch { None, Restore, Create } [Fact] public void SendCharacterCreation_ThenOkResponse_RoutesToCreateEventNotRestore() { using WorldSession session = CreateSession(); session.GameMessageCapture = (_, _) => { }; session.SendCharacterCreation( "testaccount", MakeCreateRequest(), new uint[CharacterCreate.SkillAdvancementClassCount]); var createEvents = new List(); var restoreEvents = new List(); session.CharacterCreateResponseReceived += createEvents.Add; session.CharacterRestoreReceived += restoreEvents.Add; byte[] packet = BuildPacket( BuildVerificationResponseBody( (uint)CharGenVerificationResponse.Code.Ok, 0x50000010u, "NewChar")); InvokeProcessDatagram(session, packet); CharGenVerificationResponse.Parsed created = Assert.Single(createEvents); Assert.True(created.IsOk); Assert.Equal(0x50000010u, created.Guid); Assert.Equal("NewChar", created.Name); Assert.Empty(restoreEvents); Assert.Equal(PendingLatch.None, ReadPendingLatch(session)); } [Fact] public void SendCharacterCreation_ThenFailureResponse_RoutesToCreateEventWithNullIdentity() { using WorldSession session = CreateSession(); session.GameMessageCapture = (_, _) => { }; session.SendCharacterCreation( "testaccount", MakeCreateRequest(), new uint[CharacterCreate.SkillAdvancementClassCount]); CharGenVerificationResponse.Parsed? created = null; session.CharacterCreateResponseReceived += parsed => created = parsed; byte[] packet = BuildPacket( BuildVerificationResponseBody( (uint)CharGenVerificationResponse.Code.NameInUse, guid: 0u, name: string.Empty)); InvokeProcessDatagram(session, packet); Assert.NotNull(created); Assert.Equal(CharGenVerificationResponse.Code.NameInUse, created!.Value.AsCode); Assert.False(created.Value.IsOk); Assert.Null(created.Value.Guid); } [Fact] public void SendRestoreCharacter_ThenResponse_StillRoutesToRestoreEvent() { // Regression guard: the correlation latch must not break the // pre-existing restore-only flow that predates Campaign CC. using WorldSession session = CreateSession(); session.GameMessageCapture = (_, _) => { }; session.SendRestoreCharacter(0x50000001u); var restoreEvents = new List(); var createEvents = new List(); session.CharacterRestoreReceived += restoreEvents.Add; session.CharacterCreateResponseReceived += createEvents.Add; byte[] packet = BuildPacket( BuildVerificationResponseBody( (uint)CharGenVerificationResponse.Code.Ok, 0x50000001u, "Restored")); InvokeProcessDatagram(session, packet); CharacterRestore.Parsed restored = Assert.Single(restoreEvents); Assert.Equal(0x50000001u, restored.Guid); Assert.Equal("Restored", restored.Name); Assert.Empty(createEvents); } /// /// CC2 review F1: pins the latch's stated scope EXACTLY. The latch /// correlates the single outstanding request and does NOT refuse /// overlap — a second send while one is outstanding OVERWRITES it, so /// the first request's reply is delivered to the second request's /// event. Refusing overlap is the caller's job (CC3's Runtime /// verification gate, mirroring retail's DoFinish UNDEF-state gate). /// If CC3 (or anyone) changes this transport-level behavior, this test /// must change WITH it, deliberately. /// [Fact] public void OverlappingSend_OverwritesTheLatch_ReplyRoutesToNewestRequest() { using WorldSession session = CreateSession(); session.GameMessageCapture = (_, _) => { }; session.SendRestoreCharacter(0x50000001u); session.SendCharacterCreation( "testaccount", MakeCreateRequest(), new uint[CharacterCreate.SkillAdvancementClassCount]); Assert.Equal(PendingLatch.Create, ReadPendingLatch(session)); var restoreEvents = new List(); var createEvents = new List(); session.CharacterRestoreReceived += restoreEvents.Add; session.CharacterCreateResponseReceived += createEvents.Add; // This reply is semantically the RESTORE's — but the overwritten // latch routes it to the create event. That is the documented // overwrite behavior, pinned here. byte[] packet = BuildPacket( BuildVerificationResponseBody( (uint)CharGenVerificationResponse.Code.Ok, 0x50000001u, "Restored")); InvokeProcessDatagram(session, packet); Assert.Empty(restoreEvents); Assert.Single(createEvents); Assert.Equal(PendingLatch.None, ReadPendingLatch(session)); } [Fact] public void ResponseWithNoOutstandingRequest_IsDroppedAndNeverMisattributed() { using WorldSession session = CreateSession(); var restoreEvents = new List(); var createEvents = new List(); session.CharacterRestoreReceived += restoreEvents.Add; session.CharacterCreateResponseReceived += createEvents.Add; // No SendRestoreCharacter / SendCharacterCreation call precedes this // — the latch is None. byte[] packet = BuildPacket( BuildVerificationResponseBody( (uint)CharGenVerificationResponse.Code.Ok, 0x50000099u, "Stray")); InvokeProcessDatagram(session, packet); Assert.Empty(restoreEvents); Assert.Empty(createEvents); Assert.Equal(PendingLatch.None, ReadPendingLatch(session)); } [Fact] public void SecondResponse_AfterFirstAlreadyConsumed_IsDroppedNotMisattributed() { // A create request is satisfied; a SECOND, uncorrelated 0xF643 // arriving afterward (e.g. a stray/replayed packet) must not be // misread as a reply to anything. using WorldSession session = CreateSession(); session.GameMessageCapture = (_, _) => { }; session.SendCharacterCreation( "testaccount", MakeCreateRequest(), new uint[CharacterCreate.SkillAdvancementClassCount]); var createEvents = new List(); session.CharacterCreateResponseReceived += createEvents.Add; byte[] first = BuildPacket( BuildVerificationResponseBody( (uint)CharGenVerificationResponse.Code.Ok, 0x50000010u, "NewChar")); InvokeProcessDatagram(session, first); Assert.Single(createEvents); byte[] second = BuildPacket( BuildVerificationResponseBody( (uint)CharGenVerificationResponse.Code.Ok, 0x50000011u, "Stray")); InvokeProcessDatagram(session, second); // Still exactly one — the second reply was dropped, not appended. Assert.Single(createEvents); } [Fact] public void Dispose_ClearsTheOutstandingLatch() { WorldSession session = CreateSession(); session.GameMessageCapture = (_, _) => { }; session.SendRestoreCharacter(0x50000001u); Assert.Equal(PendingLatch.Restore, ReadPendingLatch(session)); session.Dispose(); Assert.Equal(PendingLatch.None, ReadPendingLatch(session)); } [Fact] public void BuildRequestBody_InvalidSkillCount_DoesNotArmTheLatch() { // The latch is armed AFTER the body is built (SendCharacterCreation // builds first), so a builder-level throw (wrong skill count) must // leave no outstanding request behind — nothing was actually sent. using WorldSession session = CreateSession(); session.GameMessageCapture = (_, _) => { }; Assert.Throws(() => session.SendCharacterCreation( "testaccount", MakeCreateRequest(), new uint[10])); Assert.Equal(PendingLatch.None, ReadPendingLatch(session)); } }