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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,6 +6,7 @@ using AcDream.Core.Chat;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Entities; using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
namespace AcDream.App.Runtime; namespace AcDream.App.Runtime;
@ -20,11 +21,10 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
private readonly LocalPlayerIdentityState _playerIdentity; private readonly LocalPlayerIdentityState _playerIdentity;
private readonly LiveEntityRuntime _entities; private readonly LiveEntityRuntime _entities;
private readonly ClientObjectTable _objects; private readonly ClientObjectTable _objects;
private readonly ChatLog _chat;
private readonly IGameRuntimeClock _clock; private readonly IGameRuntimeClock _clock;
private readonly IRuntimeEntityView _entityView; private readonly IRuntimeEntityView _entityView;
private readonly IRuntimeInventoryView _inventoryView; private readonly IRuntimeInventoryView _inventoryView;
private readonly ChatView _chatView; private readonly IRuntimeChatView _chatView;
private readonly MovementView _movementView; private readonly MovementView _movementView;
private readonly PortalView _portalView; private readonly PortalView _portalView;
private bool _active = true; private bool _active = true;
@ -34,7 +34,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
LocalPlayerIdentityState playerIdentity, LocalPlayerIdentityState playerIdentity,
LiveEntityRuntime entities, LiveEntityRuntime entities,
RuntimeEntityObjectLifetime entityObjects, RuntimeEntityObjectLifetime entityObjects,
ChatLog chat, RuntimeCommunicationState communication,
ILocalPlayerControllerSource playerController, ILocalPlayerControllerSource playerController,
WorldRevealCoordinator worldReveal, WorldRevealCoordinator worldReveal,
IGameRuntimeClock clock) IGameRuntimeClock clock)
@ -45,12 +45,12 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
_entities = entities ?? throw new ArgumentNullException(nameof(entities)); _entities = entities ?? throw new ArgumentNullException(nameof(entities));
ArgumentNullException.ThrowIfNull(entityObjects); ArgumentNullException.ThrowIfNull(entityObjects);
_objects = entityObjects.Objects; _objects = entityObjects.Objects;
_chat = chat ?? throw new ArgumentNullException(nameof(chat)); ArgumentNullException.ThrowIfNull(communication);
_clock = clock ?? throw new ArgumentNullException(nameof(clock)); _clock = clock ?? throw new ArgumentNullException(nameof(clock));
_entityView = entityObjects.EntityView; _entityView = entityObjects.EntityView;
_inventoryView = entityObjects.InventoryView; _inventoryView = entityObjects.InventoryView;
_chatView = new ChatView(_chat); _chatView = communication.View;
_movementView = new MovementView( _movementView = new MovementView(
playerController playerController
?? throw new ArgumentNullException(nameof(playerController)), ?? throw new ArgumentNullException(nameof(playerController)),
@ -103,19 +103,13 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
_entities.MaterializedCount, _entities.MaterializedCount,
_objects.ObjectCount, _objects.ObjectCount,
_objects.ContainerCount, _objects.ContainerCount,
_chat.Revision, _chatView.Revision,
_chat.Count, _chatView.Count,
_movementView.Snapshot, _movementView.Snapshot,
_portalView.Snapshot); _portalView.Snapshot);
internal void Deactivate() => _active = false; 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( private sealed class MovementView(
ILocalPlayerControllerSource owner, ILocalPlayerControllerSource owner,
IGameRuntimeClock clock) 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; public const int DefaultDisplayLimit = 20;
private readonly ChatLog _log; private readonly ChatLog _log;
private readonly ChatCommandTargetState _commandTargets;
private readonly bool _ownsCommandTargets;
private readonly int _displayLimit; private readonly int _displayLimit;
private bool _disposed; private bool _disposed;
@ -41,7 +43,8 @@ public sealed class ChatVM : IDisposable
/// <c>chat.rs::ChatState::last_incoming_tell_sender</c> (line 74 + /// <c>chat.rs::ChatState::last_incoming_tell_sender</c> (line 74 +
/// the assignment at line 152). /// the assignment at line 152).
/// </summary> /// </summary>
public string? LastIncomingTellSender { get; private set; } public string? LastIncomingTellSender =>
_commandTargets.LastIncomingTellSender;
/// <summary> /// <summary>
/// Target of the most recent OUTGOING Tell (the player's own /// 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> — /// <c>SenderGuid == 0</c> and the target name in <c>Sender</c> —
/// that's the discriminator we capture here. /// that's the discriminator we capture here.
/// </summary> /// </summary>
public string? LastOutgoingTellTarget { get; private set; } public string? LastOutgoingTellTarget =>
_commandTargets.LastOutgoingTellTarget;
/// <summary> /// <summary>
/// Optional callback exposing the live framerate. Wired by /// 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="RecentLines"/> call. Must be &gt;= 1. Defaults to
/// <see cref="DefaultDisplayLimit"/>. /// <see cref="DefaultDisplayLimit"/>.
/// </param> /// </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)); _log = log ?? throw new ArgumentNullException(nameof(log));
if (displayLimit < 1) if (displayLimit < 1)
throw new ArgumentOutOfRangeException(nameof(displayLimit), displayLimit, "must be >= 1"); throw new ArgumentOutOfRangeException(nameof(displayLimit), displayLimit, "must be >= 1");
_displayLimit = displayLimit; _displayLimit = displayLimit;
_log.EntryAppended += OnEntryAppended; _commandTargets = commandTargets ?? new ChatCommandTargetState(_log);
} _ownsCommandTargets = commandTargets is null;
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;
} }
public void Dispose() public void Dispose()
{ {
if (_disposed) if (_disposed)
return; return;
_log.EntryAppended -= OnEntryAppended; if (_ownsCommandTargets)
_commandTargets.Dispose();
_disposed = true; _disposed = true;
} }
@ -132,8 +126,7 @@ public sealed class ChatVM : IDisposable
/// </summary> /// </summary>
public void ResetSessionTargets() public void ResetSessionTargets()
{ {
LastIncomingTellSender = null; _commandTargets.ResetSession();
LastOutgoingTellTarget = null;
} }
/// <summary> /// <summary>

View file

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

View file

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

View file

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

View file

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