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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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