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);