R1: the timestamp prefix moves from ChatLog.Append (which stamped the
stored BODY, rendering 'Alice says, "13:05:09 hi"') to ChatVM's display
composition — FormatTimestampPrefix(entry.Received) prepends the COMPOSED
line, matching retail's separate-leading-string model (fprintf("%ls%ls",
ts, text) @0x00563e5b; AddTextToScroll receives composed lines). The
prefix renders entry.Received in LOCAL time (retail strftime), invariant
literal colons. The ten defect-pinning test cases across
ChatLogTests/RuntimeCommunicationStateTests are rewritten to pin the
corrected contract (stored bodies stay clean; the composed line carries
the stamp outside the quotes — ChatVMTests).
R2: open option-bearing panels converge on every PlayerDescription seed:
OptionPage.ReloadFromLive (per-row live re-read + gating re-eval, NO
AfterApply flush — the seed just cleared the dirty module),
OptionsPanelController.OnServerOptionsSeeded (active page),
CombatUiController.OnServerOptionsSeeded (SyncControls), wired through
RuntimeSettingsController.ServerOptionsSeeded from the same factory hook
LockUI already uses. Retail cannot reach this state (its panels close
across login); the adaptation exists because retained panels survive the
session boundary — documented at the seam.
R3: tests drive the refresh widget push (model AND checkbox converge) and
ReloadFromLive's no-flush contract. R4: AP-196 addendum names the
headless AutoRepeatAttack false->true effective-default flip and the
characterOptions escape hatch.
Full Release suite: 13,083 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
384 lines
14 KiB
C#
384 lines
14 KiB
C#
using System.Globalization;
|
|
using System.Threading;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.Core.Social;
|
|
using AcDream.Runtime.Gameplay;
|
|
|
|
namespace AcDream.Runtime.Tests.Gameplay;
|
|
|
|
public sealed class RuntimeCommunicationStateTests
|
|
{
|
|
[Fact]
|
|
public void SocialViewBorrowsFriendsAndSquelchOwners()
|
|
{
|
|
using var state = new RuntimeCommunicationState();
|
|
state.Friends.Apply(new AcDream.Core.Social.FriendsUpdate(
|
|
AcDream.Core.Social.FriendsUpdateType.Full,
|
|
[
|
|
new AcDream.Core.Social.FriendEntry(
|
|
0x50000001u,
|
|
"Friend",
|
|
Online: true,
|
|
AppearOffline: false,
|
|
Friends: [],
|
|
FriendOf: []),
|
|
]));
|
|
state.Squelch.Replace(new AcDream.Core.Social.SquelchDatabase(
|
|
new Dictionary<string, uint>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["account"] = 1u,
|
|
},
|
|
new Dictionary<uint, AcDream.Core.Social.SquelchInfo>(),
|
|
new AcDream.Core.Social.SquelchInfo(
|
|
string.Empty,
|
|
false,
|
|
new HashSet<uint> { 3u })));
|
|
|
|
RuntimeSocialSnapshot snapshot = state.SocialView.Snapshot;
|
|
|
|
Assert.Equal(1, snapshot.FriendCount);
|
|
Assert.Equal(1, snapshot.SquelchedAccountCount);
|
|
Assert.Equal(1, snapshot.GlobalSquelchTypeCount);
|
|
Assert.True(state.SocialView.TryGetFriend(
|
|
0x50000001u,
|
|
out RuntimeFriendSnapshot friend));
|
|
Assert.Equal("Friend", friend.Name);
|
|
Assert.True(friend.Online);
|
|
}
|
|
|
|
[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, logTextType: 0x03u);
|
|
|
|
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, logTextType: 0x03u);
|
|
state.Chat.OnSelfSent(ChatKind.Tell, "outgoing", logTextType: 0x04u, targetOrChannel: "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, logTextType: 0x03u);
|
|
|
|
Assert.Equal(1, first.View.Count);
|
|
Assert.Equal(0, second.View.Count);
|
|
Assert.Equal("OnlyFirst", first.CommandTargets.LastIncomingTellSender);
|
|
Assert.Null(second.CommandTargets.LastIncomingTellSender);
|
|
}
|
|
|
|
// ── Campaign CH slice CH2: AddText routing chokepoint ────────────────
|
|
|
|
[Fact]
|
|
public void AddText_ClientLocal_RoutesToSpewBoxOnly_NeverChatTranscript()
|
|
{
|
|
// Retail: type == 0x1A (ClientLocal) is exactly the bit every
|
|
// ChatInterface window's default filter excludes
|
|
// (ChatInterface::ChatInterface @0x004F4550,
|
|
// m_llTextTypeFilter &= 0xFBFFFFFF). The CH1-era ChatLog.OnWeenieError
|
|
// path put EVERY WeenieError line in chat, including 0x1A ones —
|
|
// this is the fix: a ClientLocal line must reach SpewBox and never
|
|
// touch the chat transcript at all.
|
|
using var state = new RuntimeCommunicationState();
|
|
|
|
state.AddText("You can't jump while in the air", RetailLogTextType.ClientLocal);
|
|
|
|
Assert.Equal(0, state.Chat.Count);
|
|
state.SpewBox.Tick(0d);
|
|
Assert.Equal(1, state.SpewBox.Count);
|
|
Assert.Equal("You can't jump while in the air", state.SpewBox.Snapshot()[0].Text);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(RetailLogTextType.Default)]
|
|
[InlineData(RetailLogTextType.Magic)]
|
|
[InlineData(RetailLogTextType.System)]
|
|
public void AddText_NonClientLocalTypes_RouteToChatOnly_NeverSpewBox(RetailLogTextType type)
|
|
{
|
|
using var state = new RuntimeCommunicationState();
|
|
|
|
state.AddText("Your spell fizzled.", type);
|
|
|
|
Assert.Equal(1, state.Chat.Count);
|
|
Assert.Equal("Your spell fizzled.", state.Chat.Snapshot()[0].Text);
|
|
Assert.Equal((uint)type, state.Chat.Snapshot()[0].LogTextType);
|
|
state.SpewBox.Tick(0d);
|
|
Assert.Equal(0, state.SpewBox.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddText_TrimsBothEnds_LikeRetailAddTextToScroll()
|
|
{
|
|
// CH2 REJECT-review rework (SHOULD-FIX 2,
|
|
// docs/research/2026-08-09-ch2-review-findings.md): retail's
|
|
// AddTextToScroll @0x00563C50 calls trim(&str, 1, 1, ws) — BOTH
|
|
// ends, not trailing-only.
|
|
using var state = new RuntimeCommunicationState();
|
|
|
|
state.AddText(" Out of Range! ", RetailLogTextType.ClientLocal);
|
|
|
|
state.SpewBox.Tick(0d);
|
|
Assert.Equal("Out of Range!", state.SpewBox.Snapshot()[0].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddText_EmptyAfterTrim_StillBroadcasts_LikeRetail()
|
|
{
|
|
// CH2 REJECT-review rework (SHOULD-FIX 2): retail's
|
|
// AddTextToScroll has no empty-string guard — the previous
|
|
// early-return was an unregistered acdream-only divergence, now
|
|
// retired. An all-whitespace message still reaches its destination
|
|
// as an empty string.
|
|
using var state = new RuntimeCommunicationState();
|
|
|
|
state.AddText(" ", RetailLogTextType.ClientLocal);
|
|
state.AddText(" ", RetailLogTextType.Default);
|
|
|
|
state.SpewBox.Tick(0d);
|
|
Assert.Equal(1, state.SpewBox.Count);
|
|
Assert.Equal("", state.SpewBox.Snapshot()[0].Text);
|
|
Assert.Equal(1, state.Chat.Count);
|
|
Assert.Equal("", state.Chat.Snapshot()[0].Text);
|
|
}
|
|
|
|
// ── OP4 (Campaign OP, 2026-08-11): DisplayTimestampsSource —
|
|
// PlayerOption DisplayTimeStamps prefix.
|
|
|
|
[Fact]
|
|
public void AddText_TimestampsUnbound_NoPrefix()
|
|
{
|
|
using var state = new RuntimeCommunicationState();
|
|
|
|
state.AddText("Your spell fizzled.", RetailLogTextType.Default);
|
|
|
|
Assert.Equal("Your spell fizzled.", state.Chat.Snapshot()[0].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddText_TimestampsFalse_NoPrefix()
|
|
{
|
|
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => false };
|
|
|
|
state.AddText("Your spell fizzled.", RetailLogTextType.Default);
|
|
|
|
Assert.Equal("Your spell fizzled.", state.Chat.Snapshot()[0].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddText_TimestampsTrue_StoredBodyStaysClean()
|
|
{
|
|
// OP4 re-review R1 (2026-08-11): the prefix belongs to the DISPLAY
|
|
// composition (ChatVM prepends FormatTimestampPrefix(entry.Received)
|
|
// to the COMPOSED line — retail fprintf("%ls%ls", ts, text)
|
|
// @0x00563e5b), never to the stored body. The earlier fix round
|
|
// prefixed entry.Text here, which put the stamp INSIDE the quotes of
|
|
// composed kinds ('Alice says, "13:05:09 hi"').
|
|
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true };
|
|
|
|
state.AddText("Your spell fizzled.", RetailLogTextType.Default);
|
|
|
|
Assert.Equal("Your spell fizzled.", state.Chat.Snapshot()[0].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void DisplayTimestampsSource_ForwardsToChat_SoTheDisplaySeamSeesOneSource()
|
|
{
|
|
// R1: this class still owns the ONE forwarding seam — ChatVM reads
|
|
// ChatLog.DisplayTimestampsSource at display composition, so setting
|
|
// it HERE must reach the log's property (every producer's entries
|
|
// then render prefixed, ChatVMTests pins the composed-line shape).
|
|
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true };
|
|
|
|
Assert.NotNull(state.Chat.DisplayTimestampsSource);
|
|
Assert.True(state.Chat.DisplayTimestampsSource!());
|
|
|
|
// And the stored body of a non-AddText producer stays clean too.
|
|
state.Chat.OnLocalSpeech("Alice", "hi", 0xAAu, isRanged: false, logTextType: 0x02u);
|
|
Assert.Equal("hi", state.Chat.Snapshot()[0].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddText_TimestampsTrue_NeverAppliedToClientLocalSpewBox()
|
|
{
|
|
// Retail's own ClientLocal (0x1A) exemption already skips the
|
|
// whole AddTextToScroll destination — timestamps are a transcript
|
|
// concept, never applied to the transient SpewBox line.
|
|
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true };
|
|
|
|
state.AddText("Out of Range!", RetailLogTextType.ClientLocal);
|
|
|
|
state.SpewBox.Tick(0d);
|
|
Assert.Equal("Out of Range!", state.SpewBox.Snapshot()[0].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispose_ResetsSpewBox()
|
|
{
|
|
var state = new RuntimeCommunicationState();
|
|
state.AddText("about to be torn down", RetailLogTextType.ClientLocal);
|
|
state.SpewBox.Tick(0d);
|
|
Assert.Equal(1, state.SpewBox.Count);
|
|
|
|
state.Dispose();
|
|
|
|
Assert.Equal(0, state.SpewBox.Count);
|
|
}
|
|
|
|
// ── Campaign CH slice CH6b: ChatWindows is the canonical per-window
|
|
// filter/open owner every host borrows — no presentation-owned copy. ──
|
|
|
|
[Fact]
|
|
public void ChatWindows_SeededWithRetailPostInitDefaults_OnConstruction()
|
|
{
|
|
using var state = new RuntimeCommunicationState();
|
|
|
|
Assert.True(state.ChatWindows.IsOpen(0));
|
|
Assert.False(state.ChatWindows.IsOpen(1));
|
|
Assert.Equal(0x0000101Cu, state.ChatWindows.GetFilter(1));
|
|
Assert.Equal(0x00040C00u, state.ChatWindows.GetFilter(2));
|
|
Assert.Equal(0x00080000u, state.ChatWindows.GetFilter(3));
|
|
Assert.Equal(0x78000000u, state.ChatWindows.GetFilter(4));
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispose_ResetsChatWindowsToRetailDefaults()
|
|
{
|
|
var state = new RuntimeCommunicationState();
|
|
state.ChatWindows.SetOpen(1, true);
|
|
state.ChatWindows.SetFilter(2, 0u);
|
|
|
|
state.Dispose();
|
|
|
|
Assert.False(state.ChatWindows.IsOpen(1));
|
|
Assert.Equal(0x00040C00u, state.ChatWindows.GetFilter(2));
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|