refactor(net): converge live session state reset

This commit is contained in:
Erik 2026-07-21 12:00:48 +02:00
parent 78a9223b65
commit 4f31a5085f
35 changed files with 1460 additions and 83 deletions

View file

@ -403,6 +403,19 @@ public sealed class CombatAttackController : IDisposable
ResetPowerBar(); ResetPowerBar();
} }
/// <summary>
/// Restore the process-lived attack controller to retail's new-session
/// defaults without sending a cancel packet through the displaced session.
/// Mirrors <c>ClientCombatSystem::Begin @ 0x0056A460</c>.
/// </summary>
public void ResetSession()
{
Reset();
RequestedHeight = AttackHeight.Medium;
DesiredPower = InitialDesiredPower;
StateChanged?.Invoke();
}
public void Dispose() public void Dispose()
{ {
if (_disposed) return; if (_disposed) return;

View file

@ -386,11 +386,21 @@ internal sealed class SelectionInteractionController
public void ResetSession() public void ResetSession()
{ {
CancelPendingApproach(); List<Exception> failures = [];
_items.ResetSession(); try { CancelPendingApproach(); }
_selection.Reset(); catch (Exception error) { failures.Add(error); }
_outbound.Clear(); 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; _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) private bool ValidatePickupTarget(uint serverGuid, bool showToast)

View file

@ -0,0 +1,127 @@
namespace AcDream.App.Net;
using AcDream.App.World;
/// <summary>
/// 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.
/// </summary>
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; }
}
/// <summary>
/// The concrete, ordered character-session reset manifest. Retail
/// <c>CPlayerSystem::OnEndCharacterSession @ 0x00562870</c> calls End then
/// Begin; <c>ClientUISystem::OnEndCharacterSession @ 0x00564AD0</c>
/// independently resets gameplay UI. App resource dependencies impose the
/// exact projection-before-canonical-runtime order pinned here.
/// </summary>
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),
]);
}
}
/// <summary>Shared convergence gate for canonical live runtime teardown.</summary>
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}).");
}
}

View file

