diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index d4637635..9c844e61 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -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, diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 3af08129..4cf814b0 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -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 PortalTunnelFallback, Action 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, diff --git a/src/AcDream.App/Composition/SettingsDevToolsComposition.cs b/src/AcDream.App/Composition/SettingsDevToolsComposition.cs index 7ac20626..13a5ff97 100644 --- a/src/AcDream.App/Composition/SettingsDevToolsComposition.cs +++ b/src/AcDream.App/Composition/SettingsDevToolsComposition.cs @@ -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, diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 86ab9055..fca393f9 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -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, diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 040572e6..4619052b 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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, diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs index 03663c49..8da2fa68 100644 --- a/src/AcDream.App/Rendering/GameWindowLifetime.cs +++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs @@ -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()), diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs index 4fa208d2..e68d1369 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs @@ -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, diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeEventAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeEventAdapter.cs index de3070bb..b91d04c4 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeEventAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeEventAdapter.cs @@ -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 diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs index 19719fec..3db6aedd 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs @@ -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) diff --git a/src/AcDream.Core/Chat/ChatCommandTargetState.cs b/src/AcDream.Core/Chat/ChatCommandTargetState.cs new file mode 100644 index 00000000..df10d34f --- /dev/null +++ b/src/AcDream.Core/Chat/ChatCommandTargetState.cs @@ -0,0 +1,84 @@ +namespace AcDream.Core.Chat; + +/// +/// Presentation-independent reply/retell targets derived from the committed +/// chat transcript. +/// +/// +/// 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 . This is the existing retail +/// @reply/@retell behavior formerly owned by ChatVM. +/// +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; + } + } + + /// + /// Forget character-scoped command targets without clearing visible + /// transcript history. + /// + 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; + } + } +} diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs new file mode 100644 index 00000000..025a0c88 --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs @@ -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); +} + +/// +/// Canonical presentation-independent owner for communication and social +/// state. Graphical, headless, plugin, and bot hosts borrow these exact +/// instances. +/// +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 _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); + } +} diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index 5894725e..a6864c61 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -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 /// chat.rs::ChatState::last_incoming_tell_sender (line 74 + /// the assignment at line 152). /// - public string? LastIncomingTellSender { get; private set; } + public string? LastIncomingTellSender => + _commandTargets.LastIncomingTellSender; /// /// Target of the most recent OUTGOING Tell (the player's own @@ -52,7 +55,8 @@ public sealed class ChatVM : IDisposable /// SenderGuid == 0 and the target name in Sender — /// that's the discriminator we capture here. /// - public string? LastOutgoingTellTarget { get; private set; } + public string? LastOutgoingTellTarget => + _commandTargets.LastOutgoingTellTarget; /// /// Optional callback exposing the live framerate. Wired by @@ -81,35 +85,25 @@ public sealed class ChatVM : IDisposable /// call. Must be >= 1. Defaults to /// . /// - 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 /// public void ResetSessionTargets() { - LastIncomingTellSender = null; - LastOutgoingTellTarget = null; + _commandTargets.ResetSession(); } /// diff --git a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs index c411d6c7..17baf253 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs @@ -193,7 +193,7 @@ public sealed class InteractionRetainedUiCompositionTests Objects: null!, MagicCatalog: null!, Spellbook: null!, - Chat: null!, + Communication: null!, LocalPlayer: null!, ItemMana: null!, StackSplitQuantity: null!, diff --git a/tests/AcDream.App.Tests/Composition/SettingsDevToolsCompositionTests.cs b/tests/AcDream.App.Tests/Composition/SettingsDevToolsCompositionTests.cs index e0ddc3dc..f9b3436a 100644 --- a/tests/AcDream.App.Tests/Composition/SettingsDevToolsCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/SettingsDevToolsCompositionTests.cs @@ -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 diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index 50c90d35..83b1e219 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -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\"", diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index a2076439..602c1f32 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -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(); diff --git a/tests/AcDream.Core.Tests/Chat/ChatCommandTargetStateTests.cs b/tests/AcDream.Core.Tests/Chat/ChatCommandTargetStateTests.cs new file mode 100644 index 00000000..ae3e9b8d --- /dev/null +++ b/tests/AcDream.Core.Tests/Chat/ChatCommandTargetStateTests.cs @@ -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); + } +} diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs new file mode 100644 index 00000000..80689f23 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs @@ -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(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 { ["account"] = 1u }, + new Dictionary(), + new SquelchInfo(string.Empty, false, new HashSet()))); + + 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( + () => 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 Events { get; } = []; + + public void OnChat(in RuntimeCommunicationEvent delta) => + Events.Add(delta); + } + + private sealed class ReentrantObserver(ChatLog chat) + : IRuntimeCommunicationObserver + { + public List 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"); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs index 81a9510f..3ed38d82 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs @@ -13,6 +13,20 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Chat; /// 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() {