Both OP4 reviews converged on one headline bug (Character-tab rows never re-read live server truth after their pre-login constructor-word seed) plus overlapping MUST-FIXes. All ten converged/consolidated findings land here: MUST-FIX: - BoolOptionRow.SaveCurrentValue now re-reads its live binding (retail's GetValue()-into-SaveCurrentValue) on every OnShown — panel open, tab switch in, initial activation — instead of trusting the pre-login constructor word it was built with. Reset/tab-switch can now only restore values that were actually live at the last show. LockUI's host.Root.UiLocked one-shot mount seed now also converges on every PlayerDescription via the existing OnCharacterOptionsChanged hook. - Apply/Reset are wired to OptionPage.OnOptionChanged in production (Ghosted when nothing changed, Normal when dirty, run once at bind so both start disabled per retail's PostInit); Defaults stays ungated. - The Combat panel's three LEDs (Repeat Attacks/Auto Target/Keep in View) now read/write the same RuntimeCharacterOptionsState seam the Character tab uses instead of a disconnected client-local GameplaySettings copy — closes the "two writable copies" divergence. The three now-orphaned GameplaySettings fields and RuntimeSettingsController's mirror properties/SetCombatGameplay are deleted outright; the headless host's hardcoded AutoRepeatAttack/AutoTarget now read the live option bit. - RuntimeSettingsController.SetUiLocked's convergence guard now compares against the last value actually applied to the runtime target instead of the persisted GameplaySettings.LockUI snapshot, which could already match a server-derived request without ever having been pushed. SHOULD-FIX: - DisplayTimeStamps now prefixes every chat producer (ChatLog.Append is the one seam all of them funnel through), not just AddText's own callers — heard speech, emotes, Turbine channels, and combat text were previously missed. The prefix format escapes its colons and forces InvariantCulture instead of the culture-dependent TimeSeparator placeholder. - sky.frag now honors uFogParams.w (fog mode) like the mesh/terrain shaders, so Disable Distance Fog stops the sky dome's horizon band from blending toward fog color too. - Corrected the "byte-verified" overclaim on the timestamp format string doc comment (BN-sourced, wire doc U6) and the AP-194 anchor-column class-name typo; the RunAsDefaultMovement doc comments now cite retail's actual acclient.h enumerator name. - Added: DispatcherMovementInputSource's option x modifier truth table (incl. || AutoRunActive with the option off), the per-page Apply/Reset enable-gate tests, a real checkbox.OnClick/ToggleBehavior-driven click test, and hash-pins for the six header string keys. - Gate script step 8 corrected for the logout-flush false-failure (closing the panel before relogging is load-bearing); a new step documents the enable-gate sequence and the Combat-panel/Character-tab cross-check. Register: AP-196 (the Group-C default-source change + GameplaySettings retirement) and AP-197 (the ignored per-character timestamp format override) filed in this commit. Full Release suite: 13,044 passed / 4 skipped / 0 failed (was 13,008/4/0; net +36 tests from new coverage and legitimate assertion updates from the GameplaySettings retirement). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
413 lines
16 KiB
C#
413 lines
16 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_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 " (colons ESCAPED, not the
|
|
// culture-dependent TimeSeparator placeholder) is the exact
|
|
// equivalent (SF-1/S4, OP4 review-fix round, 2026-08-11).
|
|
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Your spell fizzled\.$", text);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddText_TimestampsTrue_UsesLiteralColons_RegardlessOfCurrentCulture()
|
|
{
|
|
// SF-1/S4 (OP4 review-fix round, 2026-08-11): an unescaped "H:mm:ss"
|
|
// format string renders ':' as CurrentCulture.DateTimeFormat.
|
|
// TimeSeparator, which is NOT ':' on cultures like fi-FI ("."). The
|
|
// fix escapes the colons and forces InvariantCulture — prove the
|
|
// output stays colon-separated even under a culture that would
|
|
// otherwise substitute a different separator.
|
|
CultureInfo original = Thread.CurrentThread.CurrentCulture;
|
|
try
|
|
{
|
|
Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("fi-FI");
|
|
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true };
|
|
|
|
state.AddText("Your spell fizzled.", RetailLogTextType.Default);
|
|
|
|
string text = state.Chat.Snapshot()[0].Text;
|
|
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Your spell fizzled\.$", text);
|
|
}
|
|
finally
|
|
{
|
|
Thread.CurrentThread.CurrentCulture = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void DisplayTimestampsSource_ForwardsToChat_TimestampingEveryProducer_NotJustAddText()
|
|
{
|
|
// S1 (OP4 review-fix round, 2026-08-11, blast lens): AddText's own
|
|
// callers (ServerMessage/WeenieError) were a strict SUBSET of
|
|
// every chat producer — heard speech, emotes, Turbine channels,
|
|
// and combat text all bypassed it by calling ChatLog's own OnXxx
|
|
// methods directly. Setting DisplayTimestampsSource on this class
|
|
// must timestamp lines that never go through AddText at all.
|
|
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true };
|
|
|
|
state.Chat.OnLocalSpeech("Alice", "hi", 0xAAu, isRanged: false, logTextType: 0x02u);
|
|
|
|
string text = state.Chat.Snapshot()[0].Text;
|
|
Assert.EndsWith("hi", text);
|
|
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} hi$", 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");
|
|
}
|
|
}
|