@ -0,0 +1,79 @@
namespace AcDream.App.Net;
/// <summary>One named owner operation in the live-session reset transaction.</summary>
internal sealed record LiveSessionResetStage(string Name, Action Reset);
/// <summary>
/// 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.
/// </summary>
internal sealed class LiveSessionResetPlan
{
private readonly LiveSessionResetStage[] _stages;
private int _executing;
public LiveSessionResetPlan(IEnumerable<LiveSessionResetStage> stages)
{
ArgumentNullException.ThrowIfNull(stages);
_stages = stages.ToArray();
var names = new HashSet<string>(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<string> 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<Exception>? 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;
}

View file

@ -66,10 +66,14 @@ public sealed class CameraController
public void ExitChaseMode() public void ExitChaseMode()
{ {
bool wasChaseMode = IsChaseMode;
Chase = null; Chase = null;
RetailChase = null; RetailChase = null;
if (_mode == Mode.Orbit)
return;
_mode = Mode.Fly; _mode = Mode.Fly;
ModeChanged?.Invoke(IsFlyMode); if (wasChaseMode)
ModeChanged?.Invoke(IsFlyMode);
} }
public void SetAspect(float aspect) public void SetAspect(float aspect)

View file

@ -817,6 +817,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// an ICommandBus and becomes inert before the displaced socket closes. // an ICommandBus and becomes inert before the displaced socket closes.
private AcDream.App.Net.LiveSessionEventRouter? _liveSessionEvents; private AcDream.App.Net.LiveSessionEventRouter? _liveSessionEvents;
private AcDream.App.Net.LiveSessionCommandRouter? _liveSessionCommands; private AcDream.App.Net.LiveSessionCommandRouter? _liveSessionCommands;
private AcDream.App.Net.LiveSessionResetPlan? _liveSessionResetPlan;
// Phase G.1-G.2 world lighting/time state. // Phase G.1-G.2 world lighting/time state.
public readonly AcDream.Core.World.WorldTimeService WorldTime = public readonly AcDream.Core.World.WorldTimeService WorldTime =
@ -1785,6 +1786,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// writes under the chosen character's // writes under the chosen character's
// name (or "default" pre-login). // name (or "default" pre-login).
settingsStore.SaveCharacter(_activeToonKey, character); settingsStore.SaveCharacter(_activeToonKey, character);
if (string.Equals(
_activeToonKey,
"default",
StringComparison.OrdinalIgnoreCase))
{
_persistedCharacter = character;
}
Console.WriteLine( Console.WriteLine(
$"settings: character[{_activeToonKey}] saved to " $"settings: character[{_activeToonKey}] saved to "
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
@ -2946,23 +2954,125 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private void ClearInboundEntityState() private void ClearInboundEntityState()
{ {
EndMouseLookAndRestoreCursor(); (_liveSessionResetPlan ??= CreateLiveSessionResetPlan()).Execute();
ResetTeleportTransitState(clearSession: true); }
if (_playerController is not null)
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
// Session-scoped origin readiness. A reconnect's first logical player private AcDream.App.Net.LiveSessionResetPlan CreateLiveSessionResetPlan() =>
// CreateObject must initialize the render/streaming center anew; AcDream.App.Net.LiveSessionResetManifest.Create(new()
// projection recovery inside one session never crosses this boundary. {
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<AcDream.Core.Items.ShortcutEntry>();
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; _liveCenterKnown = false;
}
// Attachment projections own GL-backed world registrations, so tear private void ResetSessionLiveRuntime()
// them down before dropping the canonical GUID/timestamp snapshots. {
_equippedChildRenderer?.Clear(); AcDream.App.Net.LiveSessionEntityRuntimeReset.ClearAndRequireConvergence(
_externalContainers.Reset(); _liveEntities);
Objects.Clear(); }
SpellBook.Clear();
_magicRuntime?.Reset(); private void ResetSessionInteraction()
{
if (_selectionInteractions is { } interactions) if (_selectionInteractions is { } interactions)
interactions.ResetSession(); interactions.ResetSession();
else else
@ -2970,36 +3080,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_itemInteractionController?.ResetSession(); _itemInteractionController?.ResetSession();
_selection.Reset(); _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();
}
}
}
} }
/// <summary> /// <summary>

View file

