refactor(runtime): own communication and social state

Construct chat history, negotiated channels, friends, squelch, and reply targets in one presentation-independent Runtime owner. Make live routing, retained UI, devtools, and current-runtime projections borrow the exact instances, preserve reconnect reset semantics, and publish failure-isolated reentrant-safe chat commits.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-26 07:50:16 +02:00
parent e321410770
commit c9d25ade50
19 changed files with 684 additions and 117 deletions

View file

@ -18,6 +18,7 @@ using AcDream.Core.Items;
using AcDream.Core.Player;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Vitals;
@ -49,7 +50,7 @@ internal sealed record InteractionRetainedUiDependencies(
ClientObjectTable Objects,
MagicCatalog MagicCatalog,
Spellbook Spellbook,
ChatLog Chat,
RuntimeCommunicationState Communication,
LocalPlayerState LocalPlayer,
ItemManaState ItemMana,
StackSplitQuantityState StackSplitQuantity,
@ -317,7 +318,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
session.CurrentSession?.SendStackableSplitTo3D(item, amount),
selectedObjectId: () => d.Selection.SelectedObjectId ?? 0u,
stackSplitQuantity: d.StackSplitQuantity,
systemMessage: text => d.Chat.OnSystemMessage(text, 0x1Au),
systemMessage:
text => d.Communication.Chat.OnSystemMessage(text, 0x1Au),
sendPutItemInContainer: (item, container, placement) =>
session.CurrentSession?.SendPutItemInContainer(
item,
@ -400,7 +402,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
late.Session.CurrentSession?.SendCastUntargetedSpell(spellId),
sendTargeted: (target, spellId) =>
late.Session.CurrentSession?.SendCastTargetedSpell(target, spellId),
displayMessage: text => d.Chat.OnSystemMessage(text, 0x1Au),
displayMessage:
text => d.Communication.Chat.OnSystemMessage(text, 0x1Au),
incrementBusy: itemInteraction.IncrementBusyCount,
canSend: () => late.Session.IsInWorld);
checkpoint(InteractionRetainedUiCompositionPoint.MagicRuntimeCreated);
@ -477,7 +480,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
host.Root.Width = d.Window.Size.X;
host.Root.Height = d.Window.Size.Y;
var chat = new ChatVM(d.Chat, displayLimit: 200);
var chat = new ChatVM(
d.Communication.Chat,
displayLimit: 200,
commandTargets: d.Communication.CommandTargets);
AcDream.UI.Abstractions.Panels.Settings.SettingsStore? layoutStore =
d.Settings.LayoutStore;
RetailUiPersistenceBindings? persistence = layoutStore is null
@ -663,7 +669,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
late.Session.CurrentSession?.SendSetInscription(
item,
inscription),
text => d.Chat.OnSystemMessage(text, 0x1Au)),
text =>
d.Communication.Chat.OnSystemMessage(text, 0x1Au)),
StackSplitQuantity: d.StackSplitQuantity,
Plugins: d.UiRegistry,
Persistence: persistence,

View file

@ -27,6 +27,7 @@ using AcDream.Core.Social;
using AcDream.Core.Spells;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
using Silk.NET.Windowing;
@ -84,13 +85,10 @@ internal sealed record SessionPlayerDependencies(
CombatAttackOperationsSlot CombatAttackOperations,
CombatState Combat,
CombatFeedbackSlot CombatFeedback,
ChatLog Chat,
RuntimeCommunicationState Communication,
LocalPlayerState LocalPlayer,
Spellbook Spellbook,
ItemManaState ItemMana,
FriendsState Friends,
SquelchState Squelch,
TurbineChatState TurbineChat,
ExternalContainerState ExternalContainers,
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
Action<string> Log);
@ -798,8 +796,6 @@ internal sealed class SessionPlayerCompositionPhase
AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? vitals =
d.SettingsDevTools.DevTools?.Vitals
?? interaction.RetainedUi?.Vitals;
AcDream.UI.Abstractions.Panels.Chat.ChatVM? retailChat =
interaction.RetainedUi?.Chat;
var sessionRuntimeFactory = new LiveSessionRuntimeFactory(
new LiveSessionPlayerRuntime(
d.PlayerIdentity,
@ -815,15 +811,11 @@ internal sealed class SessionPlayerCompositionPhase
d.Spellbook,
d.Combat,
d.ItemMana,
d.Friends,
d.Squelch,
d.TurbineChat,
d.ExternalContainers,
d.Chat),
d.Communication),
new LiveSessionUiRuntime(
interaction.RetainedUi?.Runtime,
vitals,
retailChat,
interaction.RetainedUi?.CharacterSheet,
interaction.RetainedUi?.Magic,
live.PaperdollPresenter),
@ -881,7 +873,7 @@ internal sealed class SessionPlayerCompositionPhase
interaction.ItemInteraction),
d.Log,
debugToast,
text => d.Chat.OnSystemMessage(text, 0x1Au));
text => d.Communication.Chat.OnSystemMessage(text, 0x1Au));
bindings.Adopt(
"live combat-mode commands",
d.CombatModeCommands.BindOwned(combatCommand));
@ -892,7 +884,7 @@ internal sealed class SessionPlayerCompositionPhase
d.PlayerIdentity,
live.LiveEntities,
d.EntityObjects,
d.Chat,
d.Communication,
d.PlayerController,
worldReveal,
d.UpdateClock,

View file

@ -6,6 +6,7 @@ using AcDream.App.Settings;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
using AcDream.Core.Player;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Debug;
@ -34,7 +35,7 @@ internal sealed record SettingsDevToolsDependencies(
RuntimeSettingsController Settings,
IRuntimeSettingsStartupTarget StartupTarget,
HostQuiescenceGate HostQuiescence,
ChatLog Chat,
RuntimeCommunicationState Communication,
CombatState Combat,
LocalPlayerState LocalPlayer,
SettingsDevToolsOptionalDependencies? DevTools,
@ -418,7 +419,10 @@ internal sealed class SettingsDevToolsCompositionPhase :
var chatLease = scope.Acquire(
"developer chat view model",
() => new ChatVM(_dependencies.Chat)
() => new ChatVM(
_dependencies.Communication.Chat,
commandTargets:
_dependencies.Communication.CommandTargets)
{
FpsProvider = () => optional.Facts.Fps,
PositionProvider = () => optional.Facts.PlayerPosition,

View file

@ -24,6 +24,7 @@ using AcDream.Core.Spells;
using AcDream.Core.World;
using AcDream.Content;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Vitals;
@ -47,16 +48,12 @@ internal sealed record LiveSessionDomainRuntime(
Spellbook Spellbook,
CombatState Combat,
ItemManaState ItemMana,
FriendsState Friends,
SquelchState Squelch,
TurbineChatState TurbineChat,
ExternalContainerState ExternalContainers,
ChatLog Chat);
RuntimeCommunicationState Communication);
internal sealed record LiveSessionUiRuntime(
RetailUiRuntime? RetailUi,
VitalsVM? Vitals,
ChatVM? RetailChat,
CharacterSheetProvider? CharacterSheet,
MagicRuntime? Magic,
PaperdollFramePresenter? Paperdoll);
@ -140,7 +137,7 @@ internal sealed class LiveSessionRuntimeFactory
Selection: new(
SetPlayerIdentity: id => _player.Identity.ServerGuid = id,
SetVitalsIdentity: id => _ui.Vitals?.SetLocalPlayerGuid(id),
SetChatIdentity: _domain.Chat.SetLocalPlayerGuid,
SetChatIdentity: _domain.Communication.Chat.SetLocalPlayerGuid,
MarkPersistent: _world.WorldState.MarkPersistent,
SetVanishProbeIdentity: id => EntityVanishProbe.PlayerGuid = id,
ClearCombat: _domain.Combat.Clear),
@ -151,11 +148,11 @@ internal sealed class LiveSessionRuntimeFactory
LoadCharacterSettings: _interaction.Settings.LoadCharacterContext,
ArmPlayerModeAutoEntry: _interaction.PlayerModeAutoEntry.Arm),
Connecting: (host, port, user) =>
_domain.Chat.OnSystemMessage(
_domain.Communication.Chat.OnSystemMessage(
$"connecting to {host}:{port} as {user}",
chatType: 1),
Connected: () =>
_domain.Chat.OnSystemMessage(
_domain.Communication.Chat.OnSystemMessage(
"connected — character list received",
chatType: 1)),
connectOptions);
@ -167,7 +164,7 @@ internal sealed class LiveSessionRuntimeFactory
PlayerPresentation = ResetPlayerPresentation,
TeleportTransit = _world.Teleport.ResetSession,
SessionDialogs = () => _ui.RetailUi?.ResetSessionTransientUi(),
ChatCommandTargets = () => _ui.RetailChat?.ResetSessionTargets(),
ChatCommandTargets = _domain.Communication.ResetCommandTargets,
SettingsCharacterContext =
_interaction.Settings.RestoreDefaultCharacterContext,
EquippedChildren = _world.EquippedChildren.Clear,
@ -181,9 +178,9 @@ internal sealed class LiveSessionRuntimeFactory
CombatState = _domain.Combat.Clear,
ItemMana = _domain.ItemMana.Clear,
LocalPlayer = _domain.LocalPlayer.Clear,
Friends = _domain.Friends.Clear,
Squelch = _domain.Squelch.Clear,
TurbineChat = _domain.TurbineChat.Reset,
Friends = _domain.Communication.ResetFriends,
Squelch = _domain.Communication.ResetSquelch,
TurbineChat = _domain.Communication.ResetNegotiatedChannels,
ParticleVisibility = _world.ParticleVisibility.Reset,
InboundEventFifo = _world.InboundEvents.Clear,
LiveLiveness = _world.Liveness.Clear,
@ -218,7 +215,7 @@ internal sealed class LiveSessionRuntimeFactory
// default comes from PlayerModule::PlayerModule @ 0x005D51F0.
_player.Identity.ServerGuid = 0u;
_ui.Vitals?.SetLocalPlayerGuid(0u);
_domain.Chat.ResetSessionIdentity();
_domain.Communication.ResetChatIdentity();
EntityVanishProbe.PlayerGuid = 0u;
_interaction.Settings.ResetActiveCharacterKey();
_player.CharacterOptions.Reset();
@ -254,10 +251,10 @@ internal sealed class LiveSessionRuntimeFactory
CreateInventoryBindings(),
CreateCharacterBindings(skillTable),
new LiveSocialSessionBindings(
_domain.Chat,
_domain.TurbineChat,
_domain.Friends,
_domain.Squelch));
_domain.Communication.Chat,
_domain.Communication.TurbineChat,
_domain.Communication.Friends,
_domain.Communication.Squelch));
}
private LiveInventorySessionBindings CreateInventoryBindings() => new(
@ -331,8 +328,10 @@ internal sealed class LiveSessionRuntimeFactory
ToggleUiLock: () =>
_interaction.Settings.SetUiLocked(
!_interaction.Settings.Gameplay.LockUI),
ShowSystemMessage: text => _domain.Chat.OnSystemMessage(text, 0u),
ShowWeenieError: code => _domain.Chat.OnWeenieError(code, null),
ShowSystemMessage:
text => _domain.Communication.Chat.OnSystemMessage(text, 0u),
ShowWeenieError:
code => _domain.Communication.Chat.OnWeenieError(code, null),
PlayerPublicWeenieBitfield: () =>
_domain.EntityObjects.Objects.Get(_player.Identity.ServerGuid)?
.PublicWeenieBitfield,
@ -346,7 +345,7 @@ internal sealed class LiveSessionRuntimeFactory
ShowConfirmation: (message, completed) =>
_ui.RetailUi?.ShowConfirmation(message, completed),
Suicide: session.SendSuicide,
ClearChat: _ => _domain.Chat.Clear(),
ClearChat: _ => _domain.Communication.Chat.Clear(),
SaveUi: name => _ui.RetailUi?.SaveNamedLayout(name),
LoadUi: name => _ui.RetailUi?.RestoreNamedLayout(name),
SaveAutoUi: () => _ui.RetailUi?.SaveLayout(),
@ -363,16 +362,17 @@ internal sealed class LiveSessionRuntimeFactory
ClearConsent: session.SendClearConsent,
RemoveConsent: session.SendRemoveConsent,
SendEmote: session.SendEmote,
_domain.Friends,
_domain.Communication.Friends,
AddFriend: session.SendAddFriend,
RemoveFriend: session.SendRemoveFriend,
ClearFriends: session.SendClearFriends,
RequestLegacyFriends: session.SendLegacyFriendsListRequest,
_domain.Squelch,
_domain.Communication.Squelch,
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
ModifyAccountSquelch: session.SendModifyAccountSquelch,
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
LastTeller: () => _ui.RetailChat?.LastIncomingTellSender,
LastTeller: () =>
_domain.Communication.CommandTargets.LastIncomingTellSender,
ClearDesiredComponents: () =>
{
session.SendClearDesiredComponents();
@ -380,8 +380,8 @@ internal sealed class LiveSessionRuntimeFactory
},
HasOpenVendor: () => false,
FillComponentBuyList: (_, _) => { }),
_domain.Chat,
_domain.TurbineChat,
_domain.Communication.Chat,
_domain.Communication.TurbineChat,
PlayerGuid: () => _player.Identity.ServerGuid,
SendTalk: session.SendTalk,
SendTell: session.SendTell,

View file

@ -7,6 +7,7 @@ using AcDream.App.Settings;
using AcDream.App.World;
using AcDream.Content;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
using DatReaderWriter;
using Silk.NET.Input;
@ -323,17 +324,18 @@ public sealed class GameWindow :
private AcDream.App.World.LiveEntityRuntime? _liveEntities;
private AcDream.App.World.LiveEntityLivenessController? _liveEntityLiveness;
// Phase F.1-H.1 — client-side state classes fed by GameEventWiring.
// Exposed publicly so plugins + UI panels can bind directly.
public readonly AcDream.Core.Chat.ChatLog Chat = new();
// Phase I.6 — runtime state for retail's TurbineChat (0xF7DE) global
// chat rooms. Empty/disabled until the server fires
// SetTurbineChatChannels (0x0295) shortly after EnterWorld.
public readonly AcDream.Core.Chat.TurbineChatState TurbineChat = new();
// J4.1: Runtime owns communication/social state. App, UI, plugins, and
// live-session routing borrow these exact instances.
private readonly RuntimeCommunicationState _runtimeCommunication = new();
public AcDream.Core.Chat.ChatLog Chat => _runtimeCommunication.Chat;
public AcDream.Core.Chat.TurbineChatState TurbineChat =>
_runtimeCommunication.TurbineChat;
public AcDream.Core.Social.FriendsState Friends =>
_runtimeCommunication.Friends;
public AcDream.Core.Social.SquelchState Squelch =>
_runtimeCommunication.Squelch;
public readonly AcDream.Core.Combat.CombatState Combat = new();
public readonly AcDream.Core.Items.ItemManaState ItemMana = new();
public readonly AcDream.Core.Social.FriendsState Friends = new();
public readonly AcDream.Core.Social.SquelchState Squelch = new();
private readonly DesiredComponentSnapshotState _desiredComponents = new();
public IReadOnlyList<(uint Id, uint Amount)> DesiredComponents =>
_desiredComponents.Items;
@ -1208,7 +1210,7 @@ public sealed class GameWindow :
hostInputCamera.CameraController,
contentEffectsAudio.Audio?.Engine),
_hostQuiescence,
Chat,
_runtimeCommunication,
Combat,
LocalPlayer,
optionalDevTools,
@ -1264,7 +1266,7 @@ public sealed class GameWindow :
_runtimeEntityObjects.Objects,
contentEffectsAudio.MagicCatalog,
SpellBook,
Chat,
_runtimeCommunication,
LocalPlayer,
ItemMana,
_stackSplitQuantity,
@ -1409,13 +1411,10 @@ public sealed class GameWindow :
_combatAttackOperations,
Combat,
_combatFeedback,
Chat,
_runtimeCommunication,
LocalPlayer,
SpellBook,
ItemMana,
Friends,
Squelch,
TurbineChat,
_externalContainers,
_portalTunnelFallback,
Console.WriteLine),
@ -1591,6 +1590,7 @@ public sealed class GameWindow :
_equippedChildRenderer,
_liveEntities,
_runtimeEntityObjects,
_runtimeCommunication,
_renderSceneShadow,
_livePresentationBindings,
_entityEffectAdvance,

View file

@ -17,6 +17,7 @@ using AcDream.Content;
using AcDream.Core.Audio;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
using AcDream.UI.Abstractions.Input;
using DatReaderWriter;
@ -83,6 +84,7 @@ internal sealed record LiveShutdownRoots(
EquippedChildRenderController? EquippedChildren,
LiveEntityRuntime? LiveEntities,
RuntimeEntityObjectLifetime EntityObjects,
RuntimeCommunicationState Communication,
RenderSceneShadowRuntime? RenderSceneShadow,
LivePresentationRuntimeBindings? PresentationBindings,
DeferredEntityEffectAdvanceSource EffectAdvance,
@ -439,6 +441,10 @@ internal static class GameWindowShutdownManifest
Hard("sky", () => render.Sky?.Dispose()),
Hard("particles", () => render.Particles?.Dispose()),
]),
new ResourceShutdownStage("runtime communication state",
[
Hard("communication owner", live.Communication.Dispose),
]),
new ResourceShutdownStage("shared texture owners",
[
Hard("sampler cache", () => render.Samplers?.Dispose()),

View file

@ -8,6 +8,7 @@ using AcDream.Core.Chat;
using AcDream.Core.Selection;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
using AcDream.UI.Abstractions;
@ -36,7 +37,7 @@ internal sealed class CurrentGameRuntimeAdapter
LocalPlayerIdentityState playerIdentity,
LiveEntityRuntime entities,
RuntimeEntityObjectLifetime entityObjects,
ChatLog chat,
RuntimeCommunicationState communication,
ILocalPlayerControllerSource playerController,
WorldRevealCoordinator worldReveal,
IGameRuntimeClock clock,
@ -50,7 +51,7 @@ internal sealed class CurrentGameRuntimeAdapter
playerIdentity,
entities,
entityObjects,
chat,
communication,
playerController,
worldReveal,
clock);
@ -60,7 +61,7 @@ internal sealed class CurrentGameRuntimeAdapter
_events = new CurrentGameRuntimeEventAdapter(
_view,
entityObjects,
chat);
communication);
_commands = new CurrentGameRuntimeCommandAdapter(
session,
sessionHost,

View file

@ -1,6 +1,6 @@
using AcDream.Core.Chat;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Runtime;
@ -25,14 +25,16 @@ internal sealed class CurrentGameRuntimeEventAdapter
: IRuntimeEventSource,
ICurrentGameRuntimeEventSink,
IRuntimeEntityObjectObserver,
IRuntimeCommunicationObserver,
IDisposable
{
private readonly CurrentGameRuntimeViewAdapter _view;
private readonly RuntimeEntityObjectLifetime _entityObjects;
private readonly ChatLog _chat;
private readonly RuntimeCommunicationState _communication;
private readonly object _observerGate = new();
private IRuntimeEventObserver[] _observers = [];
private IDisposable? _entityObjectSubscription;
private IDisposable? _communicationSubscription;
private bool _ownerEventsAttached;
private bool _disposed;
@ -42,12 +44,13 @@ internal sealed class CurrentGameRuntimeEventAdapter
public CurrentGameRuntimeEventAdapter(
CurrentGameRuntimeViewAdapter view,
RuntimeEntityObjectLifetime entityObjects,
ChatLog chat)
RuntimeCommunicationState communication)
{
_view = view ?? throw new ArgumentNullException(nameof(view));
_entityObjects = entityObjects
?? throw new ArgumentNullException(nameof(entityObjects));
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
_communication = communication
?? throw new ArgumentNullException(nameof(communication));
}
public IDisposable Subscribe(IRuntimeEventObserver observer)
@ -175,7 +178,7 @@ internal sealed class CurrentGameRuntimeEventAdapter
private void AttachOwnerEvents()
{
_entityObjectSubscription = _entityObjects.Events.Subscribe(this);
_chat.EntryAppended += OnChatEntry;
_communicationSubscription = _communication.Events.Subscribe(this);
_ownerEventsAttached = true;
}
@ -183,7 +186,8 @@ internal sealed class CurrentGameRuntimeEventAdapter
{
if (!_ownerEventsAttached)
return;
_chat.EntryAppended -= OnChatEntry;
_communicationSubscription?.Dispose();
_communicationSubscription = null;
_entityObjectSubscription?.Dispose();
_entityObjectSubscription = null;
_ownerEventsAttached = false;
@ -223,19 +227,13 @@ internal sealed class CurrentGameRuntimeEventAdapter
}
}
private void OnChatEntry(ChatEntry entry)
public void OnChat(in RuntimeCommunicationEvent committed)
{
if (!TryObservers(out IRuntimeEventObserver[] observers))
return;
var delta = new RuntimeChatDelta(
NextStamp(),
new RuntimeChatEntry(
_chat.Revision,
entry.SenderGuid,
(int)entry.Kind,
entry.Sender,
entry.Text,
entry.ChannelName));
committed.Entry);
foreach (IRuntimeEventObserver observer in observers)
{
try

View file

@ -6,6 +6,7 @@ using AcDream.Core.Chat;
using AcDream.Core.Items;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
namespace AcDream.App.Runtime;
@ -20,11 +21,10 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
private readonly LocalPlayerIdentityState _playerIdentity;
private readonly LiveEntityRuntime _entities;
private readonly ClientObjectTable _objects;
private readonly ChatLog _chat;
private readonly IGameRuntimeClock _clock;
private readonly IRuntimeEntityView _entityView;
private readonly IRuntimeInventoryView _inventoryView;
private readonly ChatView _chatView;
private readonly IRuntimeChatView _chatView;
private readonly MovementView _movementView;
private readonly PortalView _portalView;
private bool _active = true;
@ -34,7 +34,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
LocalPlayerIdentityState playerIdentity,
LiveEntityRuntime entities,
RuntimeEntityObjectLifetime entityObjects,
ChatLog chat,
RuntimeCommunicationState communication,
ILocalPlayerControllerSource playerController,
WorldRevealCoordinator worldReveal,
IGameRuntimeClock clock)
@ -45,12 +45,12 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
ArgumentNullException.ThrowIfNull(entityObjects);
_objects = entityObjects.Objects;
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
ArgumentNullException.ThrowIfNull(communication);
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_entityView = entityObjects.EntityView;
_inventoryView = entityObjects.InventoryView;
_chatView = new ChatView(_chat);
_chatView = communication.View;
_movementView = new MovementView(
playerController
?? throw new ArgumentNullException(nameof(playerController)),
@ -103,19 +103,13 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
_entities.MaterializedCount,
_objects.ObjectCount,
_objects.ContainerCount,
_chat.Revision,
_chat.Count,
_chatView.Revision,
_chatView.Count,
_movementView.Snapshot,
_portalView.Snapshot);
internal void Deactivate() => _active = false;
private sealed class ChatView(ChatLog owner) : IRuntimeChatView
{
public long Revision => owner.Revision;
public int Count => owner.Count;
}
private sealed class MovementView(
ILocalPlayerControllerSource owner,
IGameRuntimeClock clock)

View file

@ -0,0 +1,84 @@
namespace AcDream.Core.Chat;
/// <summary>
/// Presentation-independent reply/retell targets derived from the committed
/// chat transcript.
/// </summary>
/// <remarks>
/// Incoming tells carry a non-zero sender GUID and update the reply target.
/// Local outgoing tell echoes carry a zero sender GUID and place the target
/// name in <see cref="ChatEntry.Sender"/>. This is the existing retail
/// <c>@reply</c>/<c>@retell</c> behavior formerly owned by <c>ChatVM</c>.
/// </remarks>
public sealed class ChatCommandTargetState : IDisposable
{
private readonly ChatLog _chat;
private readonly object _gate = new();
private string? _lastIncomingTellSender;
private string? _lastOutgoingTellTarget;
private bool _disposed;
public ChatCommandTargetState(ChatLog chat)
{
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
_chat.EntryAppended += OnEntryAppended;
}
public string? LastIncomingTellSender
{
get
{
lock (_gate)
return _lastIncomingTellSender;
}
}
public string? LastOutgoingTellTarget
{
get
{
lock (_gate)
return _lastOutgoingTellTarget;
}
}
/// <summary>
/// Forget character-scoped command targets without clearing visible
/// transcript history.
/// </summary>
public void ResetSession()
{
lock (_gate)
{
_lastIncomingTellSender = null;
_lastOutgoingTellTarget = null;
}
}
public void Dispose()
{
lock (_gate)
{
if (_disposed)
return;
_disposed = true;
_chat.EntryAppended -= OnEntryAppended;
}
}
private void OnEntryAppended(ChatEntry entry)
{
if (entry.Kind != ChatKind.Tell || string.IsNullOrEmpty(entry.Sender))
return;
lock (_gate)
{
if (_disposed)
return;
if (entry.SenderGuid != 0u)
_lastIncomingTellSender = entry.Sender;
else
_lastOutgoingTellTarget = entry.Sender;
}
}
}

View file

@ -0,0 +1,263 @@
using AcDream.Core.Chat;
using AcDream.Core.Social;
namespace AcDream.Runtime.Gameplay;
public readonly record struct RuntimeCommunicationEvent(
ulong Sequence,
RuntimeChatEntry Entry);
public interface IRuntimeCommunicationObserver
{
void OnChat(in RuntimeCommunicationEvent delta);
}
public interface IRuntimeCommunicationEventSource
{
IDisposable Subscribe(IRuntimeCommunicationObserver observer);
}
/// <summary>
/// Canonical presentation-independent owner for communication and social
/// state. Graphical, headless, plugin, and bot hosts borrow these exact
/// instances.
/// </summary>
public sealed class RuntimeCommunicationState : IDisposable
{
private readonly RuntimeCommunicationEventStream _events;
private bool _disposed;
public RuntimeCommunicationState(int maximumChatEntries = 500)
{
Chat = new ChatLog(maximumChatEntries);
CommandTargets = new ChatCommandTargetState(Chat);
_events = new RuntimeCommunicationEventStream(Chat);
TurbineChat = new TurbineChatState();
Friends = new FriendsState();
Squelch = new SquelchState();
View = new CommunicationView(Chat);
}
public ChatLog Chat { get; }
public ChatCommandTargetState CommandTargets { get; }
public TurbineChatState TurbineChat { get; }
public FriendsState Friends { get; }
public SquelchState Squelch { get; }
public IRuntimeChatView View { get; }
public IRuntimeCommunicationEventSource Events => _events;
public bool IsDisposed => _disposed;
public ulong LastSequence => _events.LastSequence;
public int SubscriberCount => _events.SubscriberCount;
public int PendingDispatchCount => _events.PendingDispatchCount;
public bool IsDispatching => _events.IsDispatching;
public long DispatchFailureCount => _events.DispatchFailureCount;
public Exception? LastDispatchFailure => _events.LastDispatchFailure;
public void ResetCommandTargets() => CommandTargets.ResetSession();
public void ResetChatIdentity() => Chat.ResetSessionIdentity();
public void ResetNegotiatedChannels() => TurbineChat.Reset();
public void ResetFriends() => Friends.Clear();
public void ResetSquelch() => Squelch.Clear();
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_events.Dispose();
CommandTargets.ResetSession();
CommandTargets.Dispose();
TurbineChat.Reset();
Friends.Clear();
Squelch.Clear();
Chat.ResetSessionIdentity();
}
private sealed class CommunicationView(ChatLog chat) : IRuntimeChatView
{
public long Revision => chat.Revision;
public int Count => chat.Count;
}
}
internal sealed class RuntimeCommunicationEventStream
: IRuntimeCommunicationEventSource,
IDisposable
{
private readonly ChatLog _chat;
private readonly object _gate = new();
private readonly List<RuntimeCommunicationEvent> _pendingDispatch = [];
private IRuntimeCommunicationObserver[] _observers = [];
private long _sequence;
private long _dispatchFailureCount;
private bool _dispatching;
private bool _disposed;
public RuntimeCommunicationEventStream(ChatLog chat)
{
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
_chat.EntryAppended += OnEntryAppended;
}
public int SubscriberCount => Volatile.Read(ref _observers).Length;
public ulong LastSequence
{
get
{
lock (_gate)
return unchecked((ulong)_sequence);
}
}
public int PendingDispatchCount
{
get
{
lock (_gate)
return _pendingDispatch.Count;
}
}
public bool IsDispatching
{
get
{
lock (_gate)
return _dispatching;
}
}
public long DispatchFailureCount => Interlocked.Read(ref _dispatchFailureCount);
public Exception? LastDispatchFailure { get; private set; }
public IDisposable Subscribe(IRuntimeCommunicationObserver observer)
{
ArgumentNullException.ThrowIfNull(observer);
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
IRuntimeCommunicationObserver[] current = _observers;
if (Array.IndexOf(current, observer) >= 0)
throw new InvalidOperationException(
"The communication observer is already subscribed.");
var replacement =
new IRuntimeCommunicationObserver[current.Length + 1];
Array.Copy(current, replacement, current.Length);
replacement[^1] = observer;
Volatile.Write(ref _observers, replacement);
}
return new Subscription(this, observer);
}
public void Dispose()
{
lock (_gate)
{
if (_disposed)
return;
_disposed = true;
_chat.EntryAppended -= OnEntryAppended;
_pendingDispatch.Clear();
_dispatching = false;
Volatile.Write(ref _observers, []);
}
}
private void OnEntryAppended(ChatEntry entry)
{
lock (_gate)
{
if (_disposed)
return;
ulong sequence = unchecked((ulong)++_sequence);
_pendingDispatch.Add(new RuntimeCommunicationEvent(
sequence,
new RuntimeChatEntry(
_chat.Revision,
entry.SenderGuid,
(int)entry.Kind,
entry.Sender,
entry.Text,
entry.ChannelName)));
if (_dispatching)
return;
_dispatching = true;
}
int index = 0;
while (true)
{
RuntimeCommunicationEvent pending;
lock (_gate)
{
if (index >= _pendingDispatch.Count)
{
_pendingDispatch.Clear();
_dispatching = false;
return;
}
pending = _pendingDispatch[index++];
}
Dispatch(in pending);
}
}
private void Dispatch(in RuntimeCommunicationEvent delta)
{
IRuntimeCommunicationObserver[] observers =
Volatile.Read(ref _observers);
foreach (IRuntimeCommunicationObserver observer in observers)
{
try
{
observer.OnChat(in delta);
}
catch (Exception error)
{
Interlocked.Increment(ref _dispatchFailureCount);
LastDispatchFailure = error;
}
}
}
private void Unsubscribe(IRuntimeCommunicationObserver observer)
{
lock (_gate)
{
IRuntimeCommunicationObserver[] current = _observers;
int index = Array.IndexOf(current, observer);
if (index < 0)
return;
if (current.Length == 1)
{
Volatile.Write(ref _observers, []);
return;
}
var replacement =
new IRuntimeCommunicationObserver[current.Length - 1];
if (index > 0)
Array.Copy(current, 0, replacement, 0, index);
if (index < current.Length - 1)
{
Array.Copy(
current,
index + 1,
replacement,
index,
current.Length - index - 1);
}
Volatile.Write(ref _observers, replacement);
}
}
private sealed class Subscription(
RuntimeCommunicationEventStream owner,
IRuntimeCommunicationObserver observer)
: IDisposable
{
private RuntimeCommunicationEventStream? _owner = owner;
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unsubscribe(observer);
}
}

View file

@ -27,6 +27,8 @@ public sealed class ChatVM : IDisposable
public const int DefaultDisplayLimit = 20;
private readonly ChatLog _log;
private readonly ChatCommandTargetState _commandTargets;
private readonly bool _ownsCommandTargets;
private readonly int _displayLimit;
private bool _disposed;
@ -41,7 +43,8 @@ public sealed class ChatVM : IDisposable
/// <c>chat.rs::ChatState::last_incoming_tell_sender</c> (line 74 +
/// the assignment at line 152).
/// </summary>
public string? LastIncomingTellSender { get; private set; }
public string? LastIncomingTellSender =>
_commandTargets.LastIncomingTellSender;
/// <summary>
/// Target of the most recent OUTGOING Tell (the player's own
@ -52,7 +55,8 @@ public sealed class ChatVM : IDisposable
/// <c>SenderGuid == 0</c> and the target name in <c>Sender</c> —
/// that's the discriminator we capture here.
/// </summary>
public string? LastOutgoingTellTarget { get; private set; }
public string? LastOutgoingTellTarget =>
_commandTargets.LastOutgoingTellTarget;
/// <summary>
/// Optional callback exposing the live framerate. Wired by
@ -81,35 +85,25 @@ public sealed class ChatVM : IDisposable
/// <see cref="RecentLines"/> call. Must be &gt;= 1. Defaults to
/// <see cref="DefaultDisplayLimit"/>.
/// </param>
public ChatVM(ChatLog log, int displayLimit = DefaultDisplayLimit)
public ChatVM(
ChatLog log,
int displayLimit = DefaultDisplayLimit,
ChatCommandTargetState? commandTargets = null)
{
_log = log ?? throw new ArgumentNullException(nameof(log));
if (displayLimit < 1)
throw new ArgumentOutOfRangeException(nameof(displayLimit), displayLimit, "must be >= 1");
_displayLimit = displayLimit;
_log.EntryAppended += OnEntryAppended;
}
private void OnEntryAppended(ChatEntry entry)
{
// Tell-tracking discriminator (SenderGuid):
// != 0 = real incoming whisper; capture sender for /reply.
// == 0 = our own outgoing echo via OnSelfSent; capture the
// target (Sender field) for /retell.
if (entry.Kind != ChatKind.Tell) return;
if (string.IsNullOrEmpty(entry.Sender)) return;
if (entry.SenderGuid != 0)
LastIncomingTellSender = entry.Sender;
else
LastOutgoingTellTarget = entry.Sender;
_commandTargets = commandTargets ?? new ChatCommandTargetState(_log);
_ownsCommandTargets = commandTargets is null;
}
public void Dispose()
{
if (_disposed)
return;
_log.EntryAppended -= OnEntryAppended;
if (_ownsCommandTargets)
_commandTargets.Dispose();
_disposed = true;
}
@ -132,8 +126,7 @@ public sealed class ChatVM : IDisposable
/// </summary>
public void ResetSessionTargets()
{
LastIncomingTellSender = null;
LastOutgoingTellTarget = null;
_commandTargets.ResetSession();
}
/// <summary>

View file

@ -193,7 +193,7 @@ public sealed class InteractionRetainedUiCompositionTests
Objects: null!,
MagicCatalog: null!,
Spellbook: null!,
Chat: null!,
Communication: null!,
LocalPlayer: null!,
ItemMana: null!,
StackSplitQuantity: null!,

View file

@ -9,6 +9,7 @@ using AcDream.App.Rendering;
using AcDream.App.Settings;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
using AcDream.Runtime.Gameplay;
using AcDream.Core.Player;
using AcDream.Core.Spells;
using AcDream.UI.Abstractions.Input;
@ -228,7 +229,7 @@ public sealed class SettingsDevToolsCompositionTests
Settings,
Startup,
new HostQuiescenceGate(),
new ChatLog(),
new RuntimeCommunicationState(),
new CombatState(),
new LocalPlayerState(new Spellbook()),
enabled

View file

@ -474,6 +474,7 @@ public sealed class GameWindowSlice8BoundaryTests
"new ResourceShutdownStage(\"live entity dependents\"",
"new ResourceShutdownStage(\"submitted GPU work\"",
"new ResourceShutdownStage(\"render frontends\"",
"new ResourceShutdownStage(\"runtime communication state\"",
"new ResourceShutdownStage(\"shared texture owners\"",
"new ResourceShutdownStage(\"mesh adapter\"",
"new ResourceShutdownStage(\"remaining render owners\"",

View file

@ -18,6 +18,7 @@ using AcDream.Core.Selection;
using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Input;
@ -411,7 +412,7 @@ public sealed class CurrentGameRuntimeAdapterTests
new NoopEntityResources(),
EntityObjects);
Objects = EntityObjects.Objects;
Chat = new ChatLog();
Communication = new RuntimeCommunicationState();
Selection = new SelectionState();
MovementInput = new DispatcherMovementInputSource();
GameplayInput = new GameplayInputFrameController(
@ -456,7 +457,7 @@ public sealed class CurrentGameRuntimeAdapterTests
Identity,
Entities,
EntityObjects,
Chat,
Communication,
new LocalPlayerControllerSlot(),
WorldReveal,
Clock,
@ -471,7 +472,8 @@ public sealed class CurrentGameRuntimeAdapterTests
public RuntimeEntityObjectLifetime EntityObjects { get; }
public LiveEntityRuntime Entities { get; }
public ClientObjectTable Objects { get; }
public ChatLog Chat { get; }
public RuntimeCommunicationState Communication { get; }
public ChatLog Chat => Communication.Chat;
public SelectionState Selection { get; }
public DispatcherMovementInputSource MovementInput { get; }
public GameplayInputFrameController GameplayInput { get; }
@ -485,6 +487,7 @@ public sealed class CurrentGameRuntimeAdapterTests
public void Dispose()
{
Runtime.Dispose();
Communication.Dispose();
_session.Dispose();
_items.Dispose();
Entities.Clear();

View file

@ -0,0 +1,48 @@
using AcDream.Core.Chat;
namespace AcDream.Core.Tests.Chat;
public sealed class ChatCommandTargetStateTests
{
[Fact]
public void TracksReplyAndRetellTargetsFromCommittedTranscript()
{
var chat = new ChatLog();
using var targets = new ChatCommandTargetState(chat);
chat.OnTellReceived("Bestie", "incoming", 0x50000001u);
chat.OnSelfSent(ChatKind.Tell, "outgoing", "Caith");
Assert.Equal("Bestie", targets.LastIncomingTellSender);
Assert.Equal("Caith", targets.LastOutgoingTellTarget);
}
[Fact]
public void ResetSessionForgetsTargetsButPreservesTranscript()
{
var chat = new ChatLog();
using var targets = new ChatCommandTargetState(chat);
chat.OnTellReceived("Bestie", "incoming", 0x50000001u);
chat.OnSelfSent(ChatKind.Tell, "outgoing", "Caith");
targets.ResetSession();
Assert.Null(targets.LastIncomingTellSender);
Assert.Null(targets.LastOutgoingTellTarget);
Assert.Equal(2, chat.Count);
}
[Fact]
public void DisposeDetachesAndIsIdempotent()
{
var chat = new ChatLog();
var targets = new ChatCommandTargetState(chat);
targets.Dispose();
targets.Dispose();
chat.OnTellReceived("After", "ignored", 0x50000002u);
Assert.Null(targets.LastIncomingTellSender);
Assert.Null(targets.LastOutgoingTellTarget);
}
}

View file

@ -0,0 +1,158 @@
using AcDream.Core.Chat;
using AcDream.Core.Social;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
public sealed class RuntimeCommunicationStateTests
{
[Fact]
public void OwnsOneExactCommunicationGraphAndPublishesCommittedEntries()
{
using var state = new RuntimeCommunicationState();
var observer = new RecordingObserver();
using IDisposable subscription = state.Events.Subscribe(observer);
state.Chat.OnTellReceived("Bestie", "hello", 0x50000001u);
RuntimeCommunicationEvent delta = Assert.Single(observer.Events);
Assert.Equal(1UL, delta.Sequence);
Assert.Equal(state.Chat.Revision, delta.Entry.Revision);
Assert.Equal(1, state.View.Count);
Assert.Equal(state.Chat.Revision, state.View.Revision);
Assert.Equal(1UL, state.LastSequence);
Assert.Equal(0, state.PendingDispatchCount);
Assert.False(state.IsDispatching);
Assert.Equal("Bestie", state.CommandTargets.LastIncomingTellSender);
Assert.Equal("hello", delta.Entry.Text);
}
[Fact]
public void ReentrantAppendPreservesSequenceForEveryObserver()
{
using var state = new RuntimeCommunicationState();
var reentrant = new ReentrantObserver(state.Chat);
var trailing = new RecordingObserver();
using IDisposable first = state.Events.Subscribe(reentrant);
using IDisposable second = state.Events.Subscribe(trailing);
state.Chat.OnSystemMessage("first", 0u);
Assert.Equal([1UL, 2UL], reentrant.Sequences);
Assert.Equal(
[1UL, 2UL],
trailing.Events.Select(item => item.Sequence).ToArray());
Assert.Equal(["first", "second"], trailing.Events
.Select(item => item.Entry.Text)
.ToArray());
Assert.Equal(0, state.PendingDispatchCount);
Assert.False(state.IsDispatching);
}
[Fact]
public void ObserverFailureDoesNotStarveLaterObserverOrOwner()
{
using var state = new RuntimeCommunicationState();
var recording = new RecordingObserver();
using IDisposable throwing =
state.Events.Subscribe(new ThrowingObserver());
using IDisposable trailing = state.Events.Subscribe(recording);
state.Chat.OnSystemMessage("still committed", 0u);
Assert.Equal(1, state.Chat.Count);
Assert.Single(recording.Events);
Assert.Equal(1, state.DispatchFailureCount);
Assert.IsType<InvalidOperationException>(state.LastDispatchFailure);
}
[Fact]
public void SessionResetsClearScopedStateWithoutClearingTranscript()
{
using var state = new RuntimeCommunicationState();
state.Chat.OnTellReceived("Bestie", "hello", 0x50000001u);
state.Chat.OnSelfSent(ChatKind.Tell, "outgoing", "Caith");
state.TurbineChat.OnChannelsReceived(
1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u);
state.Friends.Apply(new FriendsUpdate(
FriendsUpdateType.Full,
[new FriendEntry(1u, "Friend", true, false, [], [])]));
state.Squelch.Replace(new SquelchDatabase(
new Dictionary<string, uint> { ["account"] = 1u },
new Dictionary<uint, SquelchInfo>(),
new SquelchInfo(string.Empty, false, new HashSet<uint>())));
state.ResetCommandTargets();
state.ResetChatIdentity();
state.ResetNegotiatedChannels();
state.ResetFriends();
state.ResetSquelch();
Assert.Equal(2, state.Chat.Count);
Assert.Null(state.CommandTargets.LastIncomingTellSender);
Assert.Null(state.CommandTargets.LastOutgoingTellTarget);
Assert.False(state.TurbineChat.Enabled);
Assert.Empty(state.Friends.Snapshot());
Assert.Empty(state.Squelch.Snapshot().Accounts);
}
[Fact]
public void DisposeDetachesStreamAndAllBorrowedState()
{
var state = new RuntimeCommunicationState();
var observer = new RecordingObserver();
IDisposable subscription = state.Events.Subscribe(observer);
state.Dispose();
state.Dispose();
state.Chat.OnSystemMessage("after dispose", 0u);
Assert.True(state.IsDisposed);
Assert.Equal(0, state.SubscriberCount);
Assert.Empty(observer.Events);
Assert.Throws<ObjectDisposedException>(
() => state.Events.Subscribe(new RecordingObserver()));
subscription.Dispose();
}
[Fact]
public void IndependentRuntimeInstancesNeverShareState()
{
using var first = new RuntimeCommunicationState();
using var second = new RuntimeCommunicationState();
first.Chat.OnTellReceived("OnlyFirst", "hello", 0x50000001u);
Assert.Equal(1, first.View.Count);
Assert.Equal(0, second.View.Count);
Assert.Equal("OnlyFirst", first.CommandTargets.LastIncomingTellSender);
Assert.Null(second.CommandTargets.LastIncomingTellSender);
}
private sealed class RecordingObserver : IRuntimeCommunicationObserver
{
public List<RuntimeCommunicationEvent> Events { get; } = [];
public void OnChat(in RuntimeCommunicationEvent delta) =>
Events.Add(delta);
}
private sealed class ReentrantObserver(ChatLog chat)
: IRuntimeCommunicationObserver
{
public List<ulong> Sequences { get; } = [];
public void OnChat(in RuntimeCommunicationEvent delta)
{
Sequences.Add(delta.Sequence);
if (delta.Sequence == 1UL)
chat.OnSystemMessage("second", 1u);
}
}
private sealed class ThrowingObserver : IRuntimeCommunicationObserver
{
public void OnChat(in RuntimeCommunicationEvent delta) =>
throw new InvalidOperationException("observer");
}
}

View file

@ -13,6 +13,20 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
/// </summary>
public sealed class ChatVMRetellAndProvidersTests
{
[Fact]
public void BorrowedCommandTargetsRemainLiveAfterOneViewModelDisposes()
{
var log = new ChatLog();
using var targets = new ChatCommandTargetState(log);
var first = new ChatVM(log, commandTargets: targets);
using var second = new ChatVM(log, commandTargets: targets);
first.Dispose();
log.OnTellReceived("Bestie", "incoming", 0x50000001u);
Assert.Equal("Bestie", second.LastIncomingTellSender);
}
[Fact]
public void ResetSessionTargets_ClearsReplyAndRetellWithoutClearingTranscript()
{