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>