@ -66,6 +66,17 @@ public sealed class GameplayConfirmationController : IDisposable
return _dialogs.CloseDialog(_dialogContext); return _dialogs.CloseDialog(_dialogContext);
} }
/// <summary>
/// Forget any tuple left after the dialog factory has completed its normal
/// retail close/reset callbacks.
/// </summary>
public void ResetSession()
{
_dialogContext = 0u;
_serverType = 0u;
_serverContext = 0u;
}
public void Dispose() public void Dispose()
{ {
if (_disposed) return; if (_disposed) return;

View file

@ -42,6 +42,31 @@ public sealed class InteractionState
public bool Clear() => Set(InteractionMode.None); public bool Clear() => Set(InteractionMode.None);
/// <summary>
/// Publishes the session-reset edge even when the mode is already clear so
/// a retry can repair any observer that missed an earlier notification.
/// </summary>
public void ResetSession()
{
InteractionMode previous = Current;
Current = InteractionMode.None;
Action<InteractionModeTransition>? listeners = Changed;
if (listeners is null)
return;
var transition = new InteractionModeTransition(previous, InteractionMode.None);
List<Exception>? failures = null;
foreach (Action<InteractionModeTransition> 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) private bool Set(InteractionMode mode)
{ {
if (Current == mode) if (Current == mode)

View file

@ -1009,7 +1009,14 @@ public sealed class ItemInteractionController : IDisposable
}; };
private void OnInteractionModeChanged(InteractionModeTransition _) private void OnInteractionModeChanged(InteractionModeTransition _)
=> StateChanged?.Invoke(); {
List<Exception> failures = [];
DispatchAll(StateChanged, failures);
if (failures.Count != 0)
throw new AggregateException(
"One or more item-interaction observers failed.",
failures);
}
private void OnInventoryObjectMoved(ClientObjectMove move) private void OnInventoryObjectMoved(ClientObjectMove move)
{ {
@ -1124,17 +1131,50 @@ public sealed class ItemInteractionController : IDisposable
/// </summary> /// </summary>
public void ResetSession() public void ResetSession()
{ {
CancelPendingBackpackPlacement(); PendingBackpackPlacement? pendingPlacement = _pendingBackpackPlacement;
bool busyChanged = _busyCount != 0 || _pendingInventoryRequest is not null; _pendingBackpackPlacement = null;
bool modeChanged = IsAnyTargetModeActive;
_busyCount = 0; _busyCount = 0;
_pendingInventoryRequest = null; _pendingInventoryRequest = null;
_lastUseMs = long.MinValue / 2; _lastUseMs = long.MinValue / 2;
_consumedPrimaryClickTarget = 0u; _consumedPrimaryClickTarget = 0u;
_consumedPrimaryClickMs = long.MinValue / 2; _consumedPrimaryClickMs = long.MinValue / 2;
_interactionState.Clear();
if (busyChanged && !modeChanged) List<Exception> failures = [];
StateChanged?.Invoke(); 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<Exception> failures)
{
if (listeners is null)
return;
foreach (Action listener in listeners.GetInvocationList())
{
try { listener(); }
catch (Exception error) { failures.Add(error); }
}
}
private static void DispatchAll<T>(
Action<T>? listeners,
T value,
List<Exception> failures)
{
if (listeners is null)
return;
foreach (Action<T> listener in listeners.GetInvocationList())
{
try { listener(value); }
catch (Exception error) { failures.Add(error); }
}
} }
private bool ConsumeUseThrottle() private bool ConsumeUseThrottle()

View file

@ -26,6 +26,7 @@ public sealed class RetailDialogFactory : IDisposable
private readonly Dictionary<uint, LinkedList<DialogInfo>> _pending = new(); private readonly Dictionary<uint, LinkedList<DialogInfo>> _pending = new();
private readonly List<DialogInfo> _openOrder = new(); private readonly List<DialogInfo> _openOrder = new();
private uint _globalContext; private uint _globalContext;
private bool _resetting;
private bool _disposed; private bool _disposed;
public RetailDialogFactory( public RetailDialogFactory(
@ -192,18 +193,46 @@ public sealed class RetailDialogFactory : IDisposable
/// </summary> /// </summary>
public void Reset() public void Reset()
{ {
DialogInfo[] infos = _activeNonQueued.Values if (_resetting)
.Concat(_activeQueued.Values) return;
.Concat(_pending.Values.SelectMany(static queue => queue))
.Distinct()
.ToArray();
_activeNonQueued.Clear(); _resetting = true;
_activeQueued.Clear(); List<Exception>? failures = null;
_pending.Clear(); try
foreach (DialogInfo info in infos) {
DialogDone(info); while (true)
RefreshModal(); {
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() public void Dispose()
@ -283,10 +312,10 @@ public sealed class RetailDialogFactory : IDisposable
{ {
if (info.View is { } view) if (info.View is { } view)
{ {
info.View = null;
view.DetachHandlers(); view.DetachHandlers();
_openOrder.Remove(info);
_host.RemoveChild(view.Root); _host.RemoveChild(view.Root);
info.View = null;
_openOrder.Remove(info);
} }
RefreshModal(); RefreshModal();
} }

View file

@ -386,6 +386,25 @@ public sealed class RetailUiRuntime : IDisposable
data => completed(data.GetBoolean(RetailDialogProperty.ConfirmationResult))) ?? 0u; data => completed(data.GetBoolean(RetailDialogProperty.ConfirmationResult))) ?? 0u;
} }
/// <summary>
/// Close character-session dialogs through retail's normal callback and
/// close-notice path, then forget the gameplay confirmation tuple.
/// <c>gmGamePlayUI::~gmGamePlayUI @ 0x004EA2A0</c> closes its retained
/// contexts before <c>DialogFactory::Reset @ 0x00477950</c> drains the
/// remaining factory entries.
/// </summary>
public void ResetSessionDialogs()
{
try
{
DialogFactory?.Reset();
}
finally
{
_gameplayConfirmationController?.ResetSession();
}
}
public void UpdateCursor(IEnumerable<IMouse> mice) public void UpdateCursor(IEnumerable<IMouse> mice)
{ {
CursorFeedback feedback = _bindings.Cursor.Feedback.Update(Host.Root); CursorFeedback feedback = _bindings.Cursor.Feedback.Update(Host.Root);

View file

@ -62,14 +62,27 @@ public sealed class ChatLog
/// <summary> /// <summary>
/// Push the authoritative local-player GUID from <c>WorldSession</c>. /// Push the authoritative local-player GUID from <c>WorldSession</c>.
/// One-way setter — only <c>GameWindow</c> should call it, exactly /// The host sets it after character selection and resets it at session
/// once per live session. Used by <see cref="OnLocalSpeech"/> to /// teardown. Used by <see cref="OnLocalSpeech"/> to
/// recognize ACE's HearSpeech echo of our own /say (server's /// recognize ACE's HearSpeech echo of our own /say (server's
/// HandleActionTalk broadcasts to all in range INCLUDING the /// HandleActionTalk broadcasts to all in range INCLUDING the
/// sender) and re-render it as <c>"You say, ..."</c>. /// sender) and re-render it as <c>"You say, ..."</c>.
/// </summary> /// </summary>
public void SetLocalPlayerGuid(uint guid) => _localPlayerGuid = guid; public void SetLocalPlayerGuid(uint guid) => _localPlayerGuid = guid;
/// <summary>
/// Reset per-session speaker classification and duplicate suppression
/// while preserving the visible transcript. Retail has an explicit
/// <c>ChatInterface::RecvNotice_ClearChatBuffer @ 0x004F2F60</c> path;
/// character-session teardown does not use that clear-buffer notice.
/// </summary>
public void ResetSessionIdentity()
{
_localPlayerGuid = 0u;
_lastSystemText = string.Empty;
_lastSystemAt = DateTime.MinValue;
}
// ── Inbound adapters ───────────────────────────────────────────────────── // ── Inbound adapters ─────────────────────────────────────────────────────
/// <summary>Local or ranged HearSpeech (0x02BB / 0x02BC).</summary> /// <summary>Local or ranged HearSpeech (0x02BB / 0x02BC).</summary>

View file

@ -93,6 +93,33 @@ public sealed class TurbineChatState
SocietyRadiantBloodRoom = societyRadiantBloodRoom; SocietyRadiantBloodRoom = societyRadiantBloodRoom;
} }
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Retail <c>CCommunicationSystem</c> construction at <c>0x005566C0</c>
/// 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.
/// </remarks>
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;
}
/// <summary> /// <summary>
/// Look up the runtime room id for a Turbine /// Look up the runtime room id for a Turbine
/// <see cref="ChatChannelKindLite"/>. Returns 0 if the channel is not /// <see cref="ChatChannelKindLite"/>. Returns 0 if the channel is not

View file

@ -178,6 +178,21 @@ public sealed class CombatState
public void Clear() public void Clear()
{ {
_healthByGuid.Clear(); _healthByGuid.Clear();
SetCombatMode(CombatMode.NonCombat); CurrentMode = CombatMode.NonCombat;
Action<CombatMode>? listeners = CombatModeChanged;
if (listeners is null)
return;
List<Exception>? failures = null;
foreach (Action<CombatMode> 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);
} }
} }

View file

@ -101,12 +101,24 @@ public sealed class ExternalContainerState
bool changed = previous != 0u || RequestedContainerId != 0u; bool changed = previous != 0u || RequestedContainerId != 0u;
CurrentContainerId = 0u; CurrentContainerId = 0u;
RequestedContainerId = 0u; RequestedContainerId = 0u;
if (changed)
var transition = new ExternalContainerTransition(
ExternalContainerTransitionKind.Reset,
previous,
0u);
Action<ExternalContainerTransition>? listeners = Changed;
if (listeners is not null)
{ {
Changed?.Invoke(new ExternalContainerTransition( List<Exception>? failures = null;
ExternalContainerTransitionKind.Reset, foreach (Action<ExternalContainerTransition> listener in listeners.GetInvocationList())
previous, {
0u)); 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; return changed;
} }

View file

@ -454,6 +454,39 @@ public sealed class LocalPlayerState
return true; return true;
} }
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Retail: <c>CPlayerSystem::OnEndCharacterSession @ 0x00562870</c>
/// calls <c>End @ 0x005606C0</c>, which invokes
/// <c>PlayerModule::Clear @ 0x005D48A0</c>, then immediately calls
/// <c>CPlayerSystem::Begin @ 0x0055D410</c>. Re-publishing invalidation
/// events is acdream's process-lived-view adaptation.
/// </remarks>
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<AttributeKind>())
AttributeChanged?.Invoke(kind);
CharacterChanged?.Invoke();
}
private static uint SaturatingAdd(uint value, uint delta) private static uint SaturatingAdd(uint value, uint delta)
=> uint.MaxValue - value < delta ? uint.MaxValue : value + delta; => uint.MaxValue - value < delta ? uint.MaxValue : value + delta;

View file

@ -78,20 +78,25 @@ public sealed class SelectionState : ISelectionService
public bool Reset(SelectionChangeSource source = SelectionChangeSource.System) public bool Reset(SelectionChangeSource source = SelectionChangeSource.System)
{ {
uint? old = SelectedObjectId; uint? old = SelectedObjectId;
bool changed = old is not null
|| PreviousObjectId is not null
|| PreviousValidObjectId is not null;
SelectedObjectId = null; SelectedObjectId = null;
PreviousObjectId = null; PreviousObjectId = null;
PreviousValidObjectId = null; PreviousValidObjectId = null;
if (old is null)
return false;
var transition = new SelectionTransition( var transition = new SelectionTransition(
old, old,
null, null,
source, source,
SelectionChangeReason.SessionReset); SelectionChangeReason.SessionReset);
Changed?.Invoke(transition); List<Exception>? failures = DispatchChanged(transition);
DispatchPluginChanged(new SelectionChangedEvent(old, null)); 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) bool ISelectionService.Select(uint objectId)
@ -120,6 +125,21 @@ public sealed class SelectionState : ISelectionService
return true; return true;
} }
private List<Exception>? DispatchChanged(SelectionTransition transition)
{
Action<SelectionTransition>? listeners = Changed;
if (listeners is null)
return null;
List<Exception>? failures = null;
foreach (Action<SelectionTransition> listener in listeners.GetInvocationList())
{
try { listener(transition); }
catch (Exception error) { (failures ??= []).Add(error); }
}
return failures;
}
private void DispatchPluginChanged(SelectionChangedEvent change) private void DispatchPluginChanged(SelectionChangedEvent change)
{ {
Action<SelectionChangedEvent>? listeners = PluginChanged; Action<SelectionChangedEvent>? listeners = PluginChanged;

View file

@ -16,6 +16,17 @@ public sealed class SquelchState
ArgumentNullException.ThrowIfNull(database); ArgumentNullException.ThrowIfNull(database);
lock (_gate) _database = database; lock (_gate) _database = database;
} }
/// <summary>
/// Drop the server-owned squelch database for the old session. Retail
/// <c>CPlayerSystem::LogOnCharacter @ 0x0055F890</c> calls
/// <c>gmCCommunicationSystem::ClearSquelchDB @ 0x00589240</c> before
/// entering the next character.
/// </summary>
public void Clear()
{
lock (_gate) _database = SquelchDatabase.Empty;
}
} }
public sealed record SquelchInfo( public sealed record SquelchInfo(

View file

@ -116,6 +116,17 @@ public sealed class ChatVM
/// </summary> /// </summary>
public void Clear() => _log.Clear(); public void Clear() => _log.Clear();
/// <summary>
/// Forget per-session reply/retell destinations while retaining the shared
/// transcript. Old character names must not become command targets after a
/// reconnect.
/// </summary>
public void ResetSessionTargets()
{
LastIncomingTellSender = null;
LastOutgoingTellTarget = null;
}
/// <summary> /// <summary>
/// Print the current framerate into chat. Used by /// Print the current framerate into chat. Used by
/// <c>/framerate</c> / <c>@framerate</c>. Falls back to a /// <c>/framerate</c> / <c>@framerate</c>. Falls back to a

View file

@ -55,8 +55,8 @@ public sealed class VitalsVM
/// <summary> /// <summary>
/// Push the authoritative local-player GUID from <c>WorldSession</c>. /// Push the authoritative local-player GUID from <c>WorldSession</c>.
/// One-way setter — only <c>GameWindow</c> should call it, exactly once /// The host assigns it after character selection and resets it to zero at
/// per live session. /// session teardown.
/// </summary> /// </summary>
public void SetLocalPlayerGuid(uint guid) => _localPlayerGuid = guid; public void SetLocalPlayerGuid(uint guid) => _localPlayerGuid = guid;

View file

@ -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( private static CombatAttackController Create(
CombatState combat, CombatState combat,
Func<double> now, Func<double> now,

View file

@ -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<string>();
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<AggregateException>(plan.Execute);
Assert.Equal(["first", "second", "third"], calls);
Assert.Collection(
error.InnerExceptions,
first => Assert.Equal(
"first",
Assert.IsType<LiveSessionResetStageException>(first).StageName),
third => Assert.Equal(
"third",
Assert.IsType<LiveSessionResetStageException>(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<AggregateException>(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<AggregateException>(plan.Execute);
Assert.True(tailRan);
LiveSessionResetStageException stage = Assert.IsType<LiveSessionResetStageException>(
Assert.Single(error.InnerExceptions));
Assert.Equal("reentrant", stage.StageName);
Assert.IsType<InvalidOperationException>(stage.InnerException);
}
[Fact]
public void Constructor_RejectsDuplicateStageNames()
{
Assert.Throws<ArgumentException>(() => 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<WorldEntity> { 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<string, uint> { ["A"] = 1u },
new Dictionary<uint, SquelchInfo>(),
new SquelchInfo("A", false, new HashSet<uint>())));
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<string, int>(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<AggregateException>(plan.Execute);
Assert.Equal(
["live runtime", "session identity"],
first.InnerExceptions
.Cast<LiveSessionResetStageException>()
.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<MeshRef>(),
};
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<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
"fixture",
null,
null,
0x09000001u,
PhysicsState: (uint)state,
InstanceSequence: instance,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: positionSequence,
Physics: physics);
}
}

View file

@ -92,4 +92,33 @@ public class CameraControllerTests
CameraDiagnostics.UseRetailChaseCamera = saved; 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);
}
} }

View file

@ -55,4 +55,25 @@ public sealed class GameplayConfirmationControllerTests
Assert.Equal([(7u, 99u, false)], responses); 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);
}
} }

