fix(runtime): close Campaign LA7b review findings

This commit is contained in:
Erik 2026-08-14 18:55:48 +02:00
parent 0e82cbf700
commit 1b9e7e41f9
11 changed files with 700 additions and 28 deletions

View file

@ -74,6 +74,12 @@ internal sealed class FakeAceTransport : IWorldSessionTransport
/// </summary>
public TimeSpan AutoAdvanceOnBlockingReceive { get; set; }
/// <summary>
/// Keeps the default ACE script intact while allowing focused handshake
/// tests to enqueue messages before the valid ServerReady response.
/// </summary>
public bool AutoReplyServerReady { get; set; } = true;
public FakeAceTransport(VirtualClock? clock = null, LossyLink? link = null)
{
Clock = clock ?? new VirtualClock();
@ -101,7 +107,12 @@ internal sealed class FakeAceTransport : IWorldSessionTransport
case CharacterEnterWorld.EnterWorldRequestOpcode: // 0xF7C8
// Server replies CharacterEnterWorldServerReady (0xF7DF) —
// WorldSession.EnterWorld blocks on this opcode.
Model.EnqueueGameMessage(BuildOpcodeOnlyBody(0xF7DFu), GameMessageGroup.UIQueue);
if (AutoReplyServerReady)
{
Model.EnqueueGameMessage(
BuildOpcodeOnlyBody(0xF7DFu),
GameMessageGroup.UIQueue);
}
break;
case CharacterLogOff.Opcode: // 0xF653 request (opcode + character id)
// ACE echoes the opcode-only confirmation; WorldSession.Dispose
@ -142,6 +153,22 @@ internal sealed class FakeAceTransport : IWorldSessionTransport
}
}
/// <summary>
/// Enqueue and flush one model game message under the transport's model
/// lock. This is the race-free test seam for a server follower emitted
/// while the real WorldSession background receiver is active.
/// </summary>
public void EnqueueServerGameMessage(
byte[] body,
GameMessageGroup group)
{
lock (_gate)
{
Model.EnqueueGameMessage(body, group);
PumpServerLocked();
}
}
/// <summary>
/// N2 test hook: deliver raw bytes straight into the client's receive
/// queue, bypassing both the model and the link. Used for late

View file

@ -2,6 +2,7 @@ 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;
@ -165,6 +166,105 @@ public sealed class FakeAceTransportTests
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);

View file

@ -3,6 +3,7 @@ using System.Net;
using System.Reflection;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
using AcDream.Core.Net.Tests.Transport;
namespace AcDream.Core.Net.Tests;
@ -91,11 +92,80 @@ public sealed class WorldSessionCharacterSelectionTests
Assert.Equal(1u, current.SecondsGreyedOut);
}
[Fact]
public void ImmediateEnterWorld_IgnoresNumErrorsSentinelBeforeServerReady()
{
var transport = new FakeAceTransport
{
AutoReplyServerReady = false,
};
using var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000),
transport);
int errors = 0;
session.CharacterErrorReceived += _ => errors++;
ConfigureSentinelThenServerReady(transport);
session.Connect(
FakeAceTransport.DefaultAccountName,
"testpassword",
TimeSpan.FromSeconds(5));
session.EnterWorld(0, TimeSpan.FromSeconds(5));
Assert.Equal(WorldSession.State.InWorld, session.CurrentState);
Assert.Equal(0, errors);
}
[Fact]
public void PausedEnterWorld_IgnoresNumErrorsSentinelBeforeServerReady()
{
var transport = new FakeAceTransport
{
AutoReplyServerReady = false,
};
using var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000),
transport);
int errors = 0;
session.CharacterErrorReceived += _ => errors++;
ConfigureSentinelThenServerReady(transport);
session.Connect(
FakeAceTransport.DefaultAccountName,
"testpassword",
TimeSpan.FromSeconds(5));
session.StartCharacterSelectionReceive();
session.EnterWorld(0, TimeSpan.FromSeconds(5));
Assert.Equal(WorldSession.State.InWorld, session.CurrentState);
Assert.Equal(0, errors);
}
private static WorldSession CreateSession() =>
new(
new IPEndPoint(IPAddress.Loopback, 9000),
new NullTransport());
private static void ConfigureSentinelThenServerReady(
FakeAceTransport transport)
{
transport.Model.MessageDispatched += body =>
{
if (BinaryPrimitives.ReadUInt32LittleEndian(body)
!= CharacterEnterWorld.EnterWorldRequestOpcode)
{
return;
}
transport.Model.EnqueueGameMessage(
BuildCharacterError(CharacterError.Code.NumErrors),
GameMessageGroup.UIQueue);
transport.Model.EnqueueGameMessage(
BitConverter.GetBytes(0xF7DFu),
GameMessageGroup.UIQueue);
};
}
private static byte[] BuildRoster(uint secondsGreyedOut)
{
var writer = new PacketWriter(96);