using System.Buffers.Binary; using System.Net; using System.Reflection; using System.Text; using AcDream.Core.CharGen; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; using AcDream.Runtime; using AcDream.Runtime.Session; using AcDream.Runtime.Tests.CharGen; namespace AcDream.Runtime.Tests.Session; /// /// Campaign CC slice CC3: 's /// character-creation integration — the wire send (via a REAL /// + GameMessageCapture, the same seam /// WorldSessionCharacterCreationTests uses in Core.Net) and the /// Ok-response round trip (roster append reusing /// , the reused /// EnterSelectedCore log-straight-in, and the new /// / /// hooks) via a /// real inbound 0xF643 packet through WorldSession.ProcessDatagram /// (reflection — the same private test seam Core.Net's own creation tests /// use). /// public sealed class LiveSessionControllerCharacterCreationTests { private sealed class TestTransport : 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) => throw new OperationCanceledException(cancellationToken); public void Dispose() { } } private sealed class TestOperations : ILiveSessionOperations { public List Sessions { get; } = []; public int EnterWorldCount { get; private set; } public IPEndPoint ResolveEndpoint(string host, int port) => new(IPAddress.Loopback, port); public WorldSession CreateSession(IPEndPoint endpoint) { var session = new WorldSession(endpoint, new TestTransport()); Sessions.Add(session); return session; } public void Connect(WorldSession session, string user, string password) { } public void StartCharacterSelectionReceive(WorldSession session) { } public CharacterList.Parsed? GetCharacters(WorldSession session) => new( 0u, [new CharacterList.Character(0x50000001u, "Existing", 0u)], [], SlotCount: 11, AccountName: "testaccount", true, true); public void EnterWorld(WorldSession session, int activeCharacterIndex) => EnterWorldCount++; public void Tick(WorldSession session) { } public void DisposeSession(WorldSession session) { } } private sealed class TestHost : ILiveSessionLifecycleHost { public List Rosters { get; } = []; public List EnteredWorld { get; } = []; public List Created { get; } = []; public List Failed { get; } = []; public LiveSessionBinding BindSession(WorldSession session) => new(session, activateCommands: () => { }, deactivateCommands: () => { }, detachEvents: () => { }); public void ResetSessionState(RuntimeGenerationToken retiringGeneration) { } public void ReportConnecting(string host, int port, string user) { } public void ReportConnected() { } public void ReportRoster(LiveSessionRosterReport roster) => Rosters.Add(roster); public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { } public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) => EnteredWorld.Add(selection); public void DetachSession(WorldSession session) { } public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity) => Created.Add(identity); public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection) => Failed.Add(rejection); } private static LiveSessionConnectOptions LiveOptions() => new( Enabled: true, "127.0.0.1", 9000, "testaccount", "password", Character: null, Probe: false, AwaitCharacterSelection: true); private static (LiveSessionController Controller, TestOperations Operations, TestHost Host, RuntimeGenerationToken Generation) StartAwaitingSelection() { var operations = new TestOperations(); var host = new TestHost(); var controller = new LiveSessionController( operations, timeProvider: null, RuntimeCharacterCreationStateFixture.Build()); LiveSessionStartResult result = controller.Start(LiveOptions(), host); Assert.Equal(LiveSessionStartStatus.AwaitingCharacterSelection, result.Status); return (controller, operations, host, controller.Generation); } private static void BuildReadyCharacter(LiveSessionController controller, RuntimeGenerationToken generation) { Assert.True(controller.SelectHeritage(generation, RuntimeCharacterCreationStateFixture.AluvianId).Accepted); Assert.True(controller.SelectGender(generation, RuntimeCharacterCreationStateFixture.MaleGenderKey).Accepted); Assert.True(controller.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.PresetTemplateIndex).Accepted); Assert.True(controller.SetName(generation, "NewChar").Accepted); } [Fact] public void Finish_SendsExactly55SkillSlotsAndTheCorrectAttributesAndName() { (LiveSessionController controller, TestOperations operations, _, RuntimeGenerationToken generation) = StartAwaitingSelection(); BuildReadyCharacter(controller, generation); WorldSession session = operations.Sessions[0]; byte[]? captured = null; GameMessageGroup? capturedGroup = null; session.GameMessageCapture = (body, group) => { captured = body; capturedGroup = group; }; RuntimeCommandResult result = controller.Finish(generation); Assert.True(result.Accepted); Assert.NotNull(captured); Assert.Equal(GameMessageGroup.LoginQueue, capturedGroup); CapturedCreateRequest decoded = DecodeCreateRequest(captured!); Assert.Equal("testaccount", decoded.AccountName); Assert.Equal(RuntimeCharacterCreationStateFixture.AluvianId, decoded.Heritage); Assert.Equal(RuntimeCharacterCreationStateFixture.MaleGenderKey, decoded.Gender); Assert.Equal(RuntimeCharacterCreationStateFixture.PresetTemplateIndex, decoded.Template); Assert.Equal(16u, decoded.Strength); Assert.Equal("NewChar", decoded.Name); Assert.Equal((uint)CharacterCreate.SkillAdvancementClassCount, decoded.NumSkills); Assert.Equal(CharacterCreate.SkillAdvancementClassCount, decoded.SkillAdvancementClasses.Length); Assert.Equal( (uint)ChargenSkillAdvancementClass.Trained, decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillTrainSpecialize]); Assert.Equal( (uint)ChargenSkillAdvancementClass.Specialized, decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillPresetPrimary]); } [Fact] public void Finish_ThenOkResponse_AppendsToRosterAndLogsStraightIn() { (LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) = StartAwaitingSelection(); BuildReadyCharacter(controller, generation); WorldSession session = operations.Sessions[0]; session.GameMessageCapture = (_, _) => { }; Assert.True(controller.Finish(generation).Accepted); InvokeProcessDatagram(session, BuildResponsePacket( (uint)CharGenVerificationResponse.Code.Ok, 0x50001234u, "NewChar")); Assert.Single(host.Created); Assert.Equal(0x50001234u, host.Created[0].Guid); Assert.Equal("NewChar", host.Created[0].Name); Assert.Empty(host.Failed); // The roster report following the Ok reply has BOTH the pre-existing // character and the newly created one. LiveSessionRosterReport lastReport = host.Rosters[^1]; Assert.Contains(lastReport.Entries, e => e.Id == 0x50001234u && e.Name == "NewChar"); Assert.Contains(lastReport.Entries, e => e.Id == 0x50000001u); // gmCharGenMainUI::Update @ 0x004E8460's log-straight-in, reused via // EnterSelectedCore — the controller is now in-world as the new // character, no second selection/EnterWorld call needed. Assert.True(controller.IsInWorld); Assert.Equal(1, operations.EnterWorldCount); Assert.Single(host.EnteredWorld); Assert.Equal(0x50001234u, host.EnteredWorld[0].CharacterId); } [Fact] public void Finish_ThenNameInUseResponse_SurfacesRejectionAndStaysAwaitingSelection() { (LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) = StartAwaitingSelection(); BuildReadyCharacter(controller, generation); WorldSession session = operations.Sessions[0]; session.GameMessageCapture = (_, _) => { }; Assert.True(controller.Finish(generation).Accepted); InvokeProcessDatagram(session, BuildResponsePacket( (uint)CharGenVerificationResponse.Code.NameInUse, 0u, string.Empty)); Assert.Single(host.Failed); Assert.Equal(CharGenVerificationResponse.Code.NameInUse, host.Failed[0].Code); Assert.Equal("NewChar", host.Failed[0].AttemptedName); Assert.Empty(host.Created); Assert.False(controller.IsInWorld); Assert.Equal(0, operations.EnterWorldCount); Assert.Empty(host.EnteredWorld); // No roster append on a rejection. Assert.DoesNotContain(host.Rosters, r => r.Entries.Any(e => e.Name == "NewChar")); } [Fact] public void Finish_RefusedLocallyWithUnspentAttributeCredits_NeverTouchesTheWire() { (LiveSessionController controller, TestOperations operations, _, RuntimeGenerationToken generation) = StartAwaitingSelection(); Assert.True(controller.SelectHeritage(generation, RuntimeCharacterCreationStateFixture.AluvianId).Accepted); Assert.True(controller.SelectGender(generation, RuntimeCharacterCreationStateFixture.MaleGenderKey).Accepted); Assert.True(controller.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.CustomTemplateIndex).Accepted); Assert.True(controller.SetName(generation, "NewChar").Accepted); WorldSession session = operations.Sessions[0]; bool sent = false; session.GameMessageCapture = (_, _) => sent = true; RuntimeCommandResult result = controller.Finish(generation); Assert.False(result.Accepted); Assert.False(sent); } 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 byte[] BuildResponseBody(uint code, uint guid, string name) { var w = new PacketWriter(); w.WriteUInt32(CharGenVerificationResponse.ResponseOpcode); w.WriteUInt32(code); if (code == (uint)CharGenVerificationResponse.Code.Ok) { w.WriteUInt32(guid); w.WriteString16L(name); w.WriteUInt32(0u); } return w.ToArray(); } private static byte[] BuildResponsePacket(uint code, uint guid, string name) { byte[] message = BuildResponseBody(code, guid, name); var fragments = new byte[MessageFragmentHeader.Size + message.Length]; GameMessageFragment.WriteSingleFragment(fragments.AsSpan(), fragmentSequence: 1u, GameMessageGroup.UIQueue, message); return PacketCodec.Encode( new PacketHeader { Sequence = 1u, Flags = PacketHeaderFlags.BlobFragments }, fragments, outboundIsaac: null); } private readonly record struct CapturedCreateRequest( string AccountName, uint Heritage, uint Gender, uint Template, uint Strength, string Name, uint NumSkills, uint[] SkillAdvancementClasses); /// Manual mirror of 's /// exact field order — see that class's doc comment for the full /// layout. private static CapturedCreateRequest DecodeCreateRequest(ReadOnlySpan body) { int pos = 0; uint opcode = ReadU32(body, ref pos); Assert.Equal(CharacterCreate.Opcode, opcode); string accountName = ReadString16L(body, ref pos); uint constant = ReadU32(body, ref pos); Assert.Equal(1u, constant); uint heritage = ReadU32(body, ref pos); uint gender = ReadU32(body, ref pos); _ = ReadU32(body, ref pos); // eyesStrip _ = ReadU32(body, ref pos); // noseStrip _ = ReadU32(body, ref pos); // mouthStrip _ = ReadU32(body, ref pos); // hairColor _ = ReadU32(body, ref pos); // eyeColor _ = ReadU32(body, ref pos); // hairStyle _ = ReadU32(body, ref pos); // headgearStyle _ = ReadU32(body, ref pos); // headgearColor _ = ReadU32(body, ref pos); // shirtStyle _ = ReadU32(body, ref pos); // shirtColor _ = ReadU32(body, ref pos); // trousersStyle _ = ReadU32(body, ref pos); // trousersColor _ = ReadU32(body, ref pos); // footwearStyle _ = ReadU32(body, ref pos); // footwearColor for (int i = 0; i < 6; i++) _ = ReadF64(body, ref pos); // six shades uint template = ReadU32(body, ref pos); uint strength = ReadU32(body, ref pos); _ = ReadU32(body, ref pos); // endurance _ = ReadU32(body, ref pos); // coordination _ = ReadU32(body, ref pos); // quickness _ = ReadU32(body, ref pos); // focus _ = ReadU32(body, ref pos); // self _ = ReadU32(body, ref pos); // slot _ = ReadU32(body, ref pos); // classId uint numSkills = ReadU32(body, ref pos); var skills = new uint[numSkills]; for (int i = 0; i < numSkills; i++) skills[i] = ReadU32(body, ref pos); string name = ReadString16L(body, ref pos); // startArea, isAdmin, isEnvoy, checksum follow — not needed here. return new CapturedCreateRequest( accountName, heritage, gender, template, strength, name, numSkills, skills); } private static uint ReadU32(ReadOnlySpan body, ref int pos) { uint value = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; return value; } private static double ReadF64(ReadOnlySpan body, ref int pos) { double value = BinaryPrimitives.ReadDoubleLittleEndian(body.Slice(pos)); pos += 8; return value; } private static string ReadString16L(ReadOnlySpan body, ref int pos) { ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); string value = Encoding.ASCII.GetString(body.Slice(pos + 2, len)); int recordSize = 2 + len; int padding = (4 - (recordSize & 3)) & 3; pos += recordSize + padding; return value; } }