acdream/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs
Erik 1aa7709988 fix(chat): CH6a/b rework — grip media, retail window-id model, floaty fixture
Applies docs/research/2026-08-10-ch6ab-review-findings.md in full:

- BLOCKER 1: UiResizeGrip now carries its ElementInfo/resolve pair and
  draws its own authored DirectState media (a synthetic parameterless
  grip still draws nothing, preserving existing resize-drag tests).
  DatWidgetFactory.BuildResizeGrip threads resolve through. All seven
  live grips on the main chat window now resolve a non-zero sprite,
  restoring the visible borders/corners CH6a silently dropped.

- SHOULD-FIX 2: ChatWindowState gains BroadcastTargetWindow, a sentinel
  distinct from every real window id (0-4), fixing the bug where the
  main window's explicit-addressing branch coincided with the broadcast
  check (both were literal 0). SetFilter's main-window no-op is dropped
  — the main window's filter is now genuinely settable. ChatWindowController
  .Bind takes a ChatWindowState (the same canonical instance the floating
  windows already share) and GetTranscriptLines builds a real accept
  predicate instead of accept:null. Verified safe: ClientLocal (0x1A)
  never reaches ChatLog (AddText routes it to the SpewBox and returns),
  so nothing observable regresses.

- SHOULD-FIX 3: UiButton.SuppressSelfToggle stops the four chat-window
  indicator buttons (DAT property 0x0B=true, no retail click handler)
  from flipping their own Selected mirror on a stray click.

- SHOULD-FIX 4: generated and committed chat_floaty_2100005b.json from
  the real installed dats; added the permanent RetailLayoutFixtureGenerator
  entry. All three flagged FloatingChatWindowController assumptions
  (input field, title bar, close button) are confirmed correct against
  real data — no controller code changes needed. New finding: unlike the
  main window, ALL EIGHT floaty border/corner elements are live Type-9
  grips (the floaty's own title bar is its move handle), so a floaty
  window resizes from every edge and corner.

- SHOULD-FIX 5: register row AP-189 documents the shared-500-entry/
  200-line-tail vs retail's per-window 10,000-line scrollback depth gap.

- NITs 1-5: documented the filter-persistence-only-on-/saveautoui
  asymmetry and the reconnect-preserves-filters intent; corrected the
  research doc's modifier-mask mislabel and the "ONLY function" false
  superlative; moved WrapText off ChatWindowController onto
  ChatTranscriptRenderer, closing the circular dependency.

Full Release suite: 12,420 passed / 4 skipped / 0 failed (baseline
12,392/4/0 at 22020ef2; net +28 tests, zero regressions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:09:32 +02:00

471 lines
16 KiB
C#

using AcDream.Core.Chat;
using AcDream.Core.Social;
namespace AcDream.Runtime.Gameplay;
public readonly record struct RuntimeCommunicationEvent(
ulong Sequence,
RuntimeChatEntry Entry);
public interface IRuntimeCommunicationObserver
{
void OnChat(in RuntimeCommunicationEvent delta);
}
public interface IRuntimeCommunicationEventSource
{
IDisposable Subscribe(IRuntimeCommunicationObserver observer);
}
public readonly record struct RuntimeCommunicationOwnershipSnapshot(
bool IsDisposed,
bool CommandTargetsDisposed,
int StreamSubscriberCount,
int PendingDispatchCount,
bool IsDispatching,
int FriendCount,
int SquelchAccountCount,
int SquelchCharacterCount,
int SquelchGlobalTypeCount,
int NegotiatedRoomCount,
bool HasReplyTarget,
bool HasRetellTarget,
long DispatchFailureCount)
{
public bool IsConverged =>
IsDisposed
&& CommandTargetsDisposed
&& StreamSubscriberCount == 0
&& PendingDispatchCount == 0
&& !IsDispatching
&& FriendCount == 0
&& SquelchAccountCount == 0
&& SquelchCharacterCount == 0
&& SquelchGlobalTypeCount == 0
&& NegotiatedRoomCount == 0
&& !HasReplyTarget
&& !HasRetellTarget;
}
/// <summary>
/// Canonical presentation-independent owner for communication and social
/// state. Graphical, headless, plugin, and bot hosts borrow these exact
/// instances.
/// </summary>
public sealed class RuntimeCommunicationState : IDisposable
{
private readonly RuntimeCommunicationEventStream _events;
private bool _disposed;
public RuntimeCommunicationState(int maximumChatEntries = 500)
{
Chat = new ChatLog(maximumChatEntries);
SpewBox = new SpewBoxState();
CommandTargets = new ChatCommandTargetState(Chat);
_events = new RuntimeCommunicationEventStream(Chat);
TurbineChat = new TurbineChatState();
Friends = new FriendsState();
Squelch = new SquelchState();
ChatWindows = new ChatWindowState();
View = new CommunicationView(Chat);
SocialView = new CommunicationSocialView(
TurbineChat,
Friends,
Squelch);
}
public ChatLog Chat { get; }
/// <summary>
/// Campaign CH slice CH6b: retail's per-window text-type filter and
/// open/visible state for the main chat window (id 0) and the four
/// floating chat windows (ids 1-4). Graphical, headless, and plugin
/// hosts all borrow this exact instance — presentation never owns a
/// second copy of filter/open state.
/// </summary>
public ChatWindowState ChatWindows { get; }
/// <summary>
/// Campaign CH slice CH2: retail's transient on-screen "interface text"
/// queue (<c>gmSpewBoxUI</c>) — the SECOND sink <see cref="AddText"/>
/// can route to. Never written to directly by producers; always through
/// <see cref="AddText"/> so the <c>type == ClientLocal</c> routing rule
/// stays centralized at one chokepoint, matching retail's
/// <c>ClientSystem::AddTextToScroll</c>.
/// </summary>
public SpewBoxState SpewBox { get; }
public ChatCommandTargetState CommandTargets { get; }
public TurbineChatState TurbineChat { get; }
public FriendsState Friends { get; }
public SquelchState Squelch { get; }
public IRuntimeChatView View { get; }
public IRuntimeSocialView SocialView { get; }
public IRuntimeCommunicationEventSource Events => _events;
public bool IsDisposed => _disposed;
public ulong LastSequence => _events.LastSequence;
public int SubscriberCount => _events.SubscriberCount;
public int PendingDispatchCount => _events.PendingDispatchCount;
public bool IsDispatching => _events.IsDispatching;
public long DispatchFailureCount => _events.DispatchFailureCount;
public Exception? LastDispatchFailure => _events.LastDispatchFailure;
public RuntimeCommunicationOwnershipSnapshot CaptureOwnership()
{
SquelchDatabase squelch = Squelch.Snapshot();
return new RuntimeCommunicationOwnershipSnapshot(
_disposed,
CommandTargets.IsDisposed,
SubscriberCount,
PendingDispatchCount,
IsDispatching,
Friends.Count,
squelch.Accounts.Count,
squelch.Characters.Count,
squelch.Global.MessageTypes.Count,
CountRooms(TurbineChat),
CommandTargets.LastIncomingTellSender is not null,
CommandTargets.LastOutgoingTellTarget is not null,
DispatchFailureCount);
}
public void ResetCommandTargets() => CommandTargets.ResetSession();
public void ResetChatIdentity() => Chat.ResetSessionIdentity();
public void ResetNegotiatedChannels() => TurbineChat.Reset();
public void ResetFriends() => Friends.Clear();
public void ResetSquelch() => Squelch.Clear();
/// <summary>
/// Campaign CH slice CH2: SpewBox lines are purely transient screen
/// flash — unlike the chat transcript (which survives a reconnect via
/// <see cref="ResetChatIdentity"/>'s preserve-content reset), a fresh
/// generation must not resurrect a stale refusal line.
/// </summary>
public void ResetSpewBox() => SpewBox.Reset();
/// <summary>
/// The single chokepoint every producer of player-visible interface
/// text must go through — the direct analogue of retail's
/// <c>ClientSystem::AddTextToScroll(text, type, allowPluginFilter,
/// windowId) @0x00563C50</c>.
/// </summary>
/// <remarks>
/// Retail's routing rule (research doc §2.1) is a receiver-side type
/// filter, not a sender-side destination switch:
/// <list type="bullet">
/// <item><c>type == RetailLogTextType.ClientLocal (0x1A)</c> → the
/// SpewBox ONLY. Every <c>ChatInterface</c> window is born with that
/// exact bit cleared from its type filter
/// (<c>ChatInterface::ChatInterface @0x004F4550</c>,
/// <c>m_llTextTypeFilter &amp;= 0xFBFFFFFF</c>) — <c>0x1A</c> is
/// precisely what every chat window refuses. Retail also skips the
/// timestamp prefix and the chat-log-file write for this type
/// (§2.1 step 4); since this branch never touches
/// <see cref="Chat"/> at all, that behaviour falls out for free.
/// </item>
/// <item>Every other type → the existing chat transcript, tagged
/// with <paramref name="type"/> exactly as before.</item>
/// </list>
/// <paramref name="windowId"/> is accepted for future parity with
/// retail's per-window echo (a non-zero window ID lands in BOTH the
/// SpewBox AND that specific chat window, ~40 slash-command-output
/// sites — research doc §2.3) but is not yet consumed; every current
/// production caller passes the default <c>0</c>. This dual-destination
/// gap is filed as register row AP-180 — implementing it is CH4/CH5
/// scope at the earliest.
/// </remarks>
public void AddText(string text, RetailLogTextType type, uint windowId = 0)
{
ArgumentNullException.ThrowIfNull(text);
// CH2 REJECT-review rework (SHOULD-FIX 2,
// docs/research/2026-08-09-ch2-review-findings.md): retail's own
// first step (ClientSystem::AddTextToScroll @0x00563C50) is
// trim(&str, 1, 1, ws) — BOTH ends, not trailing-only (the
// trailing-only trim in research doc §3.1 belongs to
// gmSpewBoxUI::Update, a SEPARATE later call on the SpewBox's own
// display path, not this chokepoint). Retail also has no empty-
// string guard here — AddTextToScroll broadcasts empty strings
// deliberately (the type-7/Magic s_NullBuffer sites reuse a shared
// buffer that can legitimately be empty between calls); inventing
// an early-return for empty text was an unregistered acdream-only
// divergence, now retired rather than kept as a guessed
// approximation.
text = text.Trim();
if (type == RetailLogTextType.ClientLocal)
{
SpewBox.Enqueue(text);
return;
}
Chat.OnSystemMessage(text, (uint)type);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_events.Dispose();
CommandTargets.ResetSession();
CommandTargets.Dispose();
TurbineChat.Reset();
Friends.Clear();
Squelch.Clear();
Chat.ResetSessionIdentity();
SpewBox.Reset();
// CH6a/b REJECT-review NIT 2: this only runs here, at full teardown
// (process exit / GameRuntime disposal) — NOT on an ordinary
// reconnect, which never calls Dispose. Deliberate: a user's chat-
// window filter customization and open/closed state are client-side
// presentation preferences, the same class as window geometry
// (RetailWindowLayoutPersistence, which also survives reconnect) —
// reconnecting should not silently discard them.
ChatWindows.ResetToDefaults();
}
private sealed class CommunicationView(ChatLog chat) : IRuntimeChatView
{
public long Revision => chat.Revision;
public int Count => chat.Count;
}
private sealed class CommunicationSocialView(
TurbineChatState turbineChat,
FriendsState friends,
SquelchState squelch)
: IRuntimeSocialView
{
public RuntimeSocialSnapshot Snapshot
{
get
{
SquelchDatabase database = squelch.Snapshot();
return new RuntimeSocialSnapshot(
friends.Revision,
friends.Count,
squelch.Revision,
database.Accounts.Count,
database.Characters.Count,
database.Global.MessageTypes.Count,
CountRooms(turbineChat));
}
}
public bool TryGetFriend(
uint characterId,
out RuntimeFriendSnapshot friend)
{
if (!friends.TryGet(characterId, out FriendEntry? current)
|| current is null)
{
friend = default;
return false;
}
friend = new RuntimeFriendSnapshot(
current.Id,
current.Name,
current.Online,
current.AppearOffline);
return true;
}
}
private static int CountRooms(TurbineChatState state)
{
int count = 0;
if (state.AllegianceRoom != 0u) count++;
if (state.GeneralRoom != 0u) count++;
if (state.TradeRoom != 0u) count++;
if (state.LfgRoom != 0u) count++;
if (state.RoleplayRoom != 0u) count++;
if (state.SocietyRoom != 0u) count++;
if (state.OlthoiRoom != 0u) count++;
return count;
}
}
internal sealed class RuntimeCommunicationEventStream
: IRuntimeCommunicationEventSource,
IDisposable
{
private readonly ChatLog _chat;
private readonly object _gate = new();
private readonly List<RuntimeCommunicationEvent> _pendingDispatch = [];
private IRuntimeCommunicationObserver[] _observers = [];
private long _sequence;
private long _dispatchFailureCount;
private bool _dispatching;
private bool _disposed;
public RuntimeCommunicationEventStream(ChatLog chat)
{
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
_chat.EntryAppended += OnEntryAppended;
}
public int SubscriberCount => Volatile.Read(ref _observers).Length;
public ulong LastSequence
{
get
{
lock (_gate)
return unchecked((ulong)_sequence);
}
}
public int PendingDispatchCount
{
get
{
lock (_gate)
return _pendingDispatch.Count;
}
}
public bool IsDispatching
{
get
{
lock (_gate)
return _dispatching;
}
}
public long DispatchFailureCount => Interlocked.Read(ref _dispatchFailureCount);
public Exception? LastDispatchFailure { get; private set; }
public IDisposable Subscribe(IRuntimeCommunicationObserver observer)
{
ArgumentNullException.ThrowIfNull(observer);
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
IRuntimeCommunicationObserver[] current = _observers;
if (Array.IndexOf(current, observer) >= 0)
throw new InvalidOperationException(
"The communication observer is already subscribed.");
var replacement =
new IRuntimeCommunicationObserver[current.Length + 1];
Array.Copy(current, replacement, current.Length);
replacement[^1] = observer;
Volatile.Write(ref _observers, replacement);
}
return new Subscription(this, observer);
}
public void Dispose()
{
lock (_gate)
{
if (_disposed)
return;
_disposed = true;
_chat.EntryAppended -= OnEntryAppended;
_pendingDispatch.Clear();
_dispatching = false;
Volatile.Write(ref _observers, []);
}
}
private void OnEntryAppended(ChatEntry entry)
{
lock (_gate)
{
if (_disposed)
return;
ulong sequence = unchecked((ulong)++_sequence);
_pendingDispatch.Add(new RuntimeCommunicationEvent(
sequence,
new RuntimeChatEntry(
_chat.Revision,
entry.SenderGuid,
(int)entry.Kind,
entry.Sender,
entry.Text,
entry.ChannelName)));
if (_dispatching)
return;
_dispatching = true;
}
int index = 0;
while (true)
{
RuntimeCommunicationEvent pending;
lock (_gate)
{
if (index >= _pendingDispatch.Count)
{
_pendingDispatch.Clear();
_dispatching = false;
return;
}
pending = _pendingDispatch[index++];
}
Dispatch(in pending);
}
}
private void Dispatch(in RuntimeCommunicationEvent delta)
{
IRuntimeCommunicationObserver[] observers =
Volatile.Read(ref _observers);
foreach (IRuntimeCommunicationObserver observer in observers)
{
try
{
observer.OnChat(in delta);
}
catch (Exception error)
{
Interlocked.Increment(ref _dispatchFailureCount);
LastDispatchFailure = error;
}
}
}
private void Unsubscribe(IRuntimeCommunicationObserver observer)
{
lock (_gate)
{
IRuntimeCommunicationObserver[] current = _observers;
int index = Array.IndexOf(current, observer);
if (index < 0)
return;
if (current.Length == 1)
{
Volatile.Write(ref _observers, []);
return;
}
var replacement =
new IRuntimeCommunicationObserver[current.Length - 1];
if (index > 0)
Array.Copy(current, 0, replacement, 0, index);
if (index < current.Length - 1)
{
Array.Copy(
current,
index + 1,
replacement,
index,
current.Length - index - 1);
}
Volatile.Write(ref _observers, replacement);
}
}
private sealed class Subscription(
RuntimeCommunicationEventStream owner,
IRuntimeCommunicationObserver observer)
: IDisposable
{
private RuntimeCommunicationEventStream? _owner = owner;
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unsubscribe(observer);
}
}