using System.Globalization; using System.Numerics; using AcDream.Core.Chat; using AcDream.Core.Combat; namespace AcDream.UI.Abstractions.Panels.Chat; /// /// ViewModel for the chat panel. Reads the tail of /// and formats each into a single display line. /// /// /// Formatting lives here (not in the panel) so the same rendering logic /// survives the Phase D.2b backend swap — under the custom retail-look /// toolkit we'll want different per- styling, but /// the plain-text form is the fallback and the starting point. /// /// /// /// Retained UI consumers can key formatted-layout caches from /// . The revision advances on append and clear, so an /// unchanged transcript does not require a queue snapshot each frame. /// /// public sealed class ChatVM : IDisposable, IChatCommandFeedback { /// Default number of tail entries rendered. public const int DefaultDisplayLimit = 20; private readonly ChatLog _log; private readonly ChatCommandTargetState _commandTargets; private readonly bool _ownsCommandTargets; private readonly int _displayLimit; private bool _disposed; /// /// Sender name of the most recent INCOMING Tell. Drives the /// /r reply slash command in . /// Null until the first Tell arrives. Outgoing self-sent Tell /// echoes (which run through ) do /// NOT update this — we discriminate by SenderGuid != 0; /// only real inbound tells from /// carry a non-zero guid. Mirrors holtburger /// chat.rs::ChatState::last_incoming_tell_sender (line 74 + /// the assignment at line 152). /// public string? LastIncomingTellSender => _commandTargets.LastIncomingTellSender; /// /// Target of the most recent OUTGOING Tell (the player's own /// /tell <name> …). Drives the /retell <msg> /// (or @retell) slash command, which resends to the same /// target. Mirrors retail's @retell. Self-sent echoes flow /// through with /// SenderGuid == 0 and the target name in Sender — /// that's the discriminator we capture here. /// public string? LastOutgoingTellTarget => _commandTargets.LastOutgoingTellTarget; /// /// Optional callback exposing the live framerate. Wired by /// GameWindow at construction so the client-side /// /framerate command can print "Framerate: 144.2 FPS" /// into chat without the panel knowing about the render-loop. /// public Func? FpsProvider { get; init; } /// /// Optional callback exposing the local player's world position. /// Used by /loc to print /// "Location: (123.4, 567.8, 60.0)". Wired by GameWindow. /// public Func? PositionProvider { get; init; } /// /// Optional hook routing retail-0x1A () interface text — command /// refusals and bad-argument usage lines — to the SpewBox instead of /// the chat transcript. AcDream.UI.Abstractions must stay /// Runtime-independent (Code Structure Rules), so it cannot call /// RuntimeCommunicationState.AddText directly; the App-layer /// composition host wires this the same way it wires /// /. Closes /// ISSUES.md #367 / register row AP-186 — /// no longer has to render every 0x1A refusal through the chat /// scroll. /// public Action? OnInterfaceText { get; init; } /// Monotonic revision of the underlying transcript content. public long Revision => _log.Revision; /// /// Build a ChatVM bound to a instance. /// /// Live chat log. Never null. /// /// Maximum number of tail entries to surface per /// call. Must be >= 1. Defaults to /// . /// public ChatVM( ChatLog log, int displayLimit = DefaultDisplayLimit, ChatCommandTargetState? commandTargets = null) { _log = log ?? throw new ArgumentNullException(nameof(log)); if (displayLimit < 1) throw new ArgumentOutOfRangeException(nameof(displayLimit), displayLimit, "must be >= 1"); _displayLimit = displayLimit; _commandTargets = commandTargets ?? new ChatCommandTargetState(_log); _ownsCommandTargets = commandTargets is null; } public void Dispose() { if (_disposed) return; if (_ownsCommandTargets) _commandTargets.Dispose(); _disposed = true; } /// /// Append a client-side system line to the chat log. Used by /// client-handled commands (/help, /clear, future) to surface /// local feedback without round-tripping the server. /// /// /// LogTextType 0x00 Default, NOT 0x1A (corrected 2026-08-09, /// Opus review of 172c6f9a). This sink is ClientCommandController's /// general-purpose output — @version, /loc, friends list, usage lines — /// and retail types the great majority of that informational command /// output 0x00, reserving 0x1A (bright red) for genuine /// refusals/errors. /// /// /// Comment corrected 2026-08-09, CH2 REJECT-review rework (NIT 2, /// docs/research/2026-08-09-ch2-review-findings.md): the earlier /// wording claimed the refusal-vs-informational split "lands with CH2's /// producer rewiring" — it did not. CH2's SpewBox routing covers /// WeenieError/WeenieErrorWithString ids, which carry their /// own resolved RetailLogTextType; this sink takes plain /// pre-formatted TEXT with no error code attached, so /// WeenieErrorMessages has nothing to classify here. Per-call-site /// classification of THIS sink's callers (which specific /// ClientCommandController lines are genuine refusals retail /// would type 0x1A) remains unstarted, and even a classified /// caller would still need retail's windowId dual-destination /// echo (see register row AP-180) to land in both the SpewBox and the /// command's originating chat window — out of scope for CH4/CH5, not /// CH2. /// public void ShowSystemMessage(string text) => _log.OnSystemMessage(text, chatType: 0x00u); /// /// Route a retail-0x1A () /// command refusal / usage line to the SpewBox — retail's /// ClientSystem::AddTextToScroll(text, 0x1A, 1, windowId) @0x00563C50 /// destination for this text type is the SpewBox exclusively, never a /// chat window (docs/research/2026-08-09-chat-retail-interface-text.md /// §2.1/§2.2). /// /// /// Prefers when the App-layer host wired /// it (the production graphical client). When unwired — headless, the /// automation probe runner, or a test fixture that only exercises the /// pure UI.Abstractions layer — the text still needs to reach the /// player somewhere, so it falls back to the ordinary chat transcript /// tagged with the real /// color rather than being silently dropped. That fallback lands in /// the wrong PANEL (chat instead of SpewBox) but keeps the right TYPE /// and never loses the line — the safe default issue #363 requires. /// public void ShowInterfaceText(string text) { if (OnInterfaceText is { } hook) hook(text); else _log.OnSystemMessage(text, chatType: (uint)RetailLogTextType.ClientLocal); } /// /// Drain the chat log. Used by the /clear client-side command. /// public void Clear() => _log.Clear(); /// /// Forget per-session reply/retell destinations while retaining the shared /// transcript. Old character names must not become command targets after a /// reconnect. /// public void ResetSessionTargets() { _commandTargets.ResetSession(); } /// /// Print the current framerate into chat. Used by /// /framerate / @framerate. Falls back to a /// helpful diagnostic line if no /// is wired (test / pre-live-session scenarios). /// public void ShowFps() { var fps = FpsProvider?.Invoke(); ShowSystemMessage(fps is null ? "Framerate: (provider unavailable)" : string.Create( CultureInfo.InvariantCulture, $"Framerate: {fps.Value:F1} FPS")); } /// /// Print the local player's world position into chat. Used by /// /loc / @loc. Falls back to a helpful /// diagnostic line if no is /// wired (pre-EnterWorld / tests). /// public void ShowLocation() { var pos = PositionProvider?.Invoke(); ShowSystemMessage(pos is null ? "Location: (provider unavailable)" : string.Create( CultureInfo.InvariantCulture, $"Location: ({pos.Value.X:F1}, {pos.Value.Y:F1}, {pos.Value.Z:F1})")); } /// /// Snapshot the tail of the chat log, formatted as display strings, /// oldest-first. Never returns null; returns an empty array if the /// log is empty. /// public IReadOnlyList RecentLines() { var snap = _log.Snapshot(); int start = Math.Max(0, snap.Length - _displayLimit); int count = snap.Length - start; if (count <= 0) return Array.Empty(); // OP4 re-review R1: read the option once per snapshot so every line // in one frame renders consistently. bool timestamps = _log.DisplayTimestampsSource?.Invoke() == true; var lines = new string[count]; for (int i = 0; i < count; i++) { var entry = snap[start + i]; lines[i] = timestamps ? ChatLog.FormatTimestampPrefix(entry.Received) + FormatEntry(entry) : FormatEntry(entry); } return lines; } /// /// Format a single for display. Public so tests /// can assert the per-kind formatting without touching a full log. /// public static string FormatEntry(ChatEntry entry) => FormatEntry(entry, static sender => sender); /// /// The lowest and highest object ids retail treats as a player, and /// therefore the only senders it makes clickable. /// /// /// AC1's dynamic/player id range, read off the guard in /// Handle_Communication__HearSpeech @0x005712A0: outside it, retail /// emits the sender's name as plain text with no tag at all. Monsters and /// NPCs therefore never become clickable, which is the behaviour we want /// and would not get from a "has a name" test. /// private const uint FirstPlayerObjectId = 0x50000001u; private const uint LastPlayerObjectId = 0x6FFFFFFFu; /// /// Formats an entry with retail's tag markup around the sender's name, /// when that sender is a player. /// /// /// Shares its format strings with /// through the sender decorator, deliberately: two copies of retail's /// wording would be two things to keep in step, and the plain and tagged /// renderings of a line MUST show the same characters — the transcript /// selects and hit-tests against the flat text. /// public static string FormatEntryTagged(ChatEntry entry) => ShouldTagSender(entry) ? FormatEntry( entry, sender => $"{sender}<\\Tell>") : FormatEntry(entry); /// Whether this entry's sender is a clickable player. /// /// A name containing a markup delimiter is deliberately NOT tagged. The /// markup has no escape mechanism — retail's has none either, because AC /// name validation makes the case unreachable there — so a name like /// Od<d would be re-parsed as a marker and SWALLOW characters /// out of the visible line. Sender names are server data, so the guard /// stays: the line renders plain, exactly as it does for any other /// untaggable sender, instead of rendering corrupted. /// internal static bool ShouldTagSender(ChatEntry entry) => entry.SenderGuid >= FirstPlayerObjectId && entry.SenderGuid <= LastPlayerObjectId && !string.IsNullOrEmpty(entry.Sender) && entry.Sender.IndexOf('<') < 0 && entry.Sender.IndexOf('>') < 0 && !IsOwnSpeaker(entry.Sender) && entry.Kind is ChatKind.LocalSpeech or ChatKind.RangedSpeech or ChatKind.Channel or ChatKind.Tell; private static string FormatEntry( ChatEntry entry, Func decorateSender) => entry.Kind switch { // Retail style: "Name says, \"text\"" (incoming) / // "You say, \"text\"" (own echo). Sender is "" for an // OnSelfSent echo; OnLocalSpeech substitutes "You" when the // server sends an empty sender (own-shout echoes). Both forms // collapse to the singular "You say" verb here. ChatKind.LocalSpeech => IsOwnSpeaker(entry.Sender) ? $"You say, \"{entry.Text}\"" : $"{decorateSender(entry.Sender)} says, \"{entry.Text}\"", ChatKind.RangedSpeech => IsOwnSpeaker(entry.Sender) ? $"You shout, \"{entry.Text}\"" : $"{decorateSender(entry.Sender)} shouts, \"{entry.Text}\"", // Channel: "[ChannelName] Sender says, \"text\"". ChannelName // is populated by callers that know the friendly name (the // TurbineChat inbound dispatch and OnSelfSent for Channel // kinds); falls back to "ch {ChannelId}" if not set. // Empty/"You" sender → "[Channel] You say, ..." for our own // optimistic echo on legacy ChatChannel and self-broadcast on // turbine channels (server's EventSendToRoom carries the // sender name; OnSelfSent for legacy channels leaves it // empty so the formatter substitutes here). ChatKind.Channel => IsOwnSpeaker(entry.Sender) ? $"[{ChannelLabel(entry)}] You say, \"{entry.Text}\"" : $"[{ChannelLabel(entry)}] {decorateSender(entry.Sender)} says, \"{entry.Text}\"", // Tell: SenderGuid != 0 means an incoming whisper; == 0 is the // OnSelfSent echo where Sender carries the target name. Retail // wording: "You tell Caith, \"hi\"" / "Caith tells you, \"hi\"". ChatKind.Tell => entry.SenderGuid != 0 ? $"{decorateSender(entry.Sender)} tells you, \"{entry.Text}\"" : $"You tell {entry.Sender}, \"{entry.Text}\"", // Campaign CH user-gate round 1 (item B): retail prints system text // bare, with no "[System]" prefix — that prefix was acdream's own // invention. [Popup] stays (AP-175, a deliberate divergent // presentation marker for a different kind). ChatKind.System => entry.Text, ChatKind.Popup => $"[Popup] {entry.Text}", // Phase I.5: emote rendering matches retail's leading-asterisk // convention ("* Caith waves at you"). SoulEmote uses the same // prefix; the difference between Emote and SoulEmote is which // animation pairs with the chat line (handled by the renderer, // not the formatter). ChatKind.Emote => $"* {entry.Sender} {entry.Text}", ChatKind.SoulEmote => $"* {entry.Sender} {entry.Text}", // Phase I.7: combat-line entries are pre-formatted by // CombatChatTranslator using holtburger templates verbatim // (chat.rs:221-308). The translator owns the wording; the VM // just passes through. The panel uses TextColored based on // entry.CombatKind. ChatKind.Combat => entry.Text, _ => entry.Text, }; /// /// True when a chat entry's Sender denotes the local player /// — i.e. the entry came from OnSelfSent (empty sender) or /// OnLocalSpeech with the empty-sender substitution kicked /// in (sender == "You"). Used by the formatter to pick the /// singular "You say" verb over the third-person "Name says". /// private static bool IsOwnSpeaker(string sender) => string.IsNullOrEmpty(sender) || sender == "You"; /// /// Friendly channel label for a Channel entry. Prefers the entry's /// ChannelName (set by callers that know the room name) /// and falls back to "ch {ChannelId}" so legacy paths still /// produce a readable line. /// private static string ChannelLabel(ChatEntry entry) => string.IsNullOrEmpty(entry.ChannelName) ? $"ch {entry.ChannelId}" : entry.ChannelName; /// /// Phase I.7: snapshot of the chat tail with kind metadata so /// can pick the right rendering primitive /// per entry (plain Text for most kinds; TextColored /// for combat lines, with the rgba chosen from /// ). Campaign CH slice CH1 also /// carries through — the retail /// color key, keyed independently of . /// public IReadOnlyList RecentLinesDetailed() { var snap = _log.Snapshot(); int start = Math.Max(0, snap.Length - _displayLimit); int count = snap.Length - start; if (count <= 0) return Array.Empty(); // OP4 re-review R1: retail prepends the timestamp to the COMPOSED // display line (a separate leading string — fprintf("%ls%ls", ts, // text) @0x00563e5b), never to the message body, so tells/says render // '13:05:09 Alice says, "hi"' and not 'Alice says, "13:05:09 hi"'. bool timestamps = _log.DisplayTimestampsSource?.Invoke() == true; var lines = new FormattedLine[count]; for (int i = 0; i < count; i++) { var entry = snap[start + i]; // Compose with retail's tag markup, then split it. Text stays the // VISIBLE line (markup consumed), so every existing consumer — // wrapping, selection, hit-testing, the caret — is unaffected; // Spans is the sidecar that remembers which stretch was the // speaker's name. Campaign CT slice A3: this is the point where // sender identity used to die. bool tagged = ShouldTagSender(entry); string markup = FormatEntryTagged(entry); IReadOnlyList? spans = tagged ? ChatTagMarkup.Parse(markup) : null; string text = spans is null ? markup : string.Concat(spans.Select(span => span.Text)); if (timestamps) { string prefix = ChatLog.FormatTimestampPrefix(entry.Received); // The stamp is its own run: retail appends it at a FIXED // colour index rather than the message's, so it stays grey // whatever colour the line is. That means a timestamped line // needs spans even when its sender is not tagged. spans = new[] { new ChatTextSpan(prefix, null, ChatSpanRole.Timestamp) } .Concat(spans ?? new[] { new ChatTextSpan(text, null) }) .ToArray(); text = prefix + text; } lines[i] = new FormattedLine( Text: text, Kind: entry.Kind, CombatKind: entry.CombatKind, LogTextType: entry.LogTextType, Spans: spans); } return lines; } } /// /// Phase I.7: formatted chat line with kind metadata. The /// switches on + /// to pick a rendering primitive /// (Text vs TextColored(rgba)). /// /// /// Campaign CH slice CH1: the retail wire LogTextType that keys /// — see . /// /// /// Campaign CT slice A3: the line split into stretches, where a stretch may /// carry a retail text tag (a clickable speaker name). /// for the ordinary single-colour line, which is most of them. /// Invariant: concatenating the span text reproduces /// exactly — the transcript wraps, selects and /// hit-tests against that flat string. /// public readonly record struct FormattedLine( string Text, ChatKind Kind, CombatLineKind? CombatKind, uint LogTextType, IReadOnlyList? Spans = null);