fix(runtime): close Campaign LA7b review findings
This commit is contained in:
parent
0e82cbf700
commit
1b9e7e41f9
11 changed files with 700 additions and 28 deletions
|
|
@ -20,6 +20,7 @@ internal sealed class CurrentGameRuntimeAdapter
|
||||||
{
|
{
|
||||||
private readonly GameRuntime _runtime;
|
private readonly GameRuntime _runtime;
|
||||||
private readonly CurrentGameRuntimeCommandAdapter _commands;
|
private readonly CurrentGameRuntimeCommandAdapter _commands;
|
||||||
|
private readonly CharacterSelectionProjection _characterSelection;
|
||||||
private readonly IDisposable _hostLease;
|
private readonly IDisposable _hostLease;
|
||||||
private readonly object _subscriptionGate = new();
|
private readonly object _subscriptionGate = new();
|
||||||
private readonly HashSet<AdapterSubscription> _subscriptions = [];
|
private readonly HashSet<AdapterSubscription> _subscriptions = [];
|
||||||
|
|
@ -40,6 +41,7 @@ internal sealed class CurrentGameRuntimeAdapter
|
||||||
"graphical game-runtime command adapter");
|
"graphical game-runtime command adapter");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
_characterSelection = new CharacterSelectionProjection(this);
|
||||||
_commands = new CurrentGameRuntimeCommandAdapter(
|
_commands = new CurrentGameRuntimeCommandAdapter(
|
||||||
runtime.Session,
|
runtime.Session,
|
||||||
sessionHost,
|
sessionHost,
|
||||||
|
|
@ -61,7 +63,7 @@ internal sealed class CurrentGameRuntimeAdapter
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsActive =>
|
private bool IsActive =>
|
||||||
!_disposed
|
!Volatile.Read(ref _disposed)
|
||||||
&& !_runtime.Session.IsDisposalComplete;
|
&& !_runtime.Session.IsDisposalComplete;
|
||||||
|
|
||||||
public RuntimeGenerationToken Generation => _runtime.Generation;
|
public RuntimeGenerationToken Generation => _runtime.Generation;
|
||||||
|
|
@ -90,7 +92,7 @@ internal sealed class CurrentGameRuntimeAdapter
|
||||||
public IRuntimeSocialView Social => _runtime.Social;
|
public IRuntimeSocialView Social => _runtime.Social;
|
||||||
public IRuntimeChatView Chat => _runtime.Chat;
|
public IRuntimeChatView Chat => _runtime.Chat;
|
||||||
public IRuntimeCharacterSelectionView CharacterSelection =>
|
public IRuntimeCharacterSelectionView CharacterSelection =>
|
||||||
_runtime.CharacterSelection;
|
_characterSelection;
|
||||||
public IRuntimeFellowshipView Fellowship => _runtime.Fellowship;
|
public IRuntimeFellowshipView Fellowship => _runtime.Fellowship;
|
||||||
public IRuntimeAllegianceView Allegiance => _runtime.Allegiance;
|
public IRuntimeAllegianceView Allegiance => _runtime.Allegiance;
|
||||||
public IRuntimeActionView Actions => _runtime.Actions;
|
public IRuntimeActionView Actions => _runtime.Actions;
|
||||||
|
|
@ -100,9 +102,9 @@ internal sealed class CurrentGameRuntimeAdapter
|
||||||
|
|
||||||
public IRuntimeSessionCommands Session => _commands;
|
public IRuntimeSessionCommands Session => _commands;
|
||||||
public IRuntimeCharacterSelectionCommands CharacterSelectionCommands =>
|
public IRuntimeCharacterSelectionCommands CharacterSelectionCommands =>
|
||||||
_runtime.Session;
|
_characterSelection;
|
||||||
IRuntimeCharacterSelectionCommands IGameRuntimeCommands.CharacterSelection =>
|
IRuntimeCharacterSelectionCommands IGameRuntimeCommands.CharacterSelection =>
|
||||||
_runtime.Session;
|
_characterSelection;
|
||||||
public IRuntimeSelectionCommands Selection => _commands;
|
public IRuntimeSelectionCommands Selection => _commands;
|
||||||
public IRuntimeCombatCommands Combat => _commands;
|
public IRuntimeCombatCommands Combat => _commands;
|
||||||
public IRuntimeMagicCommands Magic => _commands;
|
public IRuntimeMagicCommands Magic => _commands;
|
||||||
|
|
@ -166,6 +168,187 @@ internal sealed class CurrentGameRuntimeAdapter
|
||||||
_subscriptions.Remove(subscription);
|
_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(
|
private sealed class AdapterSubscription(
|
||||||
CurrentGameRuntimeAdapter owner,
|
CurrentGameRuntimeAdapter owner,
|
||||||
IDisposable runtimeSubscription) : IDisposable
|
IDisposable runtimeSubscription) : IDisposable
|
||||||
|
|
|
||||||
|
|
@ -1125,11 +1125,12 @@ public sealed class WorldSession : IDisposable
|
||||||
{
|
{
|
||||||
var opcodes = new List<uint>();
|
var opcodes = new List<uint>();
|
||||||
ProcessDatagram(datagram.Memory, opcodes);
|
ProcessDatagram(datagram.Memory, opcodes);
|
||||||
SweepTransport();
|
|
||||||
return opcodes.Contains(0xF7DFu)
|
return opcodes.Contains(0xF7DFu)
|
||||||
|| _lastCharacterSelectionError is not null;
|
|| _lastCharacterSelectionError is not null;
|
||||||
},
|
},
|
||||||
ReturnInboundDatagram);
|
ReturnInboundDatagram,
|
||||||
|
SweepTransport,
|
||||||
|
TimeSpan.FromMilliseconds(25));
|
||||||
}
|
}
|
||||||
if (_lastCharacterSelectionError is { } selectionError)
|
if (_lastCharacterSelectionError is { } selectionError)
|
||||||
{
|
{
|
||||||
|
|
@ -1817,6 +1818,12 @@ public sealed class WorldSession : IDisposable
|
||||||
{
|
{
|
||||||
continue;
|
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;
|
_lastCharacterSelectionError = parsed;
|
||||||
CharacterErrorReceived?.Invoke(parsed);
|
CharacterErrorReceived?.Invoke(parsed);
|
||||||
}
|
}
|
||||||
|
|
@ -3339,10 +3346,15 @@ public sealed class WorldSession : IDisposable
|
||||||
ChannelReader<T> reader,
|
ChannelReader<T> reader,
|
||||||
TimeSpan timeout,
|
TimeSpan timeout,
|
||||||
Func<T, bool> processAndCheckConfirmation,
|
Func<T, bool> processAndCheckConfirmation,
|
||||||
Action<T>? release = null)
|
Action<T>? release = null,
|
||||||
|
Action? periodicWork = null,
|
||||||
|
TimeSpan? periodicInterval = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(reader);
|
ArgumentNullException.ThrowIfNull(reader);
|
||||||
ArgumentNullException.ThrowIfNull(processAndCheckConfirmation);
|
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);
|
using var timeoutSource = new CancellationTokenSource(timeout);
|
||||||
|
|
||||||
// The deadline is also read straight off the monotonic clock, not only
|
// The deadline is also read straight off the monotonic clock, not only
|
||||||
|
|
@ -3382,10 +3394,52 @@ public sealed class WorldSession : IDisposable
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool canRead = reader.WaitToReadAsync(timeoutSource.Token)
|
periodicWork?.Invoke();
|
||||||
.AsTask()
|
if (timeoutSource.IsCancellationRequested || Expired())
|
||||||
.GetAwaiter()
|
return false;
|
||||||
.GetResult();
|
|
||||||
|
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)
|
if (!canRead)
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -178,8 +178,12 @@ public sealed class GameRuntime
|
||||||
faultInjection);
|
faultInjection);
|
||||||
|
|
||||||
context.Session = dependencies.SessionOperations is null
|
context.Session = dependencies.SessionOperations is null
|
||||||
? new LiveSessionController()
|
? new LiveSessionController(
|
||||||
: new LiveSessionController(dependencies.SessionOperations);
|
ProductionLiveSessionOperations.Instance,
|
||||||
|
dependencies.TimeProvider)
|
||||||
|
: new LiveSessionController(
|
||||||
|
dependencies.SessionOperations,
|
||||||
|
dependencies.TimeProvider);
|
||||||
construction.Own(context.Session);
|
construction.Own(context.Session);
|
||||||
Fault(
|
Fault(
|
||||||
GameRuntimeConstructionPoint.SessionCreated,
|
GameRuntimeConstructionPoint.SessionCreated,
|
||||||
|
|
|
||||||
|
|
@ -376,14 +376,17 @@ public sealed class LiveSessionController
|
||||||
private Action<WorldSession>? _preLogoffFlushHook;
|
private Action<WorldSession>? _preLogoffFlushHook;
|
||||||
|
|
||||||
public LiveSessionController()
|
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));
|
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
|
||||||
CharacterSelectionState = new RuntimeCharacterSelectionState();
|
CharacterSelectionState = new RuntimeCharacterSelectionState(
|
||||||
|
timeProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
public RuntimeCharacterSelectionState CharacterSelectionState { get; }
|
public RuntimeCharacterSelectionState CharacterSelectionState { get; }
|
||||||
|
|
@ -583,6 +586,7 @@ public sealed class LiveSessionController
|
||||||
_operations.Tick(scope.Session);
|
_operations.Tick(scope.Session);
|
||||||
if (!IsCurrent(scope, generation))
|
if (!IsCurrent(scope, generation))
|
||||||
return;
|
return;
|
||||||
|
CharacterSelectionState.SweepRestoreCorrelation();
|
||||||
}
|
}
|
||||||
catch (Exception tickError)
|
catch (Exception tickError)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ public enum RuntimeCharacterSelectionDeltaKind
|
||||||
DeleteAcknowledged,
|
DeleteAcknowledged,
|
||||||
RestoreRequested,
|
RestoreRequested,
|
||||||
RestoreCompleted,
|
RestoreCompleted,
|
||||||
|
RestoreCorrelationExpired,
|
||||||
ErrorChanged,
|
ErrorChanged,
|
||||||
EnteringWorld,
|
EnteringWorld,
|
||||||
EnteredWorld,
|
EnteredWorld,
|
||||||
|
|
@ -165,6 +166,9 @@ public interface IRuntimeCharacterSelectionCommands
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RuntimeCharacterSelectionState : IDisposable
|
public sealed class RuntimeCharacterSelectionState : IDisposable
|
||||||
{
|
{
|
||||||
|
internal static readonly TimeSpan RestoreCorrelationTimeout =
|
||||||
|
TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
private sealed class ViewProjection(RuntimeCharacterSelectionState owner)
|
private sealed class ViewProjection(RuntimeCharacterSelectionState owner)
|
||||||
: IRuntimeCharacterSelectionView
|
: IRuntimeCharacterSelectionView
|
||||||
{
|
{
|
||||||
|
|
@ -191,6 +195,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private readonly RuntimeCharacterSelectionEventStream _events = new();
|
private readonly RuntimeCharacterSelectionEventStream _events = new();
|
||||||
private readonly ViewProjection _view;
|
private readonly ViewProjection _view;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
private RuntimeCharacterSelectionEntry[] _entries = [];
|
private RuntimeCharacterSelectionEntry[] _entries = [];
|
||||||
private RuntimeGenerationToken _generation;
|
private RuntimeGenerationToken _generation;
|
||||||
private RuntimeCharacterSelectionLifecycle _lifecycle;
|
private RuntimeCharacterSelectionLifecycle _lifecycle;
|
||||||
|
|
@ -201,12 +206,15 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
|
||||||
private uint _pendingDeleteCharacterId;
|
private uint _pendingDeleteCharacterId;
|
||||||
private uint _lastRestoreRequestedCharacterId;
|
private uint _lastRestoreRequestedCharacterId;
|
||||||
private bool _restoreResponseArmed;
|
private bool _restoreResponseArmed;
|
||||||
|
private long _restoreCorrelationStartedTimestamp;
|
||||||
|
private bool _flagOnlyRestoreResponseAmbiguous;
|
||||||
private RuntimeCharacterSelectionOperation _operation;
|
private RuntimeCharacterSelectionOperation _operation;
|
||||||
private RuntimeCharacterSelectionError? _error;
|
private RuntimeCharacterSelectionError? _error;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
public RuntimeCharacterSelectionState()
|
public RuntimeCharacterSelectionState(TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
_view = new ViewProjection(this);
|
_view = new ViewProjection(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -303,6 +311,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
|
||||||
_pendingDeleteCharacterId = 0u;
|
_pendingDeleteCharacterId = 0u;
|
||||||
_lastRestoreRequestedCharacterId = 0u;
|
_lastRestoreRequestedCharacterId = 0u;
|
||||||
_restoreResponseArmed = false;
|
_restoreResponseArmed = false;
|
||||||
|
_restoreCorrelationStartedTimestamp = 0;
|
||||||
|
_flagOnlyRestoreResponseAmbiguous = false;
|
||||||
_operation = RuntimeCharacterSelectionOperation.None;
|
_operation = RuntimeCharacterSelectionOperation.None;
|
||||||
_error = null;
|
_error = null;
|
||||||
_highlightedCharacterId = Contains(previous)
|
_highlightedCharacterId = Contains(previous)
|
||||||
|
|
@ -408,6 +418,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
|
||||||
if (_disposed
|
if (_disposed
|
||||||
|| _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection
|
|| _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection
|
||||||
|| HasDeleteModalOrRequest()
|
|| HasDeleteModalOrRequest()
|
||||||
|
|| _restoreResponseArmed
|
||||||
|| index < 0
|
|| index < 0
|
||||||
|| !_entries[index].IsPendingDelete)
|
|| !_entries[index].IsPendingDelete)
|
||||||
{
|
{
|
||||||
|
|
@ -419,6 +430,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
|
||||||
_pendingDeleteCharacterId = 0u;
|
_pendingDeleteCharacterId = 0u;
|
||||||
_lastRestoreRequestedCharacterId = character.CharacterId;
|
_lastRestoreRequestedCharacterId = character.CharacterId;
|
||||||
_restoreResponseArmed = true;
|
_restoreResponseArmed = true;
|
||||||
|
_restoreCorrelationStartedTimestamp =
|
||||||
|
_timeProvider.GetTimestamp();
|
||||||
_operation = RuntimeCharacterSelectionOperation.RestoreRequested;
|
_operation = RuntimeCharacterSelectionOperation.RestoreRequested;
|
||||||
_error = null;
|
_error = null;
|
||||||
_revision++;
|
_revision++;
|
||||||
|
|
@ -429,6 +442,39 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
|
||||||
return true;
|
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()
|
internal bool Cancel()
|
||||||
{
|
{
|
||||||
RuntimeCharacterSelectionDeltaKind kind;
|
RuntimeCharacterSelectionDeltaKind kind;
|
||||||
|
|
@ -493,12 +539,18 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
|
||||||
|| _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection
|
|| _lifecycle != RuntimeCharacterSelectionLifecycle.AwaitingSelection
|
||||||
|| !_restoreResponseArmed)
|
|| !_restoreResponseArmed)
|
||||||
return;
|
return;
|
||||||
|
if (response.Guid is null
|
||||||
|
&& _flagOnlyRestoreResponseAmbiguous)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (response.Guid is { } responseGuid
|
if (response.Guid is { } responseGuid
|
||||||
&& responseGuid != _lastRestoreRequestedCharacterId)
|
&& responseGuid != _lastRestoreRequestedCharacterId)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_restoreResponseArmed = false;
|
_restoreResponseArmed = false;
|
||||||
|
_restoreCorrelationStartedTimestamp = 0;
|
||||||
characterId = response.Guid
|
characterId = response.Guid
|
||||||
?? _lastRestoreRequestedCharacterId;
|
?? _lastRestoreRequestedCharacterId;
|
||||||
|
|
||||||
|
|
@ -555,7 +607,10 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
|
||||||
or RuntimeCharacterSelectionLifecycle.EnteringWorld))
|
or RuntimeCharacterSelectionLifecycle.EnteringWorld))
|
||||||
return;
|
return;
|
||||||
_pendingDeleteCharacterId = 0u;
|
_pendingDeleteCharacterId = 0u;
|
||||||
|
if (_restoreResponseArmed)
|
||||||
|
_flagOnlyRestoreResponseAmbiguous = true;
|
||||||
_restoreResponseArmed = false;
|
_restoreResponseArmed = false;
|
||||||
|
_restoreCorrelationStartedTimestamp = 0;
|
||||||
_operation = RuntimeCharacterSelectionOperation.None;
|
_operation = RuntimeCharacterSelectionOperation.None;
|
||||||
_error = new RuntimeCharacterSelectionError(
|
_error = new RuntimeCharacterSelectionError(
|
||||||
error.RawErrorCode,
|
error.RawErrorCode,
|
||||||
|
|
@ -588,6 +643,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
|
||||||
_pendingDeleteCharacterId = 0u;
|
_pendingDeleteCharacterId = 0u;
|
||||||
_lastRestoreRequestedCharacterId = 0u;
|
_lastRestoreRequestedCharacterId = 0u;
|
||||||
_restoreResponseArmed = false;
|
_restoreResponseArmed = false;
|
||||||
|
_restoreCorrelationStartedTimestamp = 0;
|
||||||
|
_flagOnlyRestoreResponseAmbiguous = false;
|
||||||
_operation = RuntimeCharacterSelectionOperation.None;
|
_operation = RuntimeCharacterSelectionOperation.None;
|
||||||
_error = null;
|
_error = null;
|
||||||
_lifecycle = RuntimeCharacterSelectionLifecycle.EnteringWorld;
|
_lifecycle = RuntimeCharacterSelectionLifecycle.EnteringWorld;
|
||||||
|
|
@ -717,7 +774,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
|
||||||
return new RuntimeCharacterSelectionButtons(
|
return new RuntimeCharacterSelectionButtons(
|
||||||
CanEnter: false,
|
CanEnter: false,
|
||||||
CanDelete: false,
|
CanDelete: false,
|
||||||
CanRestore: true,
|
CanRestore: !_restoreResponseArmed,
|
||||||
DeleteVisible: false,
|
DeleteVisible: false,
|
||||||
RestoreVisible: true);
|
RestoreVisible: true);
|
||||||
}
|
}
|
||||||
|
|
@ -833,6 +890,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
|
||||||
_pendingDeleteCharacterId = 0u;
|
_pendingDeleteCharacterId = 0u;
|
||||||
_lastRestoreRequestedCharacterId = 0u;
|
_lastRestoreRequestedCharacterId = 0u;
|
||||||
_restoreResponseArmed = false;
|
_restoreResponseArmed = false;
|
||||||
|
_restoreCorrelationStartedTimestamp = 0;
|
||||||
|
_flagOnlyRestoreResponseAmbiguous = false;
|
||||||
_operation = RuntimeCharacterSelectionOperation.None;
|
_operation = RuntimeCharacterSelectionOperation.None;
|
||||||
_error = null;
|
_error = null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,74 @@ public sealed class CurrentGameRuntimeAdapterTests
|
||||||
Assert.Equal(Harness.PlayerGuid, harness.Identity.ServerGuid);
|
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]
|
[Fact]
|
||||||
public void GenerationGateRejectsStaleCommandsAndStopAcknowledgesTeardown()
|
public void GenerationGateRejectsStaleCommandsAndStopAcknowledgesTeardown()
|
||||||
{
|
{
|
||||||
|
|
@ -735,6 +803,7 @@ public sealed class CurrentGameRuntimeAdapterTests
|
||||||
private readonly LiveSessionController _session;
|
private readonly LiveSessionController _session;
|
||||||
private readonly IDisposable _combatModeBinding;
|
private readonly IDisposable _combatModeBinding;
|
||||||
private readonly GameRuntime _gameRuntime;
|
private readonly GameRuntime _gameRuntime;
|
||||||
|
private readonly SelectionInteractionController _selectionController;
|
||||||
|
|
||||||
public Harness(bool awaitCharacterSelection = false)
|
public Harness(bool awaitCharacterSelection = false)
|
||||||
{
|
{
|
||||||
|
|
@ -789,7 +858,7 @@ public sealed class CurrentGameRuntimeAdapterTests
|
||||||
sendWield: null,
|
sendWield: null,
|
||||||
sendDrop: null);
|
sendDrop: null);
|
||||||
var query = new SelectionQuery(TargetGuid);
|
var query = new SelectionQuery(TargetGuid);
|
||||||
var selectionController = new SelectionInteractionController(
|
_selectionController = new SelectionInteractionController(
|
||||||
Selection,
|
Selection,
|
||||||
query,
|
query,
|
||||||
_items,
|
_items,
|
||||||
|
|
@ -805,7 +874,7 @@ public sealed class CurrentGameRuntimeAdapterTests
|
||||||
_gameRuntime,
|
_gameRuntime,
|
||||||
Host,
|
Host,
|
||||||
Commands,
|
Commands,
|
||||||
selectionController);
|
_selectionController);
|
||||||
}
|
}
|
||||||
|
|
||||||
public RuntimeOptions Options { get; }
|
public RuntimeOptions Options { get; }
|
||||||
|
|
@ -842,6 +911,13 @@ public sealed class CurrentGameRuntimeAdapterTests
|
||||||
public LiveSessionHost Host { get; }
|
public LiveSessionHost Host { get; }
|
||||||
public CurrentGameRuntimeAdapter Runtime { get; }
|
public CurrentGameRuntimeAdapter Runtime { get; }
|
||||||
|
|
||||||
|
public CurrentGameRuntimeAdapter CreateAdditionalAdapter() =>
|
||||||
|
new(
|
||||||
|
_gameRuntime,
|
||||||
|
Host,
|
||||||
|
Commands,
|
||||||
|
_selectionController);
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
Runtime.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)
|
private static void PrepareCombatAndMagic(Harness harness)
|
||||||
{
|
{
|
||||||
harness.Actions.Combat.SetCombatMode(AcDream.Core.Combat.CombatMode.Melee);
|
harness.Actions.Combat.SetCombatMode(AcDream.Core.Combat.CombatMode.Melee);
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,12 @@ internal sealed class FakeAceTransport : IWorldSessionTransport
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public TimeSpan AutoAdvanceOnBlockingReceive { get; set; }
|
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)
|
public FakeAceTransport(VirtualClock? clock = null, LossyLink? link = null)
|
||||||
{
|
{
|
||||||
Clock = clock ?? new VirtualClock();
|
Clock = clock ?? new VirtualClock();
|
||||||
|
|
@ -101,7 +107,12 @@ internal sealed class FakeAceTransport : IWorldSessionTransport
|
||||||
case CharacterEnterWorld.EnterWorldRequestOpcode: // 0xF7C8
|
case CharacterEnterWorld.EnterWorldRequestOpcode: // 0xF7C8
|
||||||
// Server replies CharacterEnterWorldServerReady (0xF7DF) —
|
// Server replies CharacterEnterWorldServerReady (0xF7DF) —
|
||||||
// WorldSession.EnterWorld blocks on this opcode.
|
// WorldSession.EnterWorld blocks on this opcode.
|
||||||
Model.EnqueueGameMessage(BuildOpcodeOnlyBody(0xF7DFu), GameMessageGroup.UIQueue);
|
if (AutoReplyServerReady)
|
||||||
|
{
|
||||||
|
Model.EnqueueGameMessage(
|
||||||
|
BuildOpcodeOnlyBody(0xF7DFu),
|
||||||
|
GameMessageGroup.UIQueue);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case CharacterLogOff.Opcode: // 0xF653 request (opcode + character id)
|
case CharacterLogOff.Opcode: // 0xF653 request (opcode + character id)
|
||||||
// ACE echoes the opcode-only confirmation; WorldSession.Dispose
|
// 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>
|
/// <summary>
|
||||||
/// N2 test hook: deliver raw bytes straight into the client's receive
|
/// N2 test hook: deliver raw bytes straight into the client's receive
|
||||||
/// queue, bypassing both the model and the link. Used for late
|
/// queue, bypassing both the model and the link. Used for late
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using System.Buffers.Binary;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Core.Net.Packets;
|
using AcDream.Core.Net.Packets;
|
||||||
|
using AcDream.Core.Net.Transport;
|
||||||
|
|
||||||
namespace AcDream.Core.Net.Tests.Transport;
|
namespace AcDream.Core.Net.Tests.Transport;
|
||||||
|
|
||||||
|
|
@ -165,6 +166,105 @@ public sealed class FakeAceTransportTests
|
||||||
Assert.Equal(0, transport.Model.CrcDropCount);
|
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) =>
|
private static uint ReadOpcode(byte[] messageBody) =>
|
||||||
BinaryPrimitives.ReadUInt32LittleEndian(messageBody);
|
BinaryPrimitives.ReadUInt32LittleEndian(messageBody);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using System.Net;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Core.Net.Packets;
|
using AcDream.Core.Net.Packets;
|
||||||
|
using AcDream.Core.Net.Tests.Transport;
|
||||||
|
|
||||||
namespace AcDream.Core.Net.Tests;
|
namespace AcDream.Core.Net.Tests;
|
||||||
|
|
||||||
|
|
@ -91,11 +92,80 @@ public sealed class WorldSessionCharacterSelectionTests
|
||||||
Assert.Equal(1u, current.SecondsGreyedOut);
|
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() =>
|
private static WorldSession CreateSession() =>
|
||||||
new(
|
new(
|
||||||
new IPEndPoint(IPAddress.Loopback, 9000),
|
new IPEndPoint(IPAddress.Loopback, 9000),
|
||||||
new NullTransport());
|
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)
|
private static byte[] BuildRoster(uint secondsGreyedOut)
|
||||||
{
|
{
|
||||||
var writer = new PacketWriter(96);
|
var writer = new PacketWriter(96);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,18 @@ namespace AcDream.Runtime.Tests.Session;
|
||||||
|
|
||||||
public sealed class LiveSessionControllerTests
|
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
|
private sealed class TestTransport : IWorldSessionTransport
|
||||||
{
|
{
|
||||||
public void Send(ReadOnlySpan<byte> datagram) { }
|
public void Send(ReadOnlySpan<byte> datagram) { }
|
||||||
|
|
@ -466,7 +478,8 @@ public sealed class LiveSessionControllerTests
|
||||||
var calls = new List<string>();
|
var calls = new List<string>();
|
||||||
var operations = new TestOperations(calls);
|
var operations = new TestOperations(calls);
|
||||||
var host = new TestHost(calls);
|
var host = new TestHost(calls);
|
||||||
var controller = new LiveSessionController(operations);
|
var time = new ManualTimeProvider();
|
||||||
|
var controller = new LiveSessionController(operations, time);
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
LiveSessionStartStatus.AwaitingCharacterSelection,
|
LiveSessionStartStatus.AwaitingCharacterSelection,
|
||||||
controller.Start(LiveOptions(awaitSelection: true), host).Status);
|
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
|
// ACE may send no response for an unknown guid. The synchronous
|
||||||
// command has already returned and does not gate later commands.
|
// command has already returned and does not gate later commands.
|
||||||
Assert.True(controller.Highlight(generation, 0x50000002u).Accepted);
|
Assert.True(controller.Highlight(generation, 0x50000002u).Accepted);
|
||||||
|
Assert.Equal(
|
||||||
|
RuntimeCommandStatus.Rejected,
|
||||||
|
controller.Restore(generation).Status);
|
||||||
controller.Tick();
|
controller.Tick();
|
||||||
Assert.Equal(1, operations.TickCount);
|
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);
|
Assert.False(controller.IsInWorld);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,18 @@ public sealed class RuntimeCharacterSelectionStateTests
|
||||||
onDelta(delta);
|
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]
|
[Fact]
|
||||||
public void ApplyRoster_PortsRetailOrderFallbackAndDisabledButtonMatrix()
|
public void ApplyRoster_PortsRetailOrderFallbackAndDisabledButtonMatrix()
|
||||||
{
|
{
|
||||||
|
|
@ -154,9 +166,10 @@ public sealed class RuntimeCharacterSelectionStateTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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.Begin(new RuntimeGenerationToken(12));
|
||||||
state.ApplyRoster(Roster(
|
state.ApplyRoster(Roster(
|
||||||
new(0x50000001u, "First", 1u),
|
new(0x50000001u, "First", 1u),
|
||||||
|
|
@ -164,18 +177,55 @@ public sealed class RuntimeCharacterSelectionStateTests
|
||||||
|
|
||||||
Assert.True(state.TryBeginRestore(out _));
|
Assert.True(state.TryBeginRestore(out _));
|
||||||
Assert.True(state.TryHighlight(0x50000002u));
|
Assert.True(state.TryHighlight(0x50000002u));
|
||||||
|
Assert.False(state.TryBeginRestore(out _));
|
||||||
|
time.Advance(RuntimeCharacterSelectionState.RestoreCorrelationTimeout);
|
||||||
|
Assert.True(state.SweepRestoreCorrelation());
|
||||||
Assert.True(state.TryBeginRestore(out _));
|
Assert.True(state.TryBeginRestore(out _));
|
||||||
|
long revision = state.Snapshot.Revision;
|
||||||
state.ApplyRestore(new CharacterRestore.Parsed(
|
state.ApplyRestore(new CharacterRestore.Parsed(
|
||||||
1u,
|
VerificationFlag: 2u,
|
||||||
0x50000001u,
|
Guid: null,
|
||||||
"First",
|
Name: null,
|
||||||
0u));
|
SecondsGreyedOut: null));
|
||||||
|
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
RuntimeCharacterSelectionOperation.RestoreRequested,
|
RuntimeCharacterSelectionOperation.RestoreRequested,
|
||||||
state.Snapshot.Operation);
|
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(state.View.TryGet(0x50000001u, out var first));
|
||||||
Assert.True(first.IsPendingDelete);
|
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]
|
[Fact]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue