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

@ -62,14 +62,27 @@ public sealed class ChatLog
/// <summary>
/// Push the authoritative local-player GUID from <c>WorldSession</c>.
/// One-way setter — only <c>GameWindow</c> should call it, exactly
/// once per live session. Used by <see cref="OnLocalSpeech"/> to
/// The host sets it after character selection and resets it at session
/// teardown. Used by <see cref="OnLocalSpeech"/> to
/// recognize ACE's HearSpeech echo of our own /say (server's
/// HandleActionTalk broadcasts to all in range INCLUDING the
/// sender) and re-render it as <c>"You say, ..."</c>.
/// </summary>
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 ─────────────────────────────────────────────────────
/// <summary>Local or ranged HearSpeech (0x02BB / 0x02BC).</summary>

View file

@ -93,6 +93,33 @@ public sealed class TurbineChatState
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>
/// Look up the runtime room id for a Turbine
/// <see cref="ChatChannelKindLite"/>. Returns 0 if the channel is not

View file

@ -178,6 +178,21 @@ public sealed class CombatState
public void 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;
CurrentContainerId = 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(
ExternalContainerTransitionKind.Reset,
previous,
0u));
List<Exception>? failures = null;
foreach (Action<ExternalContainerTransition> listener in listeners.GetInvocationList())
{
try { listener(transition); }
catch (Exception error) { (failures ??= []).Add(error); }
}
if (failures is not null)
throw new AggregateException(
"One or more external-container reset observers failed.",
failures);
}
return changed;
}

View file

@ -454,6 +454,39 @@ public sealed class LocalPlayerState
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)
=> 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)
{
uint? old = SelectedObjectId;
bool changed = old is not null
|| PreviousObjectId is not null
|| PreviousValidObjectId is not null;
SelectedObjectId = null;
PreviousObjectId = null;
PreviousValidObjectId = null;
if (old is null)
return false;
var transition = new SelectionTransition(
old,
null,
source,
SelectionChangeReason.SessionReset);
Changed?.Invoke(transition);
List<Exception>? failures = DispatchChanged(transition);
DispatchPluginChanged(new SelectionChangedEvent(old, null));
return true;
if (failures is not null)
throw new AggregateException(
"One or more selection reset observers failed.",
failures);
return changed;
}
bool ISelectionService.Select(uint objectId)
@ -120,6 +125,21 @@ public sealed class SelectionState : ISelectionService
return true;
}
private List<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)
{
Action<SelectionChangedEvent>? listeners = PluginChanged;

View file

@ -16,6 +16,17 @@ public sealed class SquelchState
ArgumentNullException.ThrowIfNull(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(