refactor(net): converge live session state reset
This commit is contained in:
parent
78a9223b65
commit
4f31a5085f
35 changed files with 1460 additions and 83 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
127
src/AcDream.App/Net/LiveSessionResetManifest.cs
Normal file
127
src/AcDream.App/Net/LiveSessionResetManifest.cs
Normal 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}).");
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/AcDream.App/Net/LiveSessionResetPlan.cs
Normal file
79
src/AcDream.App/Net/LiveSessionResetPlan.cs
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
387
tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs
Normal file
387
tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue