diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs index 56e89e2a..6e656da7 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs @@ -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 _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 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 diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index cbe68e1c..8cfab9f5 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1125,11 +1125,12 @@ public sealed class WorldSession : IDisposable { var opcodes = new List(); 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 reader, TimeSpan timeout, Func processAndCheckConfirmation, - Action? release = null) + Action? 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; } diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index 5b6e7e60..fadf5bd9 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -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, diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index b7c37555..8dae81a4 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -376,14 +376,17 @@ public sealed class LiveSessionController private Action? _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) { diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs index db55e783..f88895bd 100644 --- a/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs +++ b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs @@ -32,6 +32,7 @@ public enum RuntimeCharacterSelectionDeltaKind DeleteAcknowledged, RestoreRequested, RestoreCompleted, + RestoreCorrelationExpired, ErrorChanged, EnteringWorld, EnteredWorld, @@ -165,6 +166,9 @@ public interface IRuntimeCharacterSelectionCommands /// 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; } + /// + /// 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. + /// + 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; } diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index 493dd5d8..24e6948c 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -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(() => + 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 Entries { get; } = []; + + public void Visit(in RuntimeCharacterSelectionEntry character) => + Entries.Add(character); + } + + private sealed class CharacterSelectionObserver + : IRuntimeCharacterSelectionObserver + { + public List 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); diff --git a/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransport.cs b/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransport.cs index 480b78be..4810ec05 100644 --- a/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransport.cs +++ b/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransport.cs @@ -74,6 +74,12 @@ internal sealed class FakeAceTransport : IWorldSessionTransport /// public TimeSpan AutoAdvanceOnBlockingReceive { get; set; } + /// + /// Keeps the default ACE script intact while allowing focused handshake + /// tests to enqueue messages before the valid ServerReady response. + /// + 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 } } + /// + /// 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. + /// + public void EnqueueServerGameMessage( + byte[] body, + GameMessageGroup group) + { + lock (_gate) + { + Model.EnqueueGameMessage(body, group); + PumpServerLocked(); + } + } + /// /// N2 test hook: deliver raw bytes straight into the client's receive /// queue, bypassing both the model and the link. Used for late diff --git a/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs b/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs index 0c817f0c..2d79544f 100644 --- a/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs +++ b/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs @@ -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); diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs index 04ac26b4..71d5f787 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs @@ -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); diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs index 124190b3..2f0fe01d 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs @@ -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 datagram) { } @@ -466,7 +478,8 @@ public sealed class LiveSessionControllerTests var calls = new List(); 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); } diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs index 2b5f43e1..c8b79b30 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs @@ -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]