View file

@ -29,4 +29,31 @@ public sealed class InteractionStateTests
Assert.Throws<ArgumentOutOfRangeException>(() => state.EnterUseItemOnTarget(0)); Assert.Throws<ArgumentOutOfRangeException>(() => state.EnterUseItemOnTarget(0));
Assert.Equal(InteractionMode.None, state.Current); 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<AggregateException>(state.ResetSession);
Assert.Equal(1, delivered);
state.ResetSession();
Assert.Equal(2, delivered);
}
} }

View file

@ -122,6 +122,33 @@ public sealed class ItemInteractionControllerTests
Assert.Empty(h.UseWithTarget); 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<AggregateException>(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] [Fact]
public void ExternalContainerUse_RequestsGroundObjectAndSendsUse() public void ExternalContainerUse_RequestsGroundObjectAndSendsUse()
{ {

View file

@ -187,6 +187,74 @@ public sealed class RetailDialogFactoryTests
Assert.Null(root.Modal); Assert.Null(root.Modal);
} }
[Fact]
public void Reset_CompletesEveryDialogAndKeepsContextSequenceMonotonic()
{
var root = new UiRoot { Width = 800f, Height = 600f };
var layouts = new List<ImportedLayout>();
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<ImportedLayout>();
var factory = CreateFactory(root, layouts);
int completed = 0;
factory.MakeConfirmation("active", _ => throw new InvalidOperationException("boom"));
factory.MakeConfirmation("pending", _ => completed++);
AggregateException error = Assert.Throws<AggregateException>(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<ImportedLayout>();
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( private static RetailDialogFactory CreateFactory(
UiRoot root, UiRoot root,
List<ImportedLayout> layouts) List<ImportedLayout> layouts)

View file

@ -53,4 +53,27 @@ public sealed class ChatLogLocalGuidTests
Assert.Equal("You", log.Snapshot()[0].Sender); Assert.Equal("You", log.Snapshot()[0].Sender);
Assert.Equal(ChatKind.RangedSpeech, log.Snapshot()[0].Kind); 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);
}
} }

View file

@ -8,6 +8,32 @@ namespace AcDream.Core.Tests.Chat;
/// </summary> /// </summary>
public sealed class TurbineChatStateTests 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] [Fact]
public void InitialState_DisabledAndZeroRooms_NextContextIdStartsAt1() public void InitialState_DisabledAndZeroRooms_NextContextIdStartsAt1()
{ {

View file

@ -141,4 +141,31 @@ public sealed class CombatStateTests
Assert.Equal(0, state.TrackedTargetCount); Assert.Equal(0, state.TrackedTargetCount);
Assert.Equal(CombatMode.NonCombat, state.CurrentMode); 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<AggregateException>(state.Clear);
Assert.Equal(1, delivered);
state.Clear();
Assert.Equal(2, delivered);
}
} }

View file

@ -68,6 +68,32 @@ public sealed class ExternalContainerStateTests
Assert.False(state.ApplyViewContents(2u)); 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<AggregateException>(() => state.Reset());
Assert.Equal(1, delivered);
Assert.False(state.Reset());
Assert.Equal(2, delivered);
}
private static ExternalContainerState Open(uint id) private static ExternalContainerState Open(uint id)
{ {
var state = new ExternalContainerState(); var state = new ExternalContainerState();

View file

@ -436,4 +436,45 @@ public sealed class LocalPlayerStateTests
s.OnProperties(props); s.OnProperties(props);
Assert.False(s.DebitInt64Property(2u, 100L)); // already 0 — no negative banking 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<uint, AcDream.Core.Physics.Position>
{
[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<LocalPlayerState.VitalKind>();
var attributeChanges = new List<LocalPlayerState.AttributeKind>();
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<LocalPlayerState.VitalKind>(), vitalChanges);
Assert.Equal(Enum.GetValues<LocalPlayerState.AttributeKind>(), attributeChanges);
Assert.Equal(1, characterChanges);
s.Clear();
Assert.Null(s.Get(LocalPlayerState.VitalKind.Health));
Assert.Empty(s.Skills);
Assert.Empty(s.Properties.Ints);
}
} }

