acdream/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs
Erik 22b86b9ff4 feat(ui): Campaign OP slice OP4 — the Character tab
Binds LayoutDesc 0x21000028 (gmCharacterSettingsUI) through OP2's
template-list mechanism and OP3's OptionPage model: 6 authored group
headers + 50 toggle rows (49 from the 2013 build + D3's "Listen to PK
death messages", AP-193) in research doc §2's authored order, each row
resolved by PlayerOption id through CharacterOptionTable, seeded from
live RuntimeCharacterOptionsState, defaulted from CharacterOptionTable.
ClientDefault (byte-verified against UIOption_Checkbox::SetPlayerOption
@0x00486e80's own GetDefaultOptionValue call — AP-194 updated to confirm
the directive was followed), labels/tooltips resolved by name from
string table 0x23000003 (never hard-coded English), and registered with
OptionsPanelController.CharacterPage. Apply/Reset/Defaults
(0x100001FC/FD/FE) are now wired per-page via a scoped subtree search
(UiElement.FindDescendant, promoted from UiTabPanel) since Character/
Chat/Config each author their own physical instance under the SAME
element ids.

Consumers: Group A (29 ids) wire+store only via the existing
SetSingleCharacterOptionRuntimeCmd/TrySetOption seam. Group B: Display
Timestamps prefixes new transcript lines (RuntimeCommunicationState.
DisplayTimestampsSource); Disable Distance Fog forces FogMode.Off
(WeatherSystem.DisableDistanceFogSource, retiring half of TS-73); Run as
Default Movement inverts the walk-mode modifier's default
(RuntimeLocalPlayerMovementState.RunAsDefaultMovementSource). Group C
re-points AutoTarget/AutoRepeatAttack/ViewCombatTarget
(CharacterOptionCombatSettingsSource), VividTargetingIndicator/
CoordinatesOnRadar/LockUI/AcceptLootPermits from the client-local
GameplaySettings record to the canonical server bit — closing two
previously-unfiled divergences where AutoRepeatAttack and
AcceptCorpseLootingPermissions never reached the wire despite being
retail auto-save ids. TS-73 narrowed to its two still-open cases;
TS-75..TS-80 file the genuine gaps (no day/night force, no weather-
particle/profanity-filter/salvage/housing/pickup-preference subsystem,
fellowship-create's unaudited client-sourced field) rather than
inventing stand-ins.

Conformance: CharacterOptionsPageControllerTests pins all 50 rows
against CharacterOptionTable in both directions (an invented or dropped
row fails the build), the authored group/order row-by-row, and the
build/seed/Apply/Reset/Defaults/wire-publish behavior end-to-end against
the committed fixture. 52 new tests; full solution suite 13,008 passed /
4 skipped / 0 failed (was 12,956/4/0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 04:31:34 +02:00

365 lines
14 KiB
C#

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_PrefixesTranscriptLine()
{
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true };
state.AddText("Your spell fizzled.", RetailLogTextType.Default);
string text = state.Chat.Snapshot()[0].Text;
Assert.EndsWith("Your spell fizzled.", text);
Assert.NotEqual("Your spell fizzled.", text);
// Retail ctor default format "%#H:%M:%S " (non-zero-padded 24h
// hour, zero-padded minute:second, trailing space before the
// text) — .NET "H:mm:ss " is the exact equivalent.
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Your spell fizzled\.$", 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");
}
}