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

@ -20,6 +20,7 @@ internal sealed class CurrentGameRuntimeAdapter
{
private readonly GameRuntime _runtime;
private readonly CurrentGameRuntimeCommandAdapter _commands;
private readonly CharacterSelectionProjection _characterSelection;
private readonly IDisposable _hostLease;
private readonly object _subscriptionGate = new();
private readonly HashSet<AdapterSubscription> _subscriptions = [];
@ -40,6 +41,7 @@ internal sealed class CurrentGameRuntimeAdapter
"graphical game-runtime command adapter");
try
{
_characterSelection = new CharacterSelectionProjection(this);
_commands = new CurrentGameRuntimeCommandAdapter(
runtime.Session,
sessionHost,
@ -61,7 +63,7 @@ internal sealed class CurrentGameRuntimeAdapter
}
private bool IsActive =>
!_disposed
!Volatile.Read(ref _disposed)
&& !_runtime.Session.IsDisposalComplete;
public RuntimeGenerationToken Generation => _runtime.Generation;
@ -90,7 +92,7 @@ internal sealed class CurrentGameRuntimeAdapter
public IRuntimeSocialView Social => _runtime.Social;
public IRuntimeChatView Chat => _runtime.Chat;
public IRuntimeCharacterSelectionView CharacterSelection =>
_runtime.CharacterSelection;
_characterSelection;
public IRuntimeFellowshipView Fellowship => _runtime.Fellowship;
public IRuntimeAllegianceView Allegiance => _runtime.Allegiance;
public IRuntimeActionView Actions => _runtime.Actions;
@ -100,9 +102,9 @@ internal sealed class CurrentGameRuntimeAdapter
public IRuntimeSessionCommands Session => _commands;
public IRuntimeCharacterSelectionCommands CharacterSelectionCommands =>
_runtime.Session;
_characterSelection;
IRuntimeCharacterSelectionCommands IGameRuntimeCommands.CharacterSelection =>
_runtime.Session;
_characterSelection;
public IRuntimeSelectionCommands Selection => _commands;
public IRuntimeCombatCommands Combat => _commands;
public IRuntimeMagicCommands Magic => _commands;
@ -166,6 +168,187 @@ internal sealed class CurrentGameRuntimeAdapter
_subscriptions.Remove(subscription);
}
private RuntimeCharacterSelectionSnapshot CharacterSelectionSnapshot()
{
lock (_subscriptionGate)
{
if (IsActive)
return _runtime.CharacterSelection.Snapshot;
return new RuntimeCharacterSelectionSnapshot(
_runtime.Generation,
RuntimeCharacterSelectionLifecycle.Inactive,
Revision: 0,
AccountName: string.Empty,
SlotCount: 0,
RosterCount: 0,
HighlightedCharacterId: 0u,
HighlightedDisplayIndex: -1,
PendingDeleteCharacterId: 0u,
LastRestoreRequestedCharacterId: 0u,
Operation: RuntimeCharacterSelectionOperation.None,
Error: null,
Buttons: RuntimeCharacterSelectionButtons.None);
}
}
private bool TryGetCharacterSelectionAt(
int displayIndex,
out RuntimeCharacterSelectionEntry character)
{
lock (_subscriptionGate)
{
if (IsActive)
{
return _runtime.CharacterSelection.TryGetAt(
displayIndex,
out character);
}
character = default;
return false;
}
}
private bool TryGetCharacterSelection(
uint characterId,
out RuntimeCharacterSelectionEntry character)
{
lock (_subscriptionGate)
{
if (IsActive)
{
return _runtime.CharacterSelection.TryGet(
characterId,
out character);
}
character = default;
return false;
}
}
private void VisitCharacterSelection(
IRuntimeCharacterSelectionVisitor visitor)
{
ArgumentNullException.ThrowIfNull(visitor);
lock (_subscriptionGate)
{
if (IsActive)
_runtime.CharacterSelection.Visit(visitor);
}
}
private IDisposable SubscribeCharacterSelection(
IRuntimeCharacterSelectionObserver observer)
{
ArgumentNullException.ThrowIfNull(observer);
lock (_subscriptionGate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var gated = new AdapterCharacterSelectionObserver(this, observer);
IDisposable runtimeSubscription =
_runtime.CharacterSelection.Subscribe(gated);
var subscription = new AdapterSubscription(
this,
runtimeSubscription);
_subscriptions.Add(subscription);
return subscription;
}
}
private RuntimeCommandResult ExecuteCharacterSelection(
Func<IRuntimeCharacterSelectionCommands, RuntimeCommandResult> execute)
{
lock (_subscriptionGate)
{
if (!IsActive)
{
return new RuntimeCommandResult(
RuntimeCommandStatus.Inactive,
_runtime.Generation);
}
return execute(_runtime.Session);
}
}
private void ForwardCharacterSelection(
IRuntimeCharacterSelectionObserver observer,
in RuntimeCharacterSelectionDelta delta)
{
lock (_subscriptionGate)
{
if (IsActive)
observer.OnCharacterSelectionChanged(in delta);
}
}
private sealed class CharacterSelectionProjection(
CurrentGameRuntimeAdapter owner)
: IRuntimeCharacterSelectionView,
IRuntimeCharacterSelectionCommands
{
public RuntimeCharacterSelectionSnapshot Snapshot =>
owner.CharacterSelectionSnapshot();
public bool TryGetAt(
int displayIndex,
out RuntimeCharacterSelectionEntry character) =>
owner.TryGetCharacterSelectionAt(displayIndex, out character);
public bool TryGet(
uint characterId,
out RuntimeCharacterSelectionEntry character) =>
owner.TryGetCharacterSelection(characterId, out character);
public void Visit(IRuntimeCharacterSelectionVisitor visitor) =>
owner.VisitCharacterSelection(visitor);
public IDisposable Subscribe(
IRuntimeCharacterSelectionObserver observer) =>
owner.SubscribeCharacterSelection(observer);
public RuntimeCommandResult Highlight(
RuntimeGenerationToken expectedGeneration,
uint characterId) =>
owner.ExecuteCharacterSelection(
commands => commands.Highlight(
expectedGeneration,
characterId));
public RuntimeCommandResult Enter(
RuntimeGenerationToken expectedGeneration) =>
owner.ExecuteCharacterSelection(
commands => commands.Enter(expectedGeneration));
public RuntimeCommandResult RequestDelete(
RuntimeGenerationToken expectedGeneration) =>
owner.ExecuteCharacterSelection(
commands => commands.RequestDelete(expectedGeneration));
public RuntimeCommandResult ConfirmDelete(
RuntimeGenerationToken expectedGeneration) =>
owner.ExecuteCharacterSelection(
commands => commands.ConfirmDelete(expectedGeneration));
public RuntimeCommandResult Restore(
RuntimeGenerationToken expectedGeneration) =>
owner.ExecuteCharacterSelection(
commands => commands.Restore(expectedGeneration));
public RuntimeCommandResult Cancel(
RuntimeGenerationToken expectedGeneration) =>
owner.ExecuteCharacterSelection(
commands => commands.Cancel(expectedGeneration));
}
private sealed class AdapterCharacterSelectionObserver(
CurrentGameRuntimeAdapter owner,
IRuntimeCharacterSelectionObserver observer)
: IRuntimeCharacterSelectionObserver
{
public void OnCharacterSelectionChanged(
in RuntimeCharacterSelectionDelta delta) =>
owner.ForwardCharacterSelection(observer, in delta);
}
private sealed class AdapterSubscription(
CurrentGameRuntimeAdapter owner,
IDisposable runtimeSubscription) : IDisposable

