using System.Buffers.Binary;
using System.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Tests.Transport;
///
/// An that binds a REAL
/// to an through a
/// — no sockets anywhere. Outbound sends run through
/// the link into the model; the model's emitted datagrams run through the
/// link into the queue that Receive/ReceiveAsync serve.
///
///
/// The handshake is scripted against the model's events so a test can run a
/// genuine Connect() / EnterWorld() / Tick() /
/// Dispose() lifecycle:
///
/// - LoginRequest → the model answers with a ConnectRequest carrying
/// the 32-byte optional (server time, cookie, client id, both ISAAC
/// seeds) — the same layout the existing negotiation fixture
/// (WorldSessionNegotiationShutdownTests.BuildConnectRequest)
/// pins.
/// - ConnectResponse (cookie match) → the model enqueues a
/// CharacterList (0xF658) with one selectable character.
/// - CharacterEnterWorldRequest (0xF7C8) → ServerReady (0xF7DF).
/// - CharacterLogOff (0xF653 request) → the opcode-only 0xF653
/// confirmation, so Dispose() completes its retail graceful
/// logout instead of burning the 35 s confirmation timeout.
///
/// Because the model seeds its C2S verifier and S2C keystream from the same
/// seeds it hands out in the ConnectRequest, post-handshake encrypted
/// traffic verifies in both directions.
///
///
///
/// Thread-safety: the model is single-threaded, so every model interaction
/// happens under one lock. ReceiveAsync (the session's background
/// receive loop) waits on a semaphore counting queued deliverables.
///
///
internal sealed class FakeAceTransport : IWorldSessionTransport
{
public const uint DefaultClientSeed = 0x2B6D6F87u;
public const uint DefaultServerSeed = 0x9A3C51E4u;
public const uint DefaultClientId = 0x1234u;
public const ulong DefaultCookie = 0xFEEDFACECAFEBABEUL;
public const uint DefaultCharacterId = 0x50000001u;
public const string DefaultCharacterName = "+Acdream";
public const string DefaultAccountName = "testaccount";
private readonly object _gate = new();
private readonly SemaphoreSlim _deliverable = new(0);
private readonly Queue _toClient = new();
private readonly IPEndPoint _serverEndpoint = new(IPAddress.Loopback, 9000);
public VirtualClock Clock { get; }
public LossyLink Link { get; }
public AceSessionModel Model { get; }
///
/// N5: virtual-clock advance applied at the top of every BLOCKING
/// call — the session-thread Connect()/EnterWorld()
/// pump path only; the async in-world receive owner never touches the
/// clock. The lossy-decorator lifecycle test needs time to move during
/// the blocking handshake pumps: with the clock frozen there, a dropped
/// handshake-window datagram could never be NAK-healed (the 0.6 s gate
/// never opens and the model never emits a later sequenced packet to
/// expose the gap) — a fixture artifact, not a transport property.
/// Zero (the default) preserves the pre-N5 fixture behavior exactly.
/// Single-threaded by construction: blocking receives happen on the same
/// thread that owns the clock in every test that sets this.
///
public TimeSpan AutoAdvanceOnBlockingReceive { get; set; }
public FakeAceTransport(VirtualClock? clock = null, LossyLink? link = null)
{
Clock = clock ?? new VirtualClock();
Link = link ?? new LossyLink();
Model = new AceSessionModel(
Clock,
DefaultClientSeed,
DefaultServerSeed,
DefaultClientId,
DefaultCookie);
Model.LoginRequestReceived += () => Model.SendConnectRequest();
Model.ConnectResponseAccepted += () =>
Model.EnqueueGameMessage(BuildCharacterListBody(), GameMessageGroup.UIQueue);
Model.MessageDispatched += OnClientMessage;
}
private void OnClientMessage(byte[] body)
{
if (body.Length < 4)
return;
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
switch (opcode)
{
case CharacterEnterWorld.EnterWorldRequestOpcode: // 0xF7C8
// Server replies CharacterEnterWorldServerReady (0xF7DF) —
// WorldSession.EnterWorld blocks on this opcode.
Model.EnqueueGameMessage(BuildOpcodeOnlyBody(0xF7DFu), GameMessageGroup.UIQueue);
break;
case CharacterLogOff.Opcode: // 0xF653 request (opcode + character id)
// ACE echoes the opcode-only confirmation; WorldSession.Dispose
// waits for it before sending the transport Disconnect.
Model.EnqueueGameMessage(BuildOpcodeOnlyBody(CharacterLogOff.Opcode), GameMessageGroup.UIQueue);
break;
}
}
// ---- IWorldSessionTransport ----
public void Send(ReadOnlySpan datagram) => SendCore(datagram);
// WorldSession sends the ConnectResponse to port+1; the double serves
// both listeners from one model, like ACE's single-process server.
public void Send(IPEndPoint remote, ReadOnlySpan datagram) => SendCore(datagram);
private void SendCore(ReadOnlySpan datagram)
{
lock (_gate)
{
foreach (byte[] delivered in Link.Transmit(LinkDirection.ClientToServer, datagram))
Model.Receive(delivered);
PumpServerLocked();
}
}
///
/// Run one server frame (model Update + S2C link delivery) without any
/// client traffic — the hook tests use after advancing the clock or
/// enqueuing server-side messages.
///
public void PumpServer()
{
lock (_gate)
{
PumpServerLocked();
}
}
///
/// N2 test hook: deliver raw bytes straight into the client's receive
/// queue, bypassing both the model and the link. Used for late
/// byte-identical redelivery of a dropped S2C datagram (ACE's cached
/// packet shape), duplicate injections, and crafted sequence-0 control
/// packets.
///
public void InjectServerDatagram(byte[] datagram)
{
lock (_gate)
{
_toClient.Enqueue((byte[])datagram.Clone());
_deliverable.Release();
}
}
private void PumpServerLocked()
{
Model.Update();
foreach (byte[] outbound in Model.TakePendingDatagrams())
{
foreach (byte[] delivered in Link.Transmit(LinkDirection.ServerToClient, outbound))
{
_toClient.Enqueue(delivered);
_deliverable.Release();
}
}
}
public int Receive(Span destination, TimeSpan timeout, out IPEndPoint? from)
{
if (AutoAdvanceOnBlockingReceive > TimeSpan.Zero)
Clock.Advance(AutoAdvanceOnBlockingReceive);
lock (_gate)
{
PumpServerLocked();
}
if (timeout < TimeSpan.Zero)
timeout = TimeSpan.Zero;
if (!_deliverable.Wait(timeout))
{
from = null;
return -1; // NetClient.Receive's timeout contract
}
from = _serverEndpoint;
lock (_gate)
{
byte[] datagram = _toClient.Dequeue();
datagram.CopyTo(destination);
return datagram.Length;
}
}
public async ValueTask ReceiveAsync(
Memory destination,
CancellationToken cancellationToken)
{
await _deliverable.WaitAsync(cancellationToken).ConfigureAwait(false);
lock (_gate)
{
byte[] datagram = _toClient.Dequeue();
datagram.CopyTo(destination);
return new NetReceiveResult(datagram.Length, _serverEndpoint);
}
}
public void Dispose()
{
// WorldSession disposes the transport only after cancelling and
// joining its receive task, so no waiter can be parked on the
// semaphore here. SemaphoreSlim without AvailableWaitHandle holds no
// unmanaged state — deliberately left to the GC to keep a hypothetical
// late waiter from hitting ObjectDisposedException.
}
// ---- scripted server content ----
///
/// Minimal CharacterList (0xF658) matching CharacterList.Parse:
/// status, active characters, deleted characters, slot count, account,
/// turbine chat, ToD flag.
///
private static byte[] BuildCharacterListBody()
{
var writer = new PacketWriter(96);
writer.WriteUInt32(CharacterList.Opcode);
writer.WriteUInt32(0); // status
writer.WriteUInt32(1); // active count
writer.WriteUInt32(DefaultCharacterId);
writer.WriteString16L(DefaultCharacterName);
writer.WriteUInt32(0); // secondsGreyedOut
writer.WriteUInt32(0); // deleted count
writer.WriteUInt32(11); // slot count
writer.WriteString16L(DefaultAccountName);
writer.WriteUInt32(1); // useTurbineChat
writer.WriteUInt32(1); // hasThroneOfDestiny
return writer.ToArray();
}
private static byte[] BuildOpcodeOnlyBody(uint opcode)
{
byte[] body = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(body, opcode);
return body;
}
}