View file

@ -24,6 +24,33 @@ public sealed class SelectionStateTests
Assert.False(state.SelectPrevious()); 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<AggregateException>(() => state.Reset());
Assert.Equal(1, delivered);
Assert.False(state.Reset());
Assert.Equal(2, delivered);
}
[Fact] [Fact]
public void Select_CommitsOneTransitionAndTracksPreviousLikeRetail() public void Select_CommitsOneTransitionAndTracksPreviousLikeRetail()
{ {

View file

@ -4,6 +4,30 @@ namespace AcDream.Core.Tests.Social;
public sealed class SocialStateTests public sealed class SocialStateTests
{ {
[Fact]
public void SquelchState_Clear_ReturnsEmptyDatabase()
{
var state = new SquelchState();
state.Replace(new SquelchDatabase(
new Dictionary<string, uint> { ["Account"] = 1u },
new Dictionary<uint, SquelchInfo>
{
[2u] = new("Character", false, new HashSet<uint> { 3u }),
},
new SquelchInfo("Global", true, new HashSet<uint> { 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] [Fact]
public void FriendsState_AppliesRetailIncrementalUpdateKindsByObjectId() public void FriendsState_AppliesRetailIncrementalUpdateKindsByObjectId()
{ {

View file

@ -13,6 +13,23 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
/// </summary> /// </summary>
public sealed class ChatVMRetellAndProvidersTests 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] [Fact]
public void LastOutgoingTellTarget_StartsNull() public void LastOutgoingTellTarget_StartsNull()
{ {