View file

@ -1125,11 +1125,12 @@ public sealed class WorldSession : IDisposable
{
var opcodes = new List<uint>();
ProcessDatagram(datagram.Memory, opcodes);
SweepTransport();
return opcodes.Contains(0xF7DFu)
|| _lastCharacterSelectionError is not null;
},
ReturnInboundDatagram);
ReturnInboundDatagram,
SweepTransport,
TimeSpan.FromMilliseconds(25));
}
if (_lastCharacterSelectionError is { } selectionError)
{
@ -1817,6 +1818,12 @@ public sealed class WorldSession : IDisposable
{
continue;
}
// CharacterError::NumErrors is the enum-count sentinel, not
// a server rejection. Retail never presents it, and treating
// it as an EnterWorld failure would abort either handshake
// pump before a valid ServerReady later in the same packet.
if (parsed.AsCode == CharacterError.Code.NumErrors)
continue;
_lastCharacterSelectionError = parsed;
CharacterErrorReceived?.Invoke(parsed);
}
@ -3339,10 +3346,15 @@ public sealed class WorldSession : IDisposable
ChannelReader<T> reader,
TimeSpan timeout,
Func<T, bool> processAndCheckConfirmation,
Action<T>? release = null)
Action<T>? release = null,
Action? periodicWork = null,
TimeSpan? periodicInterval = null)
{
ArgumentNullException.ThrowIfNull(reader);
ArgumentNullException.ThrowIfNull(processAndCheckConfirmation);
TimeSpan cadence = periodicInterval ?? TimeSpan.FromMilliseconds(25);
if (periodicWork is not null && cadence <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(periodicInterval));
using var timeoutSource = new CancellationTokenSource(timeout);
// The deadline is also read straight off the monotonic clock, not only
@ -3382,10 +3394,52 @@ public sealed class WorldSession : IDisposable
return true;
}
bool canRead = reader.WaitToReadAsync(timeoutSource.Token)
.AsTask()
.GetAwaiter()
.GetResult();
periodicWork?.Invoke();
if (timeoutSource.IsCancellationRequested || Expired())
return false;
bool canRead;
if (periodicWork is null)
{
canRead = reader.WaitToReadAsync(timeoutSource.Token)
.AsTask()
.GetAwaiter()
.GetResult();
}
else
{
TimeSpan wait = cadence;
if (bounded)
{
TimeSpan remaining = timeout
- Stopwatch.GetElapsedTime(started);
if (remaining <= TimeSpan.Zero)
return false;
if (remaining < wait)
wait = remaining;
}
using var sliceSource =
CancellationTokenSource.CreateLinkedTokenSource(
timeoutSource.Token);
sliceSource.CancelAfter(wait);
try
{
canRead = reader.WaitToReadAsync(sliceSource.Token)
.AsTask()
.GetAwaiter()
.GetResult();
}
catch (OperationCanceledException)
when (!timeoutSource.IsCancellationRequested
&& !Expired())
{
// This cadence is the paused selector's frame edge:
// keep reliable transport work moving even when no
// datagram arrives to wake the inbound queue.
continue;
}
}
if (!canRead)
return false;
}

View file

@ -178,8 +178,12 @@ public sealed class GameRuntime
faultInjection);
context.Session = dependencies.SessionOperations is null
? new LiveSessionController()
: new LiveSessionController(dependencies.SessionOperations);
? new LiveSessionController(
ProductionLiveSessionOperations.Instance,
dependencies.TimeProvider)
: new LiveSessionController(
dependencies.SessionOperations,
dependencies.TimeProvider);
construction.Own(context.Session);
Fault(
GameRuntimeConstructionPoint.SessionCreated,

View file

@ -376,14 +376,17 @@ public sealed class LiveSessionController
private Action<WorldSession>? _preLogoffFlushHook;
public LiveSessionController()
: this(ProductionLiveSessionOperations.Instance)
: this(ProductionLiveSessionOperations.Instance, null)
{
}
public LiveSessionController(ILiveSessionOperations operations)
public LiveSessionController(
ILiveSessionOperations operations,
TimeProvider? timeProvider = null)
{
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
CharacterSelectionState = new RuntimeCharacterSelectionState();
CharacterSelectionState = new RuntimeCharacterSelectionState(
timeProvider);
}
public RuntimeCharacterSelectionState CharacterSelectionState { get; }
@ -583,6 +586,7 @@ public sealed class LiveSessionController
_operations.Tick(scope.Session);
if (!IsCurrent(scope, generation))
return;
CharacterSelectionState.SweepRestoreCorrelation();
}
catch (Exception tickError)
{

View file

@ -32,6 +32,7 @@ public enum RuntimeCharacterSelectionDeltaKind
DeleteAcknowledged,
RestoreRequested,
RestoreCompleted,
RestoreCorrelationExpired,
ErrorChanged,
EnteringWorld,
EnteredWorld,
@ -165,6 +166,9 @@ public interface IRuntimeCharacterSelectionCommands
/// </summary>
public sealed class RuntimeCharacterSelectionState : IDisposable
{
internal static readonly TimeSpan RestoreCorrelationTimeout =
TimeSpan.FromSeconds(5);
private sealed class ViewProjection(RuntimeCharacterSelectionState owner)
: IRuntimeCharacterSelectionView
{
@ -191,6 +195,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
private readonly object _gate = new();
private readonly RuntimeCharacterSelectionEventStream _events = new();
private readonly ViewProjection _view;
private readonly TimeProvider _timeProvider;
private RuntimeCharacterSelectionEntry[] _entries = [];
private RuntimeGenerationToken _generation;
private RuntimeCharacterSelectionLifecycle _lifecycle;
@ -201,12 +206,15 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
private uint _pendingDeleteCharacterId;
private uint _lastRestoreRequestedCharacterId;
private bool _restoreResponseArmed;
private long _restoreCorrelationStartedTimestamp;
private bool _flagOnlyRestoreResponseAmbiguous;
private RuntimeCharacterSelectionOperation _operation;
private RuntimeCharacterSelectionError? _error;
private bool _disposed;
public RuntimeCharacterSelectionState()
public RuntimeCharacterSelectionState(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_view = new ViewProjection(this);
}
@ -303,6 +311,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
_pendingDeleteCharacterId = 0u;
_lastRestoreRequestedCharacterId = 0u;
_restoreResponseArmed = false;
_restoreCorrelationStartedTimestamp = 0;
_flagOnlyRestoreResponseAmbiguous = false;
_operation = RuntimeCharacterSelectionOperation.None;
_error = null;
_highlightedCharacterId = Contains(previous)
@ -408,6 +418,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
if (_disposed
|| _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection
|| HasDeleteModalOrRequest()
|| _restoreResponseArmed
|| index < 0
|| !_entries[index].IsPendingDelete)
{
@ -419,6 +430,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
_pendingDeleteCharacterId = 0u;
_lastRestoreRequestedCharacterId = character.CharacterId;
_restoreResponseArmed = true;
_restoreCorrelationStartedTimestamp =
_timeProvider.GetTimestamp();
_operation = RuntimeCharacterSelectionOperation.RestoreRequested;
_error = null;
_revision++;
@ -429,6 +442,39 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
return true;
}
/// <summary>
/// Releases the one outstanding restore correlation after a bounded local
/// window. ACE can intentionally send no response for an unknown GUID;
/// later flag-only responses carry no GUID, so once a request expires
/// they remain ambiguous and are never attributed to a newer request.
/// </summary>
internal bool SweepRestoreCorrelation()
{
uint characterId;
lock (_gate)
{
if (_disposed
|| !_restoreResponseArmed
|| _timeProvider.GetElapsedTime(
_restoreCorrelationStartedTimestamp)
< RestoreCorrelationTimeout)
{
return false;
}
characterId = _lastRestoreRequestedCharacterId;
_restoreResponseArmed = false;
_restoreCorrelationStartedTimestamp = 0;
_flagOnlyRestoreResponseAmbiguous = true;
_operation = RuntimeCharacterSelectionOperation.None;
_revision++;
}
Publish(
RuntimeCharacterSelectionDeltaKind.RestoreCorrelationExpired,
characterId);
return true;
}
internal bool Cancel()
{
RuntimeCharacterSelectionDeltaKind kind;
@ -493,12 +539,18 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
|| _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection
|| !_restoreResponseArmed)
return;
if (response.Guid is null
&& _flagOnlyRestoreResponseAmbiguous)
{
return;
}
if (response.Guid is { } responseGuid
&& responseGuid != _lastRestoreRequestedCharacterId)
{
return;
}
_restoreResponseArmed = false;
_restoreCorrelationStartedTimestamp = 0;
characterId = response.Guid
?? _lastRestoreRequestedCharacterId;
@ -555,7 +607,10 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
or RuntimeCharacterSelectionLifecycle.EnteringWorld))
return;
_pendingDeleteCharacterId = 0u;
if (_restoreResponseArmed)
_flagOnlyRestoreResponseAmbiguous = true;
_restoreResponseArmed = false;
_restoreCorrelationStartedTimestamp = 0;
_operation = RuntimeCharacterSelectionOperation.None;
_error = new RuntimeCharacterSelectionError(
error.RawErrorCode,
@ -588,6 +643,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
_pendingDeleteCharacterId = 0u;
_lastRestoreRequestedCharacterId = 0u;
_restoreResponseArmed = false;
_restoreCorrelationStartedTimestamp = 0;
_flagOnlyRestoreResponseAmbiguous = false;
_operation = RuntimeCharacterSelectionOperation.None;
_error = null;
_lifecycle = RuntimeCharacterSelectionLifecycle.EnteringWorld;
@ -717,7 +774,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
return new RuntimeCharacterSelectionButtons(
CanEnter: false,
CanDelete: false,
CanRestore: true,
CanRestore: !_restoreResponseArmed,
DeleteVisible: false,
RestoreVisible: true);
}
@ -833,6 +890,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
_pendingDeleteCharacterId = 0u;
_lastRestoreRequestedCharacterId = 0u;
_restoreResponseArmed = false;
_restoreCorrelationStartedTimestamp = 0;
_flagOnlyRestoreResponseAmbiguous = false;
_operation = RuntimeCharacterSelectionOperation.None;
_error = null;
}

