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:
parent
e321410770
commit
c9d25ade50
19 changed files with 684 additions and 117 deletions
|
|
@ -193,7 +193,7 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
Objects: null!,
|
||||
MagicCatalog: null!,
|
||||
Spellbook: null!,
|
||||
Chat: null!,
|
||||
Communication: null!,
|
||||
LocalPlayer: null!,
|
||||
ItemMana: null!,
|
||||
StackSplitQuantity: null!,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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\"",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
48
tests/AcDream.Core.Tests/Chat/ChatCommandTargetStateTests.cs
Normal file
48
tests/AcDream.Core.Tests/Chat/ChatCommandTargetStateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue