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()
{