From 4f31a5085f3e3b07f418933acc9a752cc8d87f77 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 12:00:48 +0200 Subject: [PATCH] refactor(net): converge live session state reset --- .../Combat/CombatAttackController.cs | 13 + .../SelectionInteractionController.cs | 18 +- .../Net/LiveSessionResetManifest.cs | 127 ++++++ src/AcDream.App/Net/LiveSessionResetPlan.cs | 79 ++++ src/AcDream.App/Rendering/CameraController.cs | 6 +- src/AcDream.App/Rendering/GameWindow.cs | 168 ++++++-- .../UI/GameplayConfirmationController.cs | 11 + src/AcDream.App/UI/InteractionState.cs | 25 ++ .../UI/ItemInteractionController.cs | 54 ++- .../UI/Layout/RetailDialogFactory.cs | 55 ++- src/AcDream.App/UI/RetailUiRuntime.cs | 19 + src/AcDream.Core/Chat/ChatLog.cs | 17 +- src/AcDream.Core/Chat/TurbineChatState.cs | 27 ++ src/AcDream.Core/Combat/CombatState.cs | 17 +- .../Items/ExternalContainerState.cs | 22 +- src/AcDream.Core/Player/LocalPlayerState.cs | 33 ++ src/AcDream.Core/Selection/SelectionState.cs | 28 +- src/AcDream.Core/Social/SquelchState.cs | 11 + .../Panels/Chat/ChatVM.cs | 11 + .../Panels/Vitals/VitalsVM.cs | 4 +- .../Combat/CombatAttackControllerTests.cs | 28 ++ .../Net/LiveSessionResetPlanTests.cs | 387 ++++++++++++++++++ .../Rendering/CameraControllerTests.cs | 29 ++ .../UI/GameplayConfirmationControllerTests.cs | 21 + .../UI/InteractionStateTests.cs | 27 ++ .../UI/ItemInteractionControllerTests.cs | 27 ++ .../UI/Layout/RetailDialogFactoryTests.cs | 68 +++ .../Chat/ChatLogLocalGuidTests.cs | 23 ++ .../Chat/TurbineChatStateTests.cs | 26 ++ .../Combat/CombatStateTests.cs | 27 ++ .../Items/ExternalContainerStateTests.cs | 26 ++ .../Player/LocalPlayerStateTests.cs | 41 ++ .../Selection/SelectionStateTests.cs | 27 ++ .../Social/SocialStateTests.cs | 24 ++ .../Chat/ChatVMRetellAndProvidersTests.cs | 17 + 35 files changed, 1460 insertions(+), 83 deletions(-) create mode 100644 src/AcDream.App/Net/LiveSessionResetManifest.cs create mode 100644 src/AcDream.App/Net/LiveSessionResetPlan.cs create mode 100644 tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs diff --git a/src/AcDream.App/Combat/CombatAttackController.cs b/src/AcDream.App/Combat/CombatAttackController.cs index 88680892..047aa8b2 100644 --- a/src/AcDream.App/Combat/CombatAttackController.cs +++ b/src/AcDream.App/Combat/CombatAttackController.cs @@ -403,6 +403,19 @@ public sealed class CombatAttackController : IDisposable ResetPowerBar(); } + /// + /// Restore the process-lived attack controller to retail's new-session + /// defaults without sending a cancel packet through the displaced session. + /// Mirrors ClientCombatSystem::Begin @ 0x0056A460. + /// + public void ResetSession() + { + Reset(); + RequestedHeight = AttackHeight.Medium; + DesiredPower = InitialDesiredPower; + StateChanged?.Invoke(); + } + public void Dispose() { if (_disposed) return; diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs index 430d1258..47def6ee 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionController.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -386,11 +386,21 @@ internal sealed class SelectionInteractionController public void ResetSession() { - CancelPendingApproach(); - _items.ResetSession(); - _selection.Reset(); - _outbound.Clear(); + List failures = []; + try { CancelPendingApproach(); } + catch (Exception error) { failures.Add(error); } + try { _items.ResetSession(); } + catch (Exception error) { failures.Add(error); } + try { _selection.Reset(); } + catch (Exception error) { failures.Add(error); } + try { _outbound.Clear(); } + catch (Exception error) { failures.Add(error); } _pendingPostArrival = null; + + if (failures.Count != 0) + throw new AggregateException( + "One or more selection-interaction reset stages failed.", + failures); } private bool ValidatePickupTarget(uint serverGuid, bool showToast) diff --git a/src/AcDream.App/Net/LiveSessionResetManifest.cs b/src/AcDream.App/Net/LiveSessionResetManifest.cs new file mode 100644 index 00000000..9c6c3191 --- /dev/null +++ b/src/AcDream.App/Net/LiveSessionResetManifest.cs @@ -0,0 +1,127 @@ +namespace AcDream.App.Net; + +using AcDream.App.World; + +/// +/// Focused owner operations composed by the App host for one character-session +/// reset. Mixed world/static registries are deliberately absent: their +/// session-owned entries leave through canonical live-entity teardown. +/// +internal sealed class LiveSessionResetBindings +{ + public required Action MouseCapture { get; init; } + public required Action PlayerPresentation { get; init; } + public required Action TeleportTransit { get; init; } + public required Action SessionDialogs { get; init; } + public required Action ChatCommandTargets { get; init; } + public required Action SettingsCharacterContext { get; init; } + public required Action EquippedChildren { get; init; } + public required Action ExternalContainer { get; init; } + public required Action InteractionAndSelection { get; init; } + public required Action SelectionPresentation { get; init; } + public required Action ObjectTable { get; init; } + public required Action Spellbook { get; init; } + public required Action MagicRuntime { get; init; } + public required Action CombatAttack { get; init; } + public required Action CombatState { get; init; } + public required Action ItemMana { get; init; } + public required Action LocalPlayer { get; init; } + public required Action Friends { get; init; } + public required Action Squelch { get; init; } + public required Action TurbineChat { get; init; } + public required Action ParticleVisibility { get; init; } + public required Action InboundEventFifo { get; init; } + public required Action LiveLiveness { get; init; } + public required Action LiveRuntime { get; init; } + public required Action SessionIdentity { get; init; } + public required Action RemoteTeleport { get; init; } + public required Action NetworkEffects { get; init; } + public required Action AnimationHookFrames { get; init; } + public required Action LivePresentation { get; init; } + public required Action RemoteMovementDiagnostics { get; init; } + public required Action PhysicsHostIndex { get; init; } +} + +/// +/// The concrete, ordered character-session reset manifest. Retail +/// CPlayerSystem::OnEndCharacterSession @ 0x00562870 calls End then +/// Begin; ClientUISystem::OnEndCharacterSession @ 0x00564AD0 +/// independently resets gameplay UI. App resource dependencies impose the +/// exact projection-before-canonical-runtime order pinned here. +/// +internal static class LiveSessionResetManifest +{ + public static LiveSessionResetPlan Create(LiveSessionResetBindings bindings) + { + ArgumentNullException.ThrowIfNull(bindings); + return new LiveSessionResetPlan( + [ + new("mouse capture", bindings.MouseCapture), + new("player presentation", bindings.PlayerPresentation), + new("teleport transit", bindings.TeleportTransit), + new("session dialogs", bindings.SessionDialogs), + new("chat command targets", bindings.ChatCommandTargets), + new("settings character context", bindings.SettingsCharacterContext), + // Attachment projections own GL-backed registrations and must leave + // before canonical live GUID records are released. + new("equipped children", bindings.EquippedChildren), + new("external container", bindings.ExternalContainer), + new("interaction and selection", bindings.InteractionAndSelection), + new("selection presentation", bindings.SelectionPresentation), + new("object table", bindings.ObjectTable), + new("spellbook", bindings.Spellbook), + new("magic runtime", bindings.MagicRuntime), + new("combat attack", bindings.CombatAttack), + new("combat state", bindings.CombatState), + new("item mana", bindings.ItemMana), + new("local player", bindings.LocalPlayer), + new("friends", bindings.Friends), + new("squelch", bindings.Squelch), + new("turbine chat", bindings.TurbineChat), + new("particle visibility", bindings.ParticleVisibility), + new("inbound event fifo", bindings.InboundEventFifo), + new("live liveness", bindings.LiveLiveness), + new("live runtime", bindings.LiveRuntime), + // Identity must remain A until live teardown has converged so + // player-specific teardown can still classify the old record. + new("session identity", bindings.SessionIdentity), + new("remote teleport", bindings.RemoteTeleport), + // F754/F755 can precede CreateObject, so pending network effects + // must clear even when no LiveEntityRecord was constructed. + new("network effects", bindings.NetworkEffects), + new("animation hook frames", bindings.AnimationHookFrames), + new("live presentation", bindings.LivePresentation), + new("remote movement diagnostics", bindings.RemoteMovementDiagnostics), + new("physics host index", bindings.PhysicsHostIndex), + ]); + } +} + +/// Shared convergence gate for canonical live runtime teardown. +internal static class LiveSessionEntityRuntimeReset +{ + public static void ClearAndRequireConvergence(LiveEntityRuntime? runtime) + { + if (runtime is null) + return; + runtime.Clear(); + RequireConvergence(runtime); + } + + public static void RequireConvergence(LiveEntityRuntime? runtime) + { + if (runtime is null) + return; + if (runtime.Count == 0 + && runtime.PendingTeardownCount == 0 + && runtime.MaterializedCount == 0) + { + return; + } + + throw new InvalidOperationException( + "The live-entity runtime has not converged after session clear " + + $"(records={runtime.Count}, pending={runtime.PendingTeardownCount}, " + + $"materialized={runtime.MaterializedCount})."); + } +} diff --git a/src/AcDream.App/Net/LiveSessionResetPlan.cs b/src/AcDream.App/Net/LiveSessionResetPlan.cs new file mode 100644 index 00000000..dbfba133 --- /dev/null +++ b/src/AcDream.App/Net/LiveSessionResetPlan.cs @@ -0,0 +1,79 @@ +namespace AcDream.App.Net; + +/// One named owner operation in the live-session reset transaction. +internal sealed record LiveSessionResetStage(string Name, Action Reset); + +/// +/// Executes every session-state reset stage even when an earlier owner fails. +/// A failed attempt throws after the complete manifest has run; lifecycle code +/// therefore cannot construct the next session until a later attempt converges. +/// +internal sealed class LiveSessionResetPlan +{ + private readonly LiveSessionResetStage[] _stages; + private int _executing; + + public LiveSessionResetPlan(IEnumerable stages) + { + ArgumentNullException.ThrowIfNull(stages); + _stages = stages.ToArray(); + var names = new HashSet(StringComparer.Ordinal); + foreach (LiveSessionResetStage stage in _stages) + { + ArgumentNullException.ThrowIfNull(stage); + if (string.IsNullOrWhiteSpace(stage.Name)) + throw new ArgumentException("Reset stage names must be non-empty.", nameof(stages)); + ArgumentNullException.ThrowIfNull(stage.Reset); + if (!names.Add(stage.Name)) + throw new ArgumentException( + $"Duplicate live-session reset stage '{stage.Name}'.", + nameof(stages)); + } + } + + public IReadOnlyList StageNames => + Array.ConvertAll(_stages, static stage => stage.Name); + + public void Execute() + { + if (Interlocked.Exchange(ref _executing, 1) != 0) + throw new InvalidOperationException( + "Live-session reset cannot run concurrently or reentrantly."); + + List? failures = null; + try + { + foreach (LiveSessionResetStage stage in _stages) + { + try + { + stage.Reset(); + } + catch (Exception error) + { + (failures ??= []).Add(new LiveSessionResetStageException( + stage.Name, + error)); + } + } + } + finally + { + Volatile.Write(ref _executing, 0); + } + + if (failures is not null) + throw new AggregateException( + "Live-session state did not converge; a new session must not start.", + failures); + } +} + +internal sealed class LiveSessionResetStageException( + string stageName, + Exception innerException) : Exception( + $"Live-session reset stage '{stageName}' failed.", + innerException) +{ + public string StageName { get; } = stageName; +} diff --git a/src/AcDream.App/Rendering/CameraController.cs b/src/AcDream.App/Rendering/CameraController.cs index c855a9a2..5961f358 100644 --- a/src/AcDream.App/Rendering/CameraController.cs +++ b/src/AcDream.App/Rendering/CameraController.cs @@ -66,10 +66,14 @@ public sealed class CameraController public void ExitChaseMode() { + bool wasChaseMode = IsChaseMode; Chase = null; RetailChase = null; + if (_mode == Mode.Orbit) + return; _mode = Mode.Fly; - ModeChanged?.Invoke(IsFlyMode); + if (wasChaseMode) + ModeChanged?.Invoke(IsFlyMode); } public void SetAspect(float aspect) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index d7479c15..f6d9bcf7 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -817,6 +817,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // an ICommandBus and becomes inert before the displaced socket closes. private AcDream.App.Net.LiveSessionEventRouter? _liveSessionEvents; private AcDream.App.Net.LiveSessionCommandRouter? _liveSessionCommands; + private AcDream.App.Net.LiveSessionResetPlan? _liveSessionResetPlan; // Phase G.1-G.2 world lighting/time state. public readonly AcDream.Core.World.WorldTimeService WorldTime = @@ -1785,6 +1786,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // writes under the chosen character's // name (or "default" pre-login). settingsStore.SaveCharacter(_activeToonKey, character); + if (string.Equals( + _activeToonKey, + "default", + StringComparison.OrdinalIgnoreCase)) + { + _persistedCharacter = character; + } Console.WriteLine( $"settings: character[{_activeToonKey}] saved to " + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); @@ -2946,23 +2954,125 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private void ClearInboundEntityState() { - EndMouseLookAndRestoreCursor(); - ResetTeleportTransitState(clearSession: true); - if (_playerController is not null) - _playerController.State = AcDream.App.Input.PlayerState.InWorld; + (_liveSessionResetPlan ??= CreateLiveSessionResetPlan()).Execute(); + } - // Session-scoped origin readiness. A reconnect's first logical player - // CreateObject must initialize the render/streaming center anew; - // projection recovery inside one session never crosses this boundary. + private AcDream.App.Net.LiveSessionResetPlan CreateLiveSessionResetPlan() => + AcDream.App.Net.LiveSessionResetManifest.Create(new() + { + MouseCapture = ResetSessionMouseCapture, + PlayerPresentation = ResetSessionPlayerPresentation, + TeleportTransit = () => ResetTeleportTransitState(clearSession: true), + SessionDialogs = () => _retailUiRuntime?.ResetSessionDialogs(), + ChatCommandTargets = () => _retailChatVm?.ResetSessionTargets(), + SettingsCharacterContext = () => + _settingsVm?.LoadCharacterContext(_persistedCharacter), + EquippedChildren = () => _equippedChildRenderer?.Clear(), + ExternalContainer = () => _externalContainers.Reset(), + InteractionAndSelection = ResetSessionInteraction, + SelectionPresentation = () => _retailSelectionScene?.Reset(), + ObjectTable = Objects.Clear, + Spellbook = SpellBook.Clear, + MagicRuntime = () => _magicRuntime?.Reset(), + CombatAttack = () => _combatAttackController?.ResetSession(), + CombatState = Combat.Clear, + ItemMana = ItemMana.Clear, + LocalPlayer = LocalPlayer.Clear, + Friends = Friends.Clear, + Squelch = Squelch.Clear, + TurbineChat = TurbineChat.Reset, + ParticleVisibility = _particleVisibility.Reset, + InboundEventFifo = _inboundEntityEvents.Clear, + LiveLiveness = () => _liveEntityLiveness?.Clear(), + LiveRuntime = ResetSessionLiveRuntime, + SessionIdentity = ResetSessionIdentity, + RemoteTeleport = () => _remoteTeleportController?.Clear(), + NetworkEffects = () => _entityEffects?.ClearNetworkState(), + AnimationHookFrames = () => _animationHookFrames?.Clear(), + LivePresentation = () => _liveEntityPresentation?.Clear(), + RemoteMovementDiagnostics = _remoteLastMove.Clear, + PhysicsHostIndex = _physicsHosts.Clear, + }); + + private void ResetSessionMouseCapture() + { + bool wasActive = _mouseLook?.Active == true; + _mouseLook?.Release(); + if (wasActive || _mouseLookSavedCursorMode.HasValue) + RestoreCursorAfterMouseLook(); + } + + private void ResetSessionPlayerPresentation() + { + _playerModeAutoEntry?.Cancel(); + try + { + _cameraController?.ExitChaseMode(); + } + finally + { + _playerMode = false; + _playerController = null; + _playerHost = null; + _chaseCamera = null; + _retailChaseCamera = null; + _playerMouseDeltaX = 0f; + _rmbHeld = false; + _autoRunActive = false; + _chaseModeEverEntered = false; + _lastMovementTruthOutbound = null; + _lastLocalPlayerShadow = null; + _spawnClaimRangeMemo = null; + } + } + + private void ResetSessionIdentity() + { + AcDream.App.Net.LiveSessionEntityRuntimeReset.RequireConvergence(_liveEntities); + + // PlayerModule::Clear @ 0x005D48A0 clears shortcuts, desired + // components, and character option objects. CPlayerSystem::Begin + // @ 0x0055D410 resets player identity and session fields. The option + // default comes from PlayerModule::PlayerModule @ 0x005D51F0. + _playerServerGuid = 0u; + _vitalsVm?.SetLocalPlayerGuid(0u); + Chat.ResetSessionIdentity(); + AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = 0u; + _activeToonKey = "default"; + _characterOptions1 = + AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default; + _playerMotionTableId = null; + _lastSeenRunSkill = -1; + _lastSeenJumpSkill = -1; + _lastLivePlayerLandblockId = null; + _liveSpawnReceived = 0; + _liveSpawnHydrated = 0; + _liveDropReasonNoPos = 0; + _liveDropReasonNoSetup = 0; + _liveDropReasonSetupDatMissing = 0; + _liveDropReasonNoMeshRefs = 0; + _liveAnimRejectNoCycle = 0; + _liveAnimRejectFramerate = 0; + _liveAnimRejectSingleFrame = 0; + _liveAnimRejectPartFrames = 0; + Shortcuts = Array.Empty(); + DesiredComponents = Array.Empty<(uint Id, uint Amount)>(); + _paperdollDollDirty = true; + + // X/Y are ignored until the next logical player CreateObject claims a + // new origin. Keeping the last values avoids inventing a synthetic + // landblock while still forcing first-spawn initialization. _liveCenterKnown = false; + } - // Attachment projections own GL-backed world registrations, so tear - // them down before dropping the canonical GUID/timestamp snapshots. - _equippedChildRenderer?.Clear(); - _externalContainers.Reset(); - Objects.Clear(); - SpellBook.Clear(); - _magicRuntime?.Reset(); + private void ResetSessionLiveRuntime() + { + AcDream.App.Net.LiveSessionEntityRuntimeReset.ClearAndRequireConvergence( + _liveEntities); + } + + private void ResetSessionInteraction() + { if (_selectionInteractions is { } interactions) interactions.ResetSession(); else @@ -2970,36 +3080,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _itemInteractionController?.ResetSession(); _selection.Reset(); } - _retailSelectionScene?.Reset(); - _particleVisibility.Reset(); - try - { - _liveEntityLiveness?.Clear(); - _liveEntities?.Clear(); - } - finally - { - try - { - // A pending teleport is operational state outside the live - // record. Clear it independently even if a resource callback - // failed while the canonical runtime was draining. - _remoteTeleportController?.Clear(); - } - finally - { - try - { - // F754/F755 can precede CreateObject, so the mixed pending FIFO - // may contain owners with no LiveEntityRecord to tear down. - _entityEffects?.ClearNetworkState(); - } - finally - { - _animationHookFrames?.Clear(); - } - } - } } /// diff --git a/src/AcDream.App/UI/GameplayConfirmationController.cs b/src/AcDream.App/UI/GameplayConfirmationController.cs index c8b81136..b23686d0 100644 --- a/src/AcDream.App/UI/GameplayConfirmationController.cs +++ b/src/AcDream.App/UI/GameplayConfirmationController.cs @@ -66,6 +66,17 @@ public sealed class GameplayConfirmationController : IDisposable return _dialogs.CloseDialog(_dialogContext); } + /// + /// Forget any tuple left after the dialog factory has completed its normal + /// retail close/reset callbacks. + /// + public void ResetSession() + { + _dialogContext = 0u; + _serverType = 0u; + _serverContext = 0u; + } + public void Dispose() { if (_disposed) return; diff --git a/src/AcDream.App/UI/InteractionState.cs b/src/AcDream.App/UI/InteractionState.cs index 49254dd7..2c80651d 100644 --- a/src/AcDream.App/UI/InteractionState.cs +++ b/src/AcDream.App/UI/InteractionState.cs @@ -42,6 +42,31 @@ public sealed class InteractionState public bool Clear() => Set(InteractionMode.None); + /// + /// Publishes the session-reset edge even when the mode is already clear so + /// a retry can repair any observer that missed an earlier notification. + /// + public void ResetSession() + { + InteractionMode previous = Current; + Current = InteractionMode.None; + Action? listeners = Changed; + if (listeners is null) + return; + + var transition = new InteractionModeTransition(previous, InteractionMode.None); + List? failures = null; + foreach (Action listener in listeners.GetInvocationList()) + { + try { listener(transition); } + catch (Exception error) { (failures ??= []).Add(error); } + } + if (failures is not null) + throw new AggregateException( + "One or more interaction-mode reset observers failed.", + failures); + } + private bool Set(InteractionMode mode) { if (Current == mode) diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 9f0c80fc..5d6c9bca 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -1009,7 +1009,14 @@ public sealed class ItemInteractionController : IDisposable }; private void OnInteractionModeChanged(InteractionModeTransition _) - => StateChanged?.Invoke(); + { + List failures = []; + DispatchAll(StateChanged, failures); + if (failures.Count != 0) + throw new AggregateException( + "One or more item-interaction observers failed.", + failures); + } private void OnInventoryObjectMoved(ClientObjectMove move) { @@ -1124,17 +1131,50 @@ public sealed class ItemInteractionController : IDisposable /// public void ResetSession() { - CancelPendingBackpackPlacement(); - bool busyChanged = _busyCount != 0 || _pendingInventoryRequest is not null; - bool modeChanged = IsAnyTargetModeActive; + PendingBackpackPlacement? pendingPlacement = _pendingBackpackPlacement; + _pendingBackpackPlacement = null; _busyCount = 0; _pendingInventoryRequest = null; _lastUseMs = long.MinValue / 2; _consumedPrimaryClickTarget = 0u; _consumedPrimaryClickMs = long.MinValue / 2; - _interactionState.Clear(); - if (busyChanged && !modeChanged) - StateChanged?.Invoke(); + + List failures = []; + try { _interactionState.ResetSession(); } + catch (Exception error) { failures.Add(error); } + + if (pendingPlacement is { } pending) + DispatchAll(PendingBackpackPlacementCancelled, pending, failures); + + if (failures.Count != 0) + throw new AggregateException( + "One or more item-interaction reset observers failed.", + failures); + } + + private static void DispatchAll(Action? listeners, List failures) + { + if (listeners is null) + return; + foreach (Action listener in listeners.GetInvocationList()) + { + try { listener(); } + catch (Exception error) { failures.Add(error); } + } + } + + private static void DispatchAll( + Action? listeners, + T value, + List failures) + { + if (listeners is null) + return; + foreach (Action listener in listeners.GetInvocationList()) + { + try { listener(value); } + catch (Exception error) { failures.Add(error); } + } } private bool ConsumeUseThrottle() diff --git a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs index 2311e278..f4c71c2f 100644 --- a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs +++ b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs @@ -26,6 +26,7 @@ public sealed class RetailDialogFactory : IDisposable private readonly Dictionary> _pending = new(); private readonly List _openOrder = new(); private uint _globalContext; + private bool _resetting; private bool _disposed; public RetailDialogFactory( @@ -192,18 +193,46 @@ public sealed class RetailDialogFactory : IDisposable /// public void Reset() { - DialogInfo[] infos = _activeNonQueued.Values - .Concat(_activeQueued.Values) - .Concat(_pending.Values.SelectMany(static queue => queue)) - .Distinct() - .ToArray(); + if (_resetting) + return; - _activeNonQueued.Clear(); - _activeQueued.Clear(); - _pending.Clear(); - foreach (DialogInfo info in infos) - DialogDone(info); - RefreshModal(); + _resetting = true; + List? failures = null; + try + { + while (true) + { + DialogInfo[] infos = _activeNonQueued.Values + .Concat(_activeQueued.Values) + .Concat(_pending.Values.SelectMany(static queue => queue)) + .Distinct() + .ToArray(); + if (infos.Length == 0) + break; + + // Callbacks may synchronously create another dialog. Retire + // this exact snapshot, then loop until those reentrant infos + // have also completed through DialogDone. + _activeNonQueued.Clear(); + _activeQueued.Clear(); + _pending.Clear(); + foreach (DialogInfo info in infos) + { + try { DialogDone(info); } + catch (Exception error) { (failures ??= []).Add(error); } + } + } + } + finally + { + _resetting = false; + RefreshModal(); + } + + if (failures is not null) + throw new AggregateException( + "One or more dialogs failed while the factory reset.", + failures); } public void Dispose() @@ -283,10 +312,10 @@ public sealed class RetailDialogFactory : IDisposable { if (info.View is { } view) { - info.View = null; view.DetachHandlers(); - _openOrder.Remove(info); _host.RemoveChild(view.Root); + info.View = null; + _openOrder.Remove(info); } RefreshModal(); } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 77075738..5d59a1cd 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -386,6 +386,25 @@ public sealed class RetailUiRuntime : IDisposable data => completed(data.GetBoolean(RetailDialogProperty.ConfirmationResult))) ?? 0u; } + /// + /// Close character-session dialogs through retail's normal callback and + /// close-notice path, then forget the gameplay confirmation tuple. + /// gmGamePlayUI::~gmGamePlayUI @ 0x004EA2A0 closes its retained + /// contexts before DialogFactory::Reset @ 0x00477950 drains the + /// remaining factory entries. + /// + public void ResetSessionDialogs() + { + try + { + DialogFactory?.Reset(); + } + finally + { + _gameplayConfirmationController?.ResetSession(); + } + } + public void UpdateCursor(IEnumerable mice) { CursorFeedback feedback = _bindings.Cursor.Feedback.Update(Host.Root); diff --git a/src/AcDream.Core/Chat/ChatLog.cs b/src/AcDream.Core/Chat/ChatLog.cs index aeedcc31..6b5cbea8 100644 --- a/src/AcDream.Core/Chat/ChatLog.cs +++ b/src/AcDream.Core/Chat/ChatLog.cs @@ -62,14 +62,27 @@ public sealed class ChatLog /// /// Push the authoritative local-player GUID from WorldSession. - /// One-way setter — only GameWindow should call it, exactly - /// once per live session. Used by to + /// The host sets it after character selection and resets it at session + /// teardown. Used by to /// recognize ACE's HearSpeech echo of our own /say (server's /// HandleActionTalk broadcasts to all in range INCLUDING the /// sender) and re-render it as "You say, ...". /// public void SetLocalPlayerGuid(uint guid) => _localPlayerGuid = guid; + /// + /// Reset per-session speaker classification and duplicate suppression + /// while preserving the visible transcript. Retail has an explicit + /// ChatInterface::RecvNotice_ClearChatBuffer @ 0x004F2F60 path; + /// character-session teardown does not use that clear-buffer notice. + /// + public void ResetSessionIdentity() + { + _localPlayerGuid = 0u; + _lastSystemText = string.Empty; + _lastSystemAt = DateTime.MinValue; + } + // ── Inbound adapters ───────────────────────────────────────────────────── /// Local or ranged HearSpeech (0x02BB / 0x02BC). diff --git a/src/AcDream.Core/Chat/TurbineChatState.cs b/src/AcDream.Core/Chat/TurbineChatState.cs index f87818be..1b6ff08c 100644 --- a/src/AcDream.Core/Chat/TurbineChatState.cs +++ b/src/AcDream.Core/Chat/TurbineChatState.cs @@ -93,6 +93,33 @@ public sealed class TurbineChatState SocietyRadiantBloodRoom = societyRadiantBloodRoom; } + /// + /// Return the server-assigned room table and outbound context sequence to + /// their pre-login values. Room ids and cookies belong to one negotiated + /// character session and must never cross a reconnect boundary. + /// + /// + /// Retail CCommunicationSystem construction at 0x005566C0 + /// starts Turbine chat disabled; SetTurbineChatChannels (0x0295) supplies + /// rooms per character. Cookie 1 is the Holtburger protocol-reference + /// initial value, with zero reserved for no context. + /// + public void Reset() + { + Enabled = false; + AllegianceRoom = 0u; + GeneralRoom = 0u; + TradeRoom = 0u; + LfgRoom = 0u; + RoleplayRoom = 0u; + OlthoiRoom = 0u; + SocietyRoom = 0u; + SocietyCelestialHandRoom = 0u; + SocietyEldrytchWebRoom = 0u; + SocietyRadiantBloodRoom = 0u; + _nextContextId = 1u; + } + /// /// Look up the runtime room id for a Turbine /// . Returns 0 if the channel is not diff --git a/src/AcDream.Core/Combat/CombatState.cs b/src/AcDream.Core/Combat/CombatState.cs index 4a18a3ae..f1952f3f 100644 --- a/src/AcDream.Core/Combat/CombatState.cs +++ b/src/AcDream.Core/Combat/CombatState.cs @@ -178,6 +178,21 @@ public sealed class CombatState public void Clear() { _healthByGuid.Clear(); - SetCombatMode(CombatMode.NonCombat); + CurrentMode = CombatMode.NonCombat; + + Action? listeners = CombatModeChanged; + if (listeners is null) + return; + + List? failures = null; + foreach (Action listener in listeners.GetInvocationList()) + { + try { listener(CombatMode.NonCombat); } + catch (Exception error) { (failures ??= []).Add(error); } + } + if (failures is not null) + throw new AggregateException( + "One or more combat reset observers failed.", + failures); } } diff --git a/src/AcDream.Core/Items/ExternalContainerState.cs b/src/AcDream.Core/Items/ExternalContainerState.cs index 72b033a9..d42eebc4 100644 --- a/src/AcDream.Core/Items/ExternalContainerState.cs +++ b/src/AcDream.Core/Items/ExternalContainerState.cs @@ -101,12 +101,24 @@ public sealed class ExternalContainerState bool changed = previous != 0u || RequestedContainerId != 0u; CurrentContainerId = 0u; RequestedContainerId = 0u; - if (changed) + + var transition = new ExternalContainerTransition( + ExternalContainerTransitionKind.Reset, + previous, + 0u); + Action? listeners = Changed; + if (listeners is not null) { - Changed?.Invoke(new ExternalContainerTransition( - ExternalContainerTransitionKind.Reset, - previous, - 0u)); + List? failures = null; + foreach (Action listener in listeners.GetInvocationList()) + { + try { listener(transition); } + catch (Exception error) { (failures ??= []).Add(error); } + } + if (failures is not null) + throw new AggregateException( + "One or more external-container reset observers failed.", + failures); } return changed; } diff --git a/src/AcDream.Core/Player/LocalPlayerState.cs b/src/AcDream.Core/Player/LocalPlayerState.cs index 961a7041..89fd8480 100644 --- a/src/AcDream.Core/Player/LocalPlayerState.cs +++ b/src/AcDream.Core/Player/LocalPlayerState.cs @@ -454,6 +454,39 @@ public sealed class LocalPlayerState return true; } + /// + /// Return the character snapshot to its pre-login state. The object itself + /// is process-lived because UI view models subscribe to it once; session + /// replacement clears its contents instead of replacing the owner. + /// + /// + /// Retail: CPlayerSystem::OnEndCharacterSession @ 0x00562870 + /// calls End @ 0x005606C0, which invokes + /// PlayerModule::Clear @ 0x005D48A0, then immediately calls + /// CPlayerSystem::Begin @ 0x0055D410. Re-publishing invalidation + /// events is acdream's process-lived-view adaptation. + /// + public void Clear() + { + _health = null; + _stamina = null; + _mana = null; + _attrs.Clear(); + _skills.Clear(); + _positions.Clear(); + _properties = new PropertyBundle(); + + // Process-lived views pull through this object and use these events as + // their invalidation edge. Publish every category even when Clear is + // repeated so a failed reset attempt can safely converge on retry. + Changed?.Invoke(VitalKind.Health); + Changed?.Invoke(VitalKind.Stamina); + Changed?.Invoke(VitalKind.Mana); + foreach (AttributeKind kind in Enum.GetValues()) + AttributeChanged?.Invoke(kind); + CharacterChanged?.Invoke(); + } + private static uint SaturatingAdd(uint value, uint delta) => uint.MaxValue - value < delta ? uint.MaxValue : value + delta; diff --git a/src/AcDream.Core/Selection/SelectionState.cs b/src/AcDream.Core/Selection/SelectionState.cs index 1bef0d44..22114ac2 100644 --- a/src/AcDream.Core/Selection/SelectionState.cs +++ b/src/AcDream.Core/Selection/SelectionState.cs @@ -78,20 +78,25 @@ public sealed class SelectionState : ISelectionService public bool Reset(SelectionChangeSource source = SelectionChangeSource.System) { uint? old = SelectedObjectId; + bool changed = old is not null + || PreviousObjectId is not null + || PreviousValidObjectId is not null; SelectedObjectId = null; PreviousObjectId = null; PreviousValidObjectId = null; - if (old is null) - return false; var transition = new SelectionTransition( old, null, source, SelectionChangeReason.SessionReset); - Changed?.Invoke(transition); + List? failures = DispatchChanged(transition); DispatchPluginChanged(new SelectionChangedEvent(old, null)); - return true; + if (failures is not null) + throw new AggregateException( + "One or more selection reset observers failed.", + failures); + return changed; } bool ISelectionService.Select(uint objectId) @@ -120,6 +125,21 @@ public sealed class SelectionState : ISelectionService return true; } + private List? DispatchChanged(SelectionTransition transition) + { + Action? listeners = Changed; + if (listeners is null) + return null; + + List? failures = null; + foreach (Action listener in listeners.GetInvocationList()) + { + try { listener(transition); } + catch (Exception error) { (failures ??= []).Add(error); } + } + return failures; + } + private void DispatchPluginChanged(SelectionChangedEvent change) { Action? listeners = PluginChanged; diff --git a/src/AcDream.Core/Social/SquelchState.cs b/src/AcDream.Core/Social/SquelchState.cs index 201a9ddd..de0b8aa8 100644 --- a/src/AcDream.Core/Social/SquelchState.cs +++ b/src/AcDream.Core/Social/SquelchState.cs @@ -16,6 +16,17 @@ public sealed class SquelchState ArgumentNullException.ThrowIfNull(database); lock (_gate) _database = database; } + + /// + /// Drop the server-owned squelch database for the old session. Retail + /// CPlayerSystem::LogOnCharacter @ 0x0055F890 calls + /// gmCCommunicationSystem::ClearSquelchDB @ 0x00589240 before + /// entering the next character. + /// + public void Clear() + { + lock (_gate) _database = SquelchDatabase.Empty; + } } public sealed record SquelchInfo( diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index 4fa8593e..04fd3871 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -116,6 +116,17 @@ public sealed class ChatVM /// public void Clear() => _log.Clear(); + /// + /// Forget per-session reply/retell destinations while retaining the shared + /// transcript. Old character names must not become command targets after a + /// reconnect. + /// + public void ResetSessionTargets() + { + LastIncomingTellSender = null; + LastOutgoingTellTarget = null; + } + /// /// Print the current framerate into chat. Used by /// /framerate / @framerate. Falls back to a diff --git a/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs index 5dd8433d..929fbb90 100644 --- a/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs @@ -55,8 +55,8 @@ public sealed class VitalsVM /// /// Push the authoritative local-player GUID from WorldSession. - /// One-way setter — only GameWindow should call it, exactly once - /// per live session. + /// The host assigns it after character selection and resets it to zero at + /// session teardown. /// public void SetLocalPlayerGuid(uint guid) => _localPlayerGuid = guid; diff --git a/tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs b/tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs index 8e02c90c..750a9827 100644 --- a/tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs +++ b/tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs @@ -215,6 +215,34 @@ public sealed class CombatAttackControllerTests } } + [Fact] + public void ResetSession_RestoresRetailBeginDefaultsWithoutSendingCancel() + { + double now = 1d; + int cancels = 0; + var combat = new CombatState(); + using var controller = new CombatAttackController( + combat, + canStartAttack: () => true, + sendAttack: (_, _) => true, + sendCancelAttack: () => cancels++, + now: () => now); + combat.SetCombatMode(CombatMode.Melee); + controller.SetDesiredPower(1f); + controller.PressAttack(AttackHeight.High); + now = 1.5d; + + controller.ResetSession(); + + Assert.False(controller.AttackRequestInProgress); + Assert.False(controller.BuildInProgress); + Assert.Equal(0f, controller.RequestedAttackPower); + Assert.Equal(0f, controller.PowerBarLevel); + Assert.Equal(AttackHeight.Medium, controller.RequestedHeight); + Assert.Equal(CombatAttackController.InitialDesiredPower, controller.DesiredPower); + Assert.Equal(0, cancels); + } + private static CombatAttackController Create( CombatState combat, Func now, diff --git a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs new file mode 100644 index 00000000..e55baf92 --- /dev/null +++ b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs @@ -0,0 +1,387 @@ +using AcDream.App.Net; +using AcDream.App.Rendering; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Chat; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Player; +using AcDream.Core.Selection; +using AcDream.Core.Social; +using AcDream.Core.Spells; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Net; + +public sealed class LiveSessionResetPlanTests +{ + private sealed class FailingOnceResources : ILiveEntityResourceLifecycle + { + private bool _failurePending = true; + + public void Register(WorldEntity entity) { } + + public void Unregister(WorldEntity entity) + { + if (_failurePending) + { + _failurePending = false; + throw new InvalidOperationException("transient teardown"); + } + } + } + + [Fact] + public void Execute_EmptyPlanConverges() + { + var plan = new LiveSessionResetPlan([]); + + plan.Execute(); + plan.Execute(); + + Assert.Empty(plan.StageNames); + } + + [Fact] + public void Execute_AttemptsEveryStageAndAggregatesNamedFailures() + { + var calls = new List(); + var plan = new LiveSessionResetPlan( + [ + new("first", () => + { + calls.Add("first"); + throw new InvalidOperationException("one"); + }), + new("second", () => calls.Add("second")), + new("third", () => + { + calls.Add("third"); + throw new ArgumentException("three"); + }), + ]); + + AggregateException error = Assert.Throws(plan.Execute); + + Assert.Equal(["first", "second", "third"], calls); + Assert.Collection( + error.InnerExceptions, + first => Assert.Equal( + "first", + Assert.IsType(first).StageName), + third => Assert.Equal( + "third", + Assert.IsType(third).StageName)); + } + + [Fact] + public void Execute_AfterFailedAttemptCanConvergeWithoutSkippingStages() + { + int firstCalls = 0; + int secondCalls = 0; + var plan = new LiveSessionResetPlan( + [ + new("transient", () => + { + firstCalls++; + if (firstCalls == 1) + throw new InvalidOperationException("transient"); + }), + new("always", () => secondCalls++), + ]); + + Assert.Throws(plan.Execute); + plan.Execute(); + + Assert.Equal(2, firstCalls); + Assert.Equal(2, secondCalls); + } + + [Fact] + public void Execute_ReentrantAttemptIsReportedButLaterStagesStillRun() + { + LiveSessionResetPlan? plan = null; + bool tailRan = false; + plan = new LiveSessionResetPlan( + [ + new("reentrant", () => plan!.Execute()), + new("tail", () => tailRan = true), + ]); + + AggregateException error = Assert.Throws(plan.Execute); + + Assert.True(tailRan); + LiveSessionResetStageException stage = Assert.IsType( + Assert.Single(error.InnerExceptions)); + Assert.Equal("reentrant", stage.StageName); + Assert.IsType(stage.InnerException); + } + + [Fact] + public void Constructor_RejectsDuplicateStageNames() + { + Assert.Throws(() => new LiveSessionResetPlan( + [ + new("duplicate", () => { }), + new("duplicate", () => { }), + ])); + } + + [Fact] + public void Manifest_MutatedA_FailedDrain_RetryConvergesWithoutClearingRetainedWorld() + { + const uint playerA = 0x50000001u; + const uint objectA = 0x70000001u; + const uint landblock = 0x0101FFFFu; + var staticEntity = Entity(42u, 0u); + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + landblock, + new LandBlock(), + new List { staticEntity })); + var live = new LiveEntityRuntime(spatial, new FailingOnceResources()); + WorldSession.EntitySpawn spawn = Spawn(playerA, 1, 1, 0x01010001u); + live.RegisterLiveEntity(spawn); + live.MaterializeLiveEntity( + playerA, + 0x01010001u, + id => Entity(id, playerA)); + spatial.MarkPersistent(playerA); + + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject { ObjectId = objectA, Name = "A" }); + var spells = new Spellbook(); + spells.OnSpellLearned(123u, 1f); + var combat = new CombatState(); + combat.OnUpdateHealth(objectA, 0.5f); + combat.SetCombatMode(CombatMode.Melee); + var mana = new ItemManaState(); + mana.OnQueryItemManaResponse(objectA, 0.5f, valid: true); + var localPlayer = new LocalPlayerState(spells); + localPlayer.OnVitalUpdate(7u, 1u, 100u, 5u, 80u); + var friends = new FriendsState(); + friends.Apply(new FriendsUpdate( + FriendsUpdateType.Full, + [new FriendEntry(objectA, "A", true, false, [], [])])); + var squelch = new SquelchState(); + squelch.Replace(new SquelchDatabase( + new Dictionary { ["A"] = 1u }, + new Dictionary(), + new SquelchInfo("A", false, new HashSet()))); + var turbine = new TurbineChatState(); + turbine.OnChannelsReceived(1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u); + var selection = new SelectionState(); + selection.Select(objectA, SelectionChangeSource.World); + var external = new ExternalContainerState(); + external.RequestOpen(objectA); + external.ApplyViewContents(objectA); + var chat = new ChatLog(); + chat.SetLocalPlayerGuid(playerA); + chat.OnSystemMessage("retained transcript", 1u); + + uint sessionIdentity = playerA; + int savedDefaultSetting = 73; + int activeSetting = savedDefaultSetting; + var calls = new Dictionary(StringComparer.Ordinal); + var dirty = ExpectedManifestNames().ToDictionary( + static name => name, + static _ => true, + StringComparer.Ordinal); + + Action Stage(string name, Action? reset = null) => () => + { + calls[name] = calls.GetValueOrDefault(name) + 1; + reset?.Invoke(); + dirty[name] = false; + }; + + LiveSessionResetPlan plan = LiveSessionResetManifest.Create(new() + { + MouseCapture = Stage("mouse capture"), + PlayerPresentation = Stage("player presentation"), + TeleportTransit = Stage("teleport transit"), + SessionDialogs = Stage("session dialogs"), + ChatCommandTargets = Stage("chat command targets"), + SettingsCharacterContext = Stage( + "settings character context", + () => activeSetting = savedDefaultSetting), + EquippedChildren = Stage("equipped children"), + ExternalContainer = Stage("external container", () => external.Reset()), + InteractionAndSelection = Stage( + "interaction and selection", + () => selection.Reset()), + SelectionPresentation = Stage("selection presentation"), + ObjectTable = Stage("object table", objects.Clear), + Spellbook = Stage("spellbook", spells.Clear), + MagicRuntime = Stage("magic runtime"), + CombatAttack = Stage("combat attack"), + CombatState = Stage("combat state", combat.Clear), + ItemMana = Stage("item mana", mana.Clear), + LocalPlayer = Stage("local player", localPlayer.Clear), + Friends = Stage("friends", friends.Clear), + Squelch = Stage("squelch", squelch.Clear), + TurbineChat = Stage("turbine chat", turbine.Reset), + ParticleVisibility = Stage("particle visibility"), + InboundEventFifo = Stage("inbound event fifo"), + LiveLiveness = Stage("live liveness"), + LiveRuntime = Stage( + "live runtime", + () => LiveSessionEntityRuntimeReset.ClearAndRequireConvergence(live)), + SessionIdentity = Stage("session identity", () => + { + LiveSessionEntityRuntimeReset.RequireConvergence(live); + sessionIdentity = 0u; + chat.ResetSessionIdentity(); + }), + RemoteTeleport = Stage("remote teleport"), + NetworkEffects = Stage("network effects"), + AnimationHookFrames = Stage("animation hook frames"), + LivePresentation = Stage("live presentation"), + RemoteMovementDiagnostics = Stage("remote movement diagnostics"), + PhysicsHostIndex = Stage("physics host index"), + }); + + Assert.Equal(ExpectedManifestNames(), plan.StageNames); + AggregateException first = Assert.Throws(plan.Execute); + + Assert.Equal( + ["live runtime", "session identity"], + first.InnerExceptions + .Cast() + .Select(static error => error.StageName)); + Assert.Equal(playerA, sessionIdentity); + Assert.Equal(1, live.PendingTeardownCount); + Assert.False(dirty["physics host index"]); + Assert.Contains(staticEntity, spatial.Entities); + Assert.Equal(1, spatial.PersistentGuidCount); + + plan.Execute(); + + Assert.All(ExpectedManifestNames(), name => Assert.Equal(2, calls[name])); + Assert.DoesNotContain(dirty, static pair => pair.Value); + Assert.Equal(0u, sessionIdentity); + Assert.Equal(savedDefaultSetting, activeSetting); + Assert.Equal(0, live.Count); + Assert.Equal(0, live.PendingTeardownCount); + Assert.Equal(0, live.MaterializedCount); + Assert.Equal(0, spatial.PersistentGuidCount); + Assert.Contains(staticEntity, spatial.Entities); + Assert.True(spatial.IsLoaded(landblock)); + Assert.Null(objects.Get(objectA)); + Assert.Equal(0, spells.LearnedCount); + Assert.Equal(CombatMode.NonCombat, combat.CurrentMode); + Assert.False(combat.HasHealth(objectA)); + Assert.False(mana.HasMana(objectA)); + Assert.Null(localPlayer.Get(LocalPlayerState.VitalKind.Health)); + Assert.Empty(friends.Snapshot()); + Assert.Same(SquelchDatabase.Empty, squelch.Snapshot()); + Assert.False(turbine.Enabled); + Assert.Null(selection.SelectedObjectId); + Assert.Equal(0u, external.CurrentContainerId); + Assert.Equal(1, chat.Count); + chat.OnLocalSpeech("A", "after", playerA, isRanged: false); + Assert.Equal("A", chat.Snapshot()[1].Sender); + } + + private static string[] ExpectedManifestNames() => + [ + "mouse capture", + "player presentation", + "teleport transit", + "session dialogs", + "chat command targets", + "settings character context", + "equipped children", + "external container", + "interaction and selection", + "selection presentation", + "object table", + "spellbook", + "magic runtime", + "combat attack", + "combat state", + "item mana", + "local player", + "friends", + "squelch", + "turbine chat", + "particle visibility", + "inbound event fifo", + "live liveness", + "live runtime", + "session identity", + "remote teleport", + "network effects", + "animation hook frames", + "live presentation", + "remote movement diagnostics", + "physics host index", + ]; + + private static WorldEntity Entity(uint id, uint guid) => new() + { + Id = id, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + + private static WorldSession.EntitySpawn Spawn( + uint guid, + ushort instance, + ushort positionSequence, + uint cell) + { + const PhysicsStateFlags state = PhysicsStateFlags.ReportCollisions; + var position = new CreateObject.ServerPosition( + cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps( + positionSequence, 1, 1, 1, 0, 1, 0, 1, instance); + var physics = new PhysicsSpawnData( + RawState: (uint)state, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + null, + null, + "fixture", + null, + null, + 0x09000001u, + PhysicsState: (uint)state, + InstanceSequence: instance, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: positionSequence, + Physics: physics); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs b/tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs index 7bddfe52..124a5375 100644 --- a/tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs @@ -92,4 +92,33 @@ public class CameraControllerTests CameraDiagnostics.UseRetailChaseCamera = saved; } } + + [Fact] + public void ExitChaseMode_AlreadyOfflinePreservesOrbitCamera() + { + var orbit = new OrbitCamera(); + var ctl = new CameraController(orbit, new FlyCamera()); + + ctl.ExitChaseMode(); + + Assert.Same(orbit, ctl.Active); + Assert.False(ctl.IsFlyMode); + Assert.False(ctl.IsChaseMode); + } + + [Fact] + public void ExitChaseMode_AfterChaseToFlyReleasesRetainedSessionCameras() + { + var (ctl, _, _) = MakeChaseFixture(); + ctl.ToggleFly(); + Assert.True(ctl.IsFlyMode); + Assert.NotNull(ctl.Chase); + Assert.NotNull(ctl.RetailChase); + + ctl.ExitChaseMode(); + + Assert.True(ctl.IsFlyMode); + Assert.Null(ctl.Chase); + Assert.Null(ctl.RetailChase); + } } diff --git a/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs b/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs index 98da961c..f45ce004 100644 --- a/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs @@ -55,4 +55,25 @@ public sealed class GameplayConfirmationControllerTests Assert.Equal([(7u, 99u, false)], responses); } + [Fact] + public void FactoryReset_CompletesResponseBeforeSessionTupleIsForgotten() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + var factory = new RetailDialogFactory(root, _ => + FixtureLoader.LoadConfirmationDialog()); + var responses = new List<(uint Type, uint Context, bool Accepted)>(); + using var controller = new GameplayConfirmationController( + factory, + (type, context, accepted) => responses.Add((type, context, accepted))); + controller.HandleRequest( + new GameEvents.CharacterConfirmationRequest(7u, 99u, "Proceed?")); + + factory.Reset(); + controller.ResetSession(); + + Assert.Equal(0u, controller.ActiveDialogContext); + Assert.Equal([(7u, 99u, false)], responses); + Assert.False(factory.IsOpen); + } + } diff --git a/tests/AcDream.App.Tests/UI/InteractionStateTests.cs b/tests/AcDream.App.Tests/UI/InteractionStateTests.cs index c60f1e75..2fdeb2dd 100644 --- a/tests/AcDream.App.Tests/UI/InteractionStateTests.cs +++ b/tests/AcDream.App.Tests/UI/InteractionStateTests.cs @@ -29,4 +29,31 @@ public sealed class InteractionStateTests Assert.Throws(() => state.EnterUseItemOnTarget(0)); Assert.Equal(InteractionMode.None, state.Current); } + + [Fact] + public void ResetSession_RetryRepublishesAndOneObserverCannotStarveAnother() + { + var state = new InteractionState(); + state.EnterExamine(); + bool fail = true; + int delivered = 0; + state.Changed += _ => + { + if (fail) + { + fail = false; + throw new InvalidOperationException("transient"); + } + }; + state.Changed += transition => + { + Assert.Equal(InteractionMode.None, transition.Current); + delivered++; + }; + + Assert.Throws(state.ResetSession); + Assert.Equal(1, delivered); + state.ResetSession(); + Assert.Equal(2, delivered); + } } diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 36b8275e..404e75c5 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -122,6 +122,33 @@ public sealed class ItemInteractionControllerTests Assert.Empty(h.UseWithTarget); } + [Fact] + public void ResetSession_RetryNotifiesEveryStateObserver() + { + var h = new Harness(); + h.Controller.InteractionState.EnterExamine(); + h.Controller.IncrementBusyCount(); + bool fail = true; + int delivered = 0; + h.Controller.StateChanged += () => + { + if (fail) + { + fail = false; + throw new InvalidOperationException("transient"); + } + }; + h.Controller.StateChanged += () => delivered++; + + Assert.Throws(h.Controller.ResetSession); + Assert.Equal(1, delivered); + Assert.Equal(0, h.Controller.BusyCount); + Assert.Equal(InteractionMode.None, h.Controller.InteractionState.Current); + + h.Controller.ResetSession(); + Assert.Equal(2, delivered); + } + [Fact] public void ExternalContainerUse_RequestsGroundObjectAndSendsUse() { diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs index fecb6e43..8ae2d1d5 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs @@ -187,6 +187,74 @@ public sealed class RetailDialogFactoryTests Assert.Null(root.Modal); } + [Fact] + public void Reset_CompletesEveryDialogAndKeepsContextSequenceMonotonic() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + var layouts = new List(); + var factory = CreateFactory(root, layouts); + int callbacks = 0; + int notices = 0; + factory.DialogClosed += (_, _) => notices++; + + uint first = factory.MakeConfirmation("active", _ => callbacks++); + factory.MakeConfirmation("pending", _ => callbacks++); + factory.Reset(); + + Assert.Equal(1u, first); + Assert.Equal(2, callbacks); + Assert.Equal(2, notices); + Assert.False(factory.IsOpen); + Assert.Equal(0, factory.PendingCount); + Assert.Null(root.Modal); + Assert.Empty(root.Children); + Assert.Equal(3u, factory.MakeConfirmation("new session")); + } + + [Fact] + public void Reset_AttemptsEveryDialogWhenOneCallbackThrows() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + var layouts = new List(); + var factory = CreateFactory(root, layouts); + int completed = 0; + + factory.MakeConfirmation("active", _ => throw new InvalidOperationException("boom")); + factory.MakeConfirmation("pending", _ => completed++); + + AggregateException error = Assert.Throws(factory.Reset); + + Assert.Contains("boom", error.ToString()); + Assert.Equal(1, completed); + Assert.False(factory.IsOpen); + Assert.Equal(0, factory.PendingCount); + Assert.Null(root.Modal); + Assert.Empty(root.Children); + factory.Reset(); + } + + [Fact] + public void Reset_DrainsDialogCreatedReentrantlyByCompletionCallback() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + var layouts = new List(); + var factory = CreateFactory(root, layouts); + int completed = 0; + factory.MakeConfirmation("first", _ => + { + completed++; + factory.MakeConfirmation("reentrant", _ => completed++); + }); + + factory.Reset(); + + Assert.Equal(2, completed); + Assert.False(factory.IsOpen); + Assert.Equal(0, factory.PendingCount); + Assert.Null(root.Modal); + Assert.Empty(root.Children); + } + private static RetailDialogFactory CreateFactory( UiRoot root, List layouts) diff --git a/tests/AcDream.Core.Tests/Chat/ChatLogLocalGuidTests.cs b/tests/AcDream.Core.Tests/Chat/ChatLogLocalGuidTests.cs index bd280642..2c5dd3f4 100644 --- a/tests/AcDream.Core.Tests/Chat/ChatLogLocalGuidTests.cs +++ b/tests/AcDream.Core.Tests/Chat/ChatLogLocalGuidTests.cs @@ -53,4 +53,27 @@ public sealed class ChatLogLocalGuidTests Assert.Equal("You", log.Snapshot()[0].Sender); Assert.Equal(ChatKind.RangedSpeech, log.Snapshot()[0].Kind); } + + [Fact] + public void ResetSessionIdentity_RetainsTranscriptButClearsGuidAndDedupeWindow() + { + var log = new ChatLog(); + const uint oldGuid = 0x50000001u; + const uint newGuid = 0x50000002u; + log.SetLocalPlayerGuid(oldGuid); + log.OnSystemMessage("session boundary", 1u); + log.OnLocalSpeech("Old", "before", oldGuid, isRanged: false); + + log.ResetSessionIdentity(); + log.OnSystemMessage("session boundary", 1u); + log.OnLocalSpeech("Old", "after", oldGuid, isRanged: false); + log.SetLocalPlayerGuid(newGuid); + log.OnLocalSpeech("New", "new", newGuid, isRanged: false); + + ChatEntry[] entries = log.Snapshot(); + Assert.Equal(5, entries.Length); + Assert.Equal("You", entries[1].Sender); + Assert.Equal("Old", entries[3].Sender); + Assert.Equal("You", entries[4].Sender); + } } diff --git a/tests/AcDream.Core.Tests/Chat/TurbineChatStateTests.cs b/tests/AcDream.Core.Tests/Chat/TurbineChatStateTests.cs index d3c77036..eb9b3bb4 100644 --- a/tests/AcDream.Core.Tests/Chat/TurbineChatStateTests.cs +++ b/tests/AcDream.Core.Tests/Chat/TurbineChatStateTests.cs @@ -8,6 +8,32 @@ namespace AcDream.Core.Tests.Chat; /// public sealed class TurbineChatStateTests { + [Fact] + public void Reset_ClearsRoomsAndRestartsSessionCookieAtOne() + { + var state = new TurbineChatState(); + state.OnChannelsReceived(1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u); + Assert.Equal(1u, state.NextContextId()); + Assert.Equal(2u, state.NextContextId()); + + state.Reset(); + + Assert.False(state.Enabled); + Assert.Equal(0u, state.AllegianceRoom); + Assert.Equal(0u, state.GeneralRoom); + Assert.Equal(0u, state.TradeRoom); + Assert.Equal(0u, state.LfgRoom); + Assert.Equal(0u, state.RoleplayRoom); + Assert.Equal(0u, state.OlthoiRoom); + Assert.Equal(0u, state.SocietyRoom); + Assert.Equal(0u, state.SocietyCelestialHandRoom); + Assert.Equal(0u, state.SocietyEldrytchWebRoom); + Assert.Equal(0u, state.SocietyRadiantBloodRoom); + Assert.Equal(1u, state.NextContextId()); + state.Reset(); + Assert.Equal(1u, state.NextContextId()); + } + [Fact] public void InitialState_DisabledAndZeroRooms_NextContextIdStartsAt1() { diff --git a/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs b/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs index 0a0ad587..c0542b60 100644 --- a/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs +++ b/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs @@ -141,4 +141,31 @@ public sealed class CombatStateTests Assert.Equal(0, state.TrackedTargetCount); Assert.Equal(CombatMode.NonCombat, state.CurrentMode); } + + [Fact] + public void Clear_RetryRepublishesAndOneObserverCannotStarveAnother() + { + var state = new CombatState(); + state.SetCombatMode(CombatMode.Melee); + bool fail = true; + int delivered = 0; + state.CombatModeChanged += _ => + { + if (fail) + { + fail = false; + throw new InvalidOperationException("transient"); + } + }; + state.CombatModeChanged += mode => + { + Assert.Equal(CombatMode.NonCombat, mode); + delivered++; + }; + + Assert.Throws(state.Clear); + Assert.Equal(1, delivered); + state.Clear(); + Assert.Equal(2, delivered); + } } diff --git a/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs b/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs index 1fb7cdfb..b3bc83e6 100644 --- a/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs +++ b/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs @@ -68,6 +68,32 @@ public sealed class ExternalContainerStateTests Assert.False(state.ApplyViewContents(2u)); } + [Fact] + public void Reset_RetryRepublishesAndOneObserverCannotStarveAnother() + { + var state = Open(1u); + bool fail = true; + int delivered = 0; + state.Changed += _ => + { + if (fail) + { + fail = false; + throw new InvalidOperationException("transient"); + } + }; + state.Changed += transition => + { + Assert.Equal(ExternalContainerTransitionKind.Reset, transition.Kind); + delivered++; + }; + + Assert.Throws(() => state.Reset()); + Assert.Equal(1, delivered); + Assert.False(state.Reset()); + Assert.Equal(2, delivered); + } + private static ExternalContainerState Open(uint id) { var state = new ExternalContainerState(); diff --git a/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs b/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs index 66cb4efb..82d22ca6 100644 --- a/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs +++ b/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs @@ -436,4 +436,45 @@ public sealed class LocalPlayerStateTests s.OnProperties(props); Assert.False(s.DebitInt64Property(2u, 100L)); // already 0 — no negative banking } + + [Fact] + public void Clear_ReturnsEveryCharacterSnapshotToPreLoginState() + { + var s = new LocalPlayerState(); + var properties = new PropertyBundle(); + properties.Ints[1u] = 42; + s.OnVitalUpdate(7u, 1u, 2u, 3u, 4u); + s.OnAttributeUpdate(1u, 5u, 6u, 7u); + s.OnSkillUpdate(8u, 9u, 2u, 10u, 11u, 12u, 13d, 14u); + s.OnPositions(new Dictionary + { + [1u] = new AcDream.Core.Physics.Position( + 0xA9B40001u, + new System.Numerics.Vector3(1f, 2f, 3f), + System.Numerics.Quaternion.Identity), + }); + s.OnProperties(properties); + int characterChanges = 0; + var vitalChanges = new List(); + var attributeChanges = new List(); + s.CharacterChanged += () => characterChanges++; + s.Changed += vitalChanges.Add; + s.AttributeChanged += attributeChanges.Add; + + s.Clear(); + + Assert.Null(s.Get(LocalPlayerState.VitalKind.Health)); + Assert.Null(s.GetAttribute(LocalPlayerState.AttributeKind.Strength)); + Assert.Empty(s.Skills); + Assert.Empty(s.Positions); + Assert.Empty(s.Properties.Ints); + Assert.Equal(Enum.GetValues(), vitalChanges); + Assert.Equal(Enum.GetValues(), attributeChanges); + Assert.Equal(1, characterChanges); + + s.Clear(); + Assert.Null(s.Get(LocalPlayerState.VitalKind.Health)); + Assert.Empty(s.Skills); + Assert.Empty(s.Properties.Ints); + } } diff --git a/tests/AcDream.Core.Tests/Selection/SelectionStateTests.cs b/tests/AcDream.Core.Tests/Selection/SelectionStateTests.cs index be4b1a24..5210e6be 100644 --- a/tests/AcDream.Core.Tests/Selection/SelectionStateTests.cs +++ b/tests/AcDream.Core.Tests/Selection/SelectionStateTests.cs @@ -24,6 +24,33 @@ public sealed class SelectionStateTests Assert.False(state.SelectPrevious()); } + [Fact] + public void Reset_RetryRepublishesAndOneObserverCannotStarveAnother() + { + var state = new SelectionState(); + state.Select(0x50000001u, SelectionChangeSource.World); + bool fail = true; + int delivered = 0; + state.Changed += _ => + { + if (fail) + { + fail = false; + throw new InvalidOperationException("transient"); + } + }; + state.Changed += transition => + { + Assert.Equal(SelectionChangeReason.SessionReset, transition.Reason); + delivered++; + }; + + Assert.Throws(() => state.Reset()); + Assert.Equal(1, delivered); + Assert.False(state.Reset()); + Assert.Equal(2, delivered); + } + [Fact] public void Select_CommitsOneTransitionAndTracksPreviousLikeRetail() { diff --git a/tests/AcDream.Core.Tests/Social/SocialStateTests.cs b/tests/AcDream.Core.Tests/Social/SocialStateTests.cs index ef4feb11..a97a0382 100644 --- a/tests/AcDream.Core.Tests/Social/SocialStateTests.cs +++ b/tests/AcDream.Core.Tests/Social/SocialStateTests.cs @@ -4,6 +4,30 @@ namespace AcDream.Core.Tests.Social; public sealed class SocialStateTests { + [Fact] + public void SquelchState_Clear_ReturnsEmptyDatabase() + { + var state = new SquelchState(); + state.Replace(new SquelchDatabase( + new Dictionary { ["Account"] = 1u }, + new Dictionary + { + [2u] = new("Character", false, new HashSet { 3u }), + }, + new SquelchInfo("Global", true, new HashSet { 4u }))); + + state.Clear(); + + SquelchDatabase snapshot = state.Snapshot(); + Assert.Empty(snapshot.Accounts); + Assert.Empty(snapshot.Characters); + Assert.Empty(snapshot.Global.MessageTypes); + Assert.Equal(string.Empty, snapshot.Global.Name); + Assert.False(snapshot.Global.AccountWide); + state.Clear(); + Assert.Empty(state.Snapshot().Accounts); + } + [Fact] public void FriendsState_AppliesRetailIncrementalUpdateKindsByObjectId() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs index a29cca19..81a9510f 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs @@ -13,6 +13,23 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Chat; /// public sealed class ChatVMRetellAndProvidersTests { + [Fact] + public void ResetSessionTargets_ClearsReplyAndRetellWithoutClearingTranscript() + { + var log = new ChatLog(); + var vm = new ChatVM(log); + log.OnTellReceived("Bestie", "incoming", 0x50000001u); + log.OnSelfSent(ChatKind.Tell, "outgoing", targetOrChannel: "Caith"); + Assert.NotNull(vm.LastIncomingTellSender); + Assert.NotNull(vm.LastOutgoingTellTarget); + + vm.ResetSessionTargets(); + + Assert.Null(vm.LastIncomingTellSender); + Assert.Null(vm.LastOutgoingTellTarget); + Assert.Equal(2, log.Count); + } + [Fact] public void LastOutgoingTellTarget_StartsNull() {