using System.Buffers.Binary; using System.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; using AcDream.Core.Net.Transport; namespace AcDream.Core.Net.Tests.Transport; /// /// Tests of the N0 harness plumbing: the deterministic /// fault injector and the that binds a REAL /// to the with no /// sockets anywhere. /// public sealed class FakeAceTransportTests { // ---- LossyLink ---- [Fact] public void LossyLink_DropNextAndDropAt_DropDeterministically() { var link = new LossyLink(); link.DropNext(LinkDirection.ClientToServer); link.DropAt(LinkDirection.ClientToServer, 2); Assert.Empty(link.Transmit(LinkDirection.ClientToServer, new byte[] { 1 })); // index 0: DropNext Assert.Single(link.Transmit(LinkDirection.ClientToServer, new byte[] { 2 })); // index 1 Assert.Empty(link.Transmit(LinkDirection.ClientToServer, new byte[] { 3 })); // index 2: DropAt Assert.Single(link.Transmit(LinkDirection.ClientToServer, new byte[] { 4 })); // index 3 Assert.Equal(4, link.TransmitCount(LinkDirection.ClientToServer)); Assert.Equal(2, link.DroppedCount(LinkDirection.ClientToServer)); Assert.Equal(2, link.DeliveredCount(LinkDirection.ClientToServer)); // Directions are independent. Assert.Equal(0, link.TransmitCount(LinkDirection.ServerToClient)); } [Fact] public void LossyLink_PredicateDrop_IsPersistent() { var link = new LossyLink(); link.Drop(LinkDirection.ServerToClient, (_, datagram) => datagram[0] == 0xAA); Assert.Empty(link.Transmit(LinkDirection.ServerToClient, new byte[] { 0xAA })); Assert.Single(link.Transmit(LinkDirection.ServerToClient, new byte[] { 0xBB })); Assert.Empty(link.Transmit(LinkDirection.ServerToClient, new byte[] { 0xAA })); Assert.Equal(2, link.DroppedCount(LinkDirection.ServerToClient)); } [Fact] public void LossyLink_Reorder_SwapsAdjacentDatagrams() { var link = new LossyLink(); link.Reorder(LinkDirection.ClientToServer); Assert.Empty(link.Transmit(LinkDirection.ClientToServer, new byte[] { 1 })); // held IReadOnlyList delivered = link.Transmit(LinkDirection.ClientToServer, new byte[] { 2 }); Assert.Equal(2, delivered.Count); Assert.Equal(2, delivered[0][0]); // the follower first Assert.Equal(1, delivered[1][0]); // then the held one // A held datagram with no follower can be force-released. link.Reorder(LinkDirection.ClientToServer); Assert.Empty(link.Transmit(LinkDirection.ClientToServer, new byte[] { 3 })); IReadOnlyList drained = link.DrainHeld(LinkDirection.ClientToServer); Assert.Equal(3, Assert.Single(drained)[0]); } [Fact] public void LossyLink_SeededRandomLoss_IsDeterministic() { var first = new LossyLink(); var second = new LossyLink(); first.RandomLoss(LinkDirection.ClientToServer, probability: 0.5, seed: 42); second.RandomLoss(LinkDirection.ClientToServer, probability: 0.5, seed: 42); for (int i = 0; i < 100; i++) { byte[] datagram = { (byte)i }; Assert.Equal( first.Transmit(LinkDirection.ClientToServer, datagram).Count, second.Transmit(LinkDirection.ClientToServer, datagram).Count); } // At 50% over 100 datagrams both outcomes occur. Assert.True(first.DroppedCount(LinkDirection.ClientToServer) > 0); Assert.True(first.DeliveredCount(LinkDirection.ClientToServer) > 0); } // ---- FakeAceTransport end-to-end ---- /// /// The N0 goal made concrete: a genuine Connect() / /// EnterWorld() / Tick() / Dispose() lifecycle runs /// against the ACE-behaviour model with zero sockets — including both /// ISAAC streams staying aligned end-to-end and retail's graceful-logout /// order at teardown. /// [Fact] public void RealWorldSession_HandshakeEnterWorldTickAndGracefulLogout_NoSockets() { var transport = new FakeAceTransport(); var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), transport); try { session.Connect("testaccount", "testpassword", TimeSpan.FromSeconds(10)); Assert.Equal(WorldSession.State.InCharacterSelect, session.CurrentState); Assert.NotNull(session.Characters); CharacterList.Character character = Assert.Single(session.Characters!.Characters); Assert.Equal(FakeAceTransport.DefaultCharacterName, character.Name); Assert.Equal(FakeAceTransport.DefaultAccountName, session.Characters.AccountName); var messages = new List(); session.ServerMessageReceived += m => messages.Add(m.Message); session.EnterWorld(0, TimeSpan.FromSeconds(10)); Assert.Equal(WorldSession.State.InWorld, session.CurrentState); // A world message flows model → link → async receive loop → // Tick() → typed event. transport.Model.EnqueueGameMessage( BuildServerMessage("hello acdream"), GameMessageGroup.UIQueue); transport.PumpServer(); DateTime deadline = DateTime.UtcNow.AddSeconds(10); while (messages.Count == 0 && DateTime.UtcNow < deadline) { session.Tick(); Thread.Sleep(5); } Assert.Equal("hello acdream", Assert.Single(messages)); // The model saw the genuine ordered client stream, and neither // direction desynced its ISAAC keystream. Assert.Equal( new[] { CharacterEnterWorld.EnterWorldRequestOpcode, CharacterEnterWorld.EnterWorldOpcode, }, transport.Model.DispatchedMessages.Select(ReadOpcode).ToArray()); Assert.Equal(0, transport.Model.CrcDropCount); Assert.Equal(0, transport.Model.DuplicateDropCount); Assert.Equal(256, transport.Model.Crypto.Headroom); } finally { session.Dispose(); } // Dispose ran retail's graceful order — 0xF653 request, the model's // scripted confirmation, then the transport Disconnect that // terminates the model exactly like ACE's session teardown. Assert.Equal(WorldSession.State.Disconnected, session.CurrentState); Assert.True(transport.Model.IsTerminated); Assert.Equal( AceTerminationReason.PacketHeaderDisconnect, transport.Model.TerminationReason); Assert.Equal( CharacterLogOff.Opcode, ReadOpcode(transport.Model.DispatchedMessages[^1])); Assert.Equal(0, transport.Model.CrcDropCount); } [Fact] public async Task PausedSelector_SeededDroppedServerReady_RecoversOnIdleSweep() { var fake = new FakeAceTransport(); // Random(~13) yields 4, 92, 55, 54: at 50%, only the first // post-arm inbound datagram (ServerReady) is dropped; the follower, // recovered resend, and graceful-logoff confirmation all land. var lossy = new LossyTransportDecorator( fake, dropPercent: 50, seed: 13, NetDropDirection.In); var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), lossy) { TransportClockSource = (fake.Clock.GetTimestamp, fake.Clock.Frequency), }; using var enterRequest = new ManualResetEventSlim(); fake.Model.MessageDispatched += body => { if (ReadOpcode(body) == CharacterEnterWorld.EnterWorldRequestOpcode) { enterRequest.Set(); } }; try { session.Connect( FakeAceTransport.DefaultAccountName, "testpassword", TimeSpan.FromSeconds(5)); session.StartCharacterSelectionReceive(); // Graphical selector frames continue before the user enters. for (int i = 0; i < 3; i++) { fake.Clock.Advance(TimeSpan.FromMilliseconds(50)); session.Tick(); } Task gapDriver = Task.Run(() => { Assert.True(enterRequest.Wait(TimeSpan.FromSeconds(2))); // This later sequenced packet passes the seeded loss gate, // exposing the missing ServerReady and parking behind it. fake.EnqueueServerGameMessage( BuildServerMessage("post-ready follower"), GameMessageGroup.UIQueue); Assert.True(SpinWait.SpinUntil( () => session.Transport?.Inbound.NakCount > 0, TimeSpan.FromSeconds(2))); // No datagram follows this virtual-time edge. Recovery now // requires paused EnterWorld's independent periodic sweep. fake.Clock.Advance(TimeSpan.FromSeconds(1)); }); session.EnterWorld(0, TimeSpan.FromSeconds(5)); await gapDriver; Assert.Equal(WorldSession.State.InWorld, session.CurrentState); Assert.Equal(1, lossy.InboundDropped); Assert.True(session.Transport!.Stats.NaksSent > 0); Assert.True(fake.Model.RetransmitsServed > 0); Assert.Equal(0, session.Transport.Inbound.NakCount); Assert.Equal(0, session.Transport.Outbound.PendingResendCount); Assert.Equal( new[] { CharacterEnterWorld.EnterWorldRequestOpcode, CharacterEnterWorld.EnterWorldOpcode, }, fake.Model.DispatchedMessages.Select(ReadOpcode).ToArray()); Assert.Equal(256, fake.Model.Crypto.Headroom); Assert.Equal(0, fake.Model.Crypto.OrphanCount); Assert.Equal(0, fake.Model.CrcDropCount); Assert.False(fake.Model.IsTerminated); } finally { session.Dispose(); } Assert.Equal(WorldSession.State.Disconnected, session.CurrentState); Assert.True(fake.Model.IsTerminated); Assert.Equal( AceTerminationReason.PacketHeaderDisconnect, fake.Model.TerminationReason); Assert.Equal( CharacterLogOff.Opcode, ReadOpcode(fake.Model.DispatchedMessages[^1])); Assert.Equal(256, fake.Model.Crypto.Headroom); Assert.Equal(0, fake.Model.Crypto.OrphanCount); } private static uint ReadOpcode(byte[] messageBody) => BinaryPrimitives.ReadUInt32LittleEndian(messageBody); private static byte[] BuildServerMessage(string text) { var writer = new PacketWriter(64); writer.WriteUInt32(ServerMessage.Opcode); // 0xF7E0 writer.WriteString16L(text); writer.WriteUInt32(1); // ChatMessageType return writer.ToArray(); } }