View file

@ -211,6 +211,74 @@ public sealed class CurrentGameRuntimeAdapterTests
Assert.Equal(Harness.PlayerGuid, harness.Identity.ServerGuid);
}
[Fact]
public void DisposedAdapterMakesRetainedSelectionRoutesInertWhileRuntimeLives()
{
using var harness = new Harness(awaitCharacterSelection: true);
using CurrentGameRuntimeAdapter survivor =
harness.CreateAdditionalAdapter();
Assert.Equal(
RuntimeSessionStartStatus.AwaitingCharacterSelection,
harness.Runtime.Session.Start(harness.Runtime.Generation).Status);
RuntimeGenerationToken generation = harness.Runtime.Generation;
IRuntimeCharacterSelectionView retainedView =
harness.Runtime.CharacterSelection;
IRuntimeCharacterSelectionCommands publicCommands =
harness.Runtime.CharacterSelectionCommands;
IRuntimeCharacterSelectionCommands interfaceCommands =
((IGameRuntimeCommands)harness.Runtime).CharacterSelection;
var observer = new CharacterSelectionObserver();
using IDisposable subscription = retainedView.Subscribe(observer);
RuntimeCharacterSelectionSnapshot before =
survivor.CharacterSelection.Snapshot;
harness.Runtime.Dispose();
Assert.False(harness.Runtime.Lifecycle.HasTransport);
Assert.Equal(
RuntimeCharacterSelectionLifecycle.Inactive,
retainedView.Snapshot.Lifecycle);
Assert.Equal(0, retainedView.Snapshot.RosterCount);
Assert.False(retainedView.TryGetAt(0, out _));
Assert.False(retainedView.TryGet(Harness.PlayerGuid, out _));
var visitor = new CharacterSelectionVisitor();
retainedView.Visit(visitor);
Assert.Empty(visitor.Entries);
Assert.Throws<ObjectDisposedException>(() =>
retainedView.Subscribe(new CharacterSelectionObserver()));
RuntimeCommandStatus[] statuses =
[
publicCommands.Highlight(generation, 0x50000001u).Status,
publicCommands.Enter(generation).Status,
publicCommands.RequestDelete(generation).Status,
interfaceCommands.ConfirmDelete(generation).Status,
interfaceCommands.Restore(generation).Status,
interfaceCommands.Cancel(generation).Status,
];
Assert.All(statuses, status =>
Assert.Equal(RuntimeCommandStatus.Inactive, status));
RuntimeCharacterSelectionSnapshot after =
survivor.CharacterSelection.Snapshot;
Assert.Equal(before.Revision, after.Revision);
Assert.Equal(before.HighlightedCharacterId, after.HighlightedCharacterId);
Assert.Equal(before.Operation, after.Operation);
// The second lease proves the canonical Runtime is still live, while
// the disposed adapter's already-returned observer stays detached.
Assert.True(survivor.CharacterSelectionCommands.Highlight(
generation,
0x50000001u).Accepted);
Assert.Equal(0x50000001u,
survivor.CharacterSelection.Snapshot.HighlightedCharacterId);
Assert.Empty(observer.Deltas);
Assert.Equal(
RuntimeCharacterSelectionLifecycle.Inactive,
harness.Runtime.CharacterSelection.Snapshot.Lifecycle);
}
[Fact]
public void GenerationGateRejectsStaleCommandsAndStopAcknowledgesTeardown()
{
@ -735,6 +803,7 @@ public sealed class CurrentGameRuntimeAdapterTests
private readonly LiveSessionController _session;
private readonly IDisposable _combatModeBinding;
private readonly GameRuntime _gameRuntime;
private readonly SelectionInteractionController _selectionController;
public Harness(bool awaitCharacterSelection = false)
{
@ -789,7 +858,7 @@ public sealed class CurrentGameRuntimeAdapterTests
sendWield: null,
sendDrop: null);
var query = new SelectionQuery(TargetGuid);
var selectionController = new SelectionInteractionController(
_selectionController = new SelectionInteractionController(
Selection,
query,
_items,
@ -805,7 +874,7 @@ public sealed class CurrentGameRuntimeAdapterTests
_gameRuntime,
Host,
Commands,
selectionController);
_selectionController);
}
public RuntimeOptions Options { get; }
@ -842,6 +911,13 @@ public sealed class CurrentGameRuntimeAdapterTests
public LiveSessionHost Host { get; }
public CurrentGameRuntimeAdapter Runtime { get; }
public CurrentGameRuntimeAdapter CreateAdditionalAdapter() =>
new(
_gameRuntime,
Host,
Commands,
_selectionController);
public void Dispose()
{
Runtime.Dispose();
@ -853,6 +929,25 @@ public sealed class CurrentGameRuntimeAdapterTests
}
}
private sealed class CharacterSelectionVisitor
: IRuntimeCharacterSelectionVisitor
{
public List<RuntimeCharacterSelectionEntry> Entries { get; } = [];
public void Visit(in RuntimeCharacterSelectionEntry character) =>
Entries.Add(character);
}
private sealed class CharacterSelectionObserver
: IRuntimeCharacterSelectionObserver
{
public List<RuntimeCharacterSelectionDelta> Deltas { get; } = [];
public void OnCharacterSelectionChanged(
in RuntimeCharacterSelectionDelta delta) =>
Deltas.Add(delta);
}
private static void PrepareCombatAndMagic(Harness harness)
{
harness.Actions.Combat.SetCombatMode(AcDream.Core.Combat.CombatMode.Melee);

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);

View file

@ -8,6 +8,18 @@ namespace AcDream.Runtime.Tests.Session;
public sealed class LiveSessionControllerTests
{
private sealed class ManualTimeProvider : TimeProvider
{
private long _timestamp;
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
public override long GetTimestamp() => _timestamp;
public void Advance(TimeSpan elapsed) =>
_timestamp += elapsed.Ticks;
}
private sealed class TestTransport : IWorldSessionTransport
{
public void Send(ReadOnlySpan<byte> datagram) { }
@ -466,7 +478,8 @@ public sealed class LiveSessionControllerTests
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
var time = new ManualTimeProvider();
var controller = new LiveSessionController(operations, time);
Assert.Equal(
LiveSessionStartStatus.AwaitingCharacterSelection,
controller.Start(LiveOptions(awaitSelection: true), host).Status);
@ -499,8 +512,21 @@ public sealed class LiveSessionControllerTests
// ACE may send no response for an unknown guid. The synchronous
// command has already returned and does not gate later commands.
Assert.True(controller.Highlight(generation, 0x50000002u).Accepted);
Assert.Equal(
RuntimeCommandStatus.Rejected,
controller.Restore(generation).Status);
controller.Tick();
Assert.Equal(1, operations.TickCount);
time.Advance(RuntimeCharacterSelectionState.RestoreCorrelationTimeout);
controller.Tick();
Assert.Equal(
RuntimeCharacterSelectionOperation.None,
controller.CharacterSelection.Snapshot.Operation);
Assert.True(controller.CharacterSelection.Snapshot.Buttons.CanRestore);
Assert.True(controller.Restore(generation).Accepted);
Assert.Equal(
[0x50000001u, 0x50000002u],
operations.RestoreRequests);
Assert.False(controller.IsInWorld);
}

View file

@ -23,6 +23,18 @@ public sealed class RuntimeCharacterSelectionStateTests
onDelta(delta);
}
private sealed class ManualTimeProvider : TimeProvider
{
private long _timestamp;
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
public override long GetTimestamp() => _timestamp;
public void Advance(TimeSpan elapsed) =>
_timestamp += elapsed.Ticks;
}
[Fact]
public void ApplyRoster_PortsRetailOrderFallbackAndDisabledButtonMatrix()
{
@ -154,9 +166,10 @@ public sealed class RuntimeCharacterSelectionStateTests
}
[Fact]
public void RestoreResponse_FromSupersededRequestCannotCompleteTheCurrentRequest()
public void DelayedFlagOnlyRestoreResponse_CannotCompleteNewerRequest()
{
using var state = new RuntimeCharacterSelectionState();
var time = new ManualTimeProvider();
using var state = new RuntimeCharacterSelectionState(time);
state.Begin(new RuntimeGenerationToken(12));
state.ApplyRoster(Roster(
new(0x50000001u, "First", 1u),
@ -164,18 +177,55 @@ public sealed class RuntimeCharacterSelectionStateTests
Assert.True(state.TryBeginRestore(out _));
Assert.True(state.TryHighlight(0x50000002u));
Assert.False(state.TryBeginRestore(out _));
time.Advance(RuntimeCharacterSelectionState.RestoreCorrelationTimeout);
Assert.True(state.SweepRestoreCorrelation());
Assert.True(state.TryBeginRestore(out _));
long revision = state.Snapshot.Revision;
state.ApplyRestore(new CharacterRestore.Parsed(
1u,
0x50000001u,
"First",
0u));
VerificationFlag: 2u,
Guid: null,
Name: null,
SecondsGreyedOut: null));
Assert.Equal(
RuntimeCharacterSelectionOperation.RestoreRequested,
state.Snapshot.Operation);
Assert.Equal(0x50000002u, state.Snapshot.LastRestoreRequestedCharacterId);
Assert.Equal(revision, state.Snapshot.Revision);
Assert.True(state.View.TryGet(0x50000001u, out var first));
Assert.True(first.IsPendingDelete);
Assert.True(state.View.TryGet(0x50000002u, out var second));
Assert.True(second.IsPendingDelete);
Assert.False(state.Snapshot.Buttons.CanRestore);
}
[Fact]
public void NoReplyRestoreTimeout_ReleasesOnlyRestoreGate()
{
var time = new ManualTimeProvider();
using var state = new RuntimeCharacterSelectionState(time);
state.Begin(new RuntimeGenerationToken(13));
state.ApplyRoster(Roster(
new(0x50000001u, "Pending", 1u),
new(0x50000002u, "Ready", 0u)));
Assert.True(state.TryHighlight(0x50000001u));
Assert.True(state.TryBeginRestore(out _));
Assert.False(state.Snapshot.Buttons.CanRestore);
Assert.True(state.TryHighlight(0x50000002u));
Assert.True(state.BeginEnter(out var ready));
Assert.Equal(0x50000002u, ready.CharacterId);
state.ReturnToSelection();
Assert.True(state.TryHighlight(0x50000001u));
Assert.True(state.TryBeginRestore(out _));
time.Advance(RuntimeCharacterSelectionState.RestoreCorrelationTimeout);
Assert.True(state.SweepRestoreCorrelation());
Assert.Equal(
RuntimeCharacterSelectionOperation.None,
state.Snapshot.Operation);
Assert.True(state.Snapshot.Buttons.CanRestore);
}
[Fact]