383 lines
17 KiB
C#
383 lines
17 KiB
C#
using System.Numerics;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.Core.Combat;
|
|
|
|
namespace AcDream.UI.Abstractions.Panels.Chat;
|
|
|
|
/// <summary>
|
|
/// ViewModel for the chat panel. Reads the tail of <see cref="ChatLog"/>
|
|
/// and formats each <see cref="ChatEntry"/> into a single display line.
|
|
///
|
|
/// <para>
|
|
/// 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-<see cref="ChatKind"/> styling, but
|
|
/// the plain-text form is the fallback and the starting point.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Retained UI consumers can key formatted-layout caches from
|
|
/// <see cref="Revision"/>. The revision advances on append and clear, so an
|
|
/// unchanged transcript does not require a queue snapshot each frame.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class ChatVM : IDisposable, IChatCommandFeedback
|
|
{
|
|
/// <summary>Default number of tail entries rendered.</summary>
|
|
public const int DefaultDisplayLimit = 20;
|
|
|
|
private readonly ChatLog _log;
|
|
private readonly ChatCommandTargetState _commandTargets;
|
|
private readonly bool _ownsCommandTargets;
|
|
private readonly int _displayLimit;
|
|
private bool _disposed;
|
|
|
|
/// <summary>
|
|
/// Sender name of the most recent INCOMING Tell. Drives the
|
|
/// <c>/r</c> reply slash command in <see cref="ChatInputParser"/>.
|
|
/// Null until the first Tell arrives. Outgoing self-sent Tell
|
|
/// echoes (which run through <see cref="ChatLog.OnSelfSent"/>) do
|
|
/// NOT update this — we discriminate by <c>SenderGuid != 0</c>;
|
|
/// only real inbound tells from <see cref="ChatLog.OnTellReceived"/>
|
|
/// carry a non-zero guid. Mirrors holtburger
|
|
/// <c>chat.rs::ChatState::last_incoming_tell_sender</c> (line 74 +
|
|
/// the assignment at line 152).
|
|
/// </summary>
|
|
public string? LastIncomingTellSender =>
|
|
_commandTargets.LastIncomingTellSender;
|
|
|
|
/// <summary>
|
|
/// Target of the most recent OUTGOING Tell (the player's own
|
|
/// <c>/tell <name> …</c>). Drives the <c>/retell <msg></c>
|
|
/// (or <c>@retell</c>) slash command, which resends to the same
|
|
/// target. Mirrors retail's <c>@retell</c>. Self-sent echoes flow
|
|
/// through <see cref="ChatLog.OnSelfSent"/> with
|
|
/// <c>SenderGuid == 0</c> and the target name in <c>Sender</c> —
|
|
/// that's the discriminator we capture here.
|
|
/// </summary>
|
|
public string? LastOutgoingTellTarget =>
|
|
_commandTargets.LastOutgoingTellTarget;
|
|
|
|
/// <summary>
|
|
/// Optional callback exposing the live framerate. Wired by
|
|
/// <c>GameWindow</c> at construction so the client-side
|
|
/// <c>/framerate</c> command can print "Framerate: 144.2 FPS"
|
|
/// into chat without the panel knowing about the render-loop.
|
|
/// </summary>
|
|
public Func<float>? FpsProvider { get; init; }
|
|
|
|
/// <summary>
|
|
/// Optional callback exposing the local player's world position.
|
|
/// Used by <c>/loc</c> to print
|
|
/// "Location: (123.4, 567.8, 60.0)". Wired by <c>GameWindow</c>.
|
|
/// </summary>
|
|
public Func<Vector3>? PositionProvider { get; init; }
|
|
|
|
/// <summary>
|
|
/// Optional hook routing retail-<c>0x1A</c> (<see
|
|
/// cref="RetailLogTextType.ClientLocal"/>) interface text — command
|
|
/// refusals and bad-argument usage lines — to the SpewBox instead of
|
|
/// the chat transcript. <c>AcDream.UI.Abstractions</c> must stay
|
|
/// Runtime-independent (Code Structure Rules), so it cannot call
|
|
/// <c>RuntimeCommunicationState.AddText</c> directly; the App-layer
|
|
/// composition host wires this the same way it wires
|
|
/// <see cref="FpsProvider"/>/<see cref="PositionProvider"/>. Closes
|
|
/// ISSUES.md #367 / register row AP-186 — <see cref="ChatCommandRouter"/>
|
|
/// no longer has to render every <c>0x1A</c> refusal through the chat
|
|
/// scroll.
|
|
/// </summary>
|
|
public Action<string>? OnInterfaceText { get; init; }
|
|
|
|
/// <summary>Monotonic revision of the underlying transcript content.</summary>
|
|
public long Revision => _log.Revision;
|
|
|
|
/// <summary>
|
|
/// Build a ChatVM bound to a <see cref="ChatLog"/> instance.
|
|
/// </summary>
|
|
/// <param name="log">Live chat log. Never null.</param>
|
|
/// <param name="displayLimit">
|
|
/// Maximum number of tail entries to surface per
|
|
/// <see cref="RecentLines"/> call. Must be >= 1. Defaults to
|
|
/// <see cref="DefaultDisplayLimit"/>.
|
|
/// </param>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// LogTextType <c>0x00</c> Default, NOT <c>0x1A</c> (corrected 2026-08-09,
|
|
/// Opus review of 172c6f9a). This sink is <c>ClientCommandController</c>'s
|
|
/// general-purpose output — @version, /loc, friends list, usage lines —
|
|
/// and retail types the great majority of that informational command
|
|
/// output <c>0x00</c>, reserving <c>0x1A</c> (bright red) for genuine
|
|
/// refusals/errors.
|
|
/// </remarks>
|
|
/// <remarks>
|
|
/// <b>Comment corrected 2026-08-09, CH2 REJECT-review rework (NIT 2,
|
|
/// docs/research/2026-08-09-ch2-review-findings.md):</b> the earlier
|
|
/// wording claimed the refusal-vs-informational split "lands with CH2's
|
|
/// producer rewiring" — it did not. CH2's SpewBox routing covers
|
|
/// <c>WeenieError</c>/<c>WeenieErrorWithString</c> ids, which carry their
|
|
/// own resolved <c>RetailLogTextType</c>; this sink takes plain
|
|
/// pre-formatted TEXT with no error code attached, so
|
|
/// <c>WeenieErrorMessages</c> has nothing to classify here. Per-call-site
|
|
/// classification of THIS sink's callers (which specific
|
|
/// <c>ClientCommandController</c> lines are genuine refusals retail
|
|
/// would type <c>0x1A</c>) remains unstarted, and even a classified
|
|
/// caller would still need retail's <c>windowId</c> 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.
|
|
/// </remarks>
|
|
public void ShowSystemMessage(string text) => _log.OnSystemMessage(text, chatType: 0x00u);
|
|
|
|
/// <summary>
|
|
/// Route a retail-<c>0x1A</c> (<see cref="RetailLogTextType.ClientLocal"/>)
|
|
/// command refusal / usage line to the SpewBox — retail's
|
|
/// <c>ClientSystem::AddTextToScroll(text, 0x1A, 1, windowId) @0x00563C50</c>
|
|
/// destination for this text type is the SpewBox exclusively, never a
|
|
/// chat window (<c>docs/research/2026-08-09-chat-retail-interface-text.md</c>
|
|
/// §2.1/§2.2).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Prefers <see cref="OnInterfaceText"/> 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 <see cref="RetailLogTextType.ClientLocal"/>
|
|
/// 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.
|
|
/// </remarks>
|
|
public void ShowInterfaceText(string text)
|
|
{
|
|
if (OnInterfaceText is { } hook)
|
|
hook(text);
|
|
else
|
|
_log.OnSystemMessage(text, chatType: (uint)RetailLogTextType.ClientLocal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drain the chat log. Used by the /clear client-side command.
|
|
/// </summary>
|
|
public void Clear() => _log.Clear();
|
|
|
|
/// <summary>
|
|
/// Forget per-session reply/retell destinations while retaining the shared
|
|
/// transcript. Old character names must not become command targets after a
|
|
/// reconnect.
|
|
/// </summary>
|
|
public void ResetSessionTargets()
|
|
{
|
|
_commandTargets.ResetSession();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Print the current framerate into chat. Used by
|
|
/// <c>/framerate</c> / <c>@framerate</c>. Falls back to a
|
|
/// helpful diagnostic line if no <see cref="FpsProvider"/>
|
|
/// is wired (test / pre-live-session scenarios).
|
|
/// </summary>
|
|
public void ShowFps()
|
|
{
|
|
var fps = FpsProvider?.Invoke();
|
|
ShowSystemMessage(fps is null
|
|
? "Framerate: (provider unavailable)"
|
|
: $"Framerate: {fps.Value:F1} FPS");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Print the local player's world position into chat. Used by
|
|
/// <c>/loc</c> / <c>@loc</c>. Falls back to a helpful
|
|
/// diagnostic line if no <see cref="PositionProvider"/> is
|
|
/// wired (pre-EnterWorld / tests).
|
|
/// </summary>
|
|
public void ShowLocation()
|
|
{
|
|
var pos = PositionProvider?.Invoke();
|
|
ShowSystemMessage(pos is null
|
|
? "Location: (provider unavailable)"
|
|
: $"Location: ({pos.Value.X:F1}, {pos.Value.Y:F1}, {pos.Value.Z:F1})");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public IReadOnlyList<string> RecentLines()
|
|
{
|
|
var snap = _log.Snapshot();
|
|
int start = Math.Max(0, snap.Length - _displayLimit);
|
|
int count = snap.Length - start;
|
|
if (count <= 0) return Array.Empty<string>();
|
|
|
|
// 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Format a single <see cref="ChatEntry"/> for display. Public so tests
|
|
/// can assert the per-kind formatting without touching a full log.
|
|
/// </summary>
|
|
public static string FormatEntry(ChatEntry entry) => 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}\""
|
|
: $"{entry.Sender} says, \"{entry.Text}\"",
|
|
ChatKind.RangedSpeech => IsOwnSpeaker(entry.Sender)
|
|
? $"You shout, \"{entry.Text}\""
|
|
: $"{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)}] {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
|
|
? $"{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,
|
|
};
|
|
|
|
/// <summary>
|
|
/// True when a chat entry's <c>Sender</c> denotes the local player
|
|
/// — i.e. the entry came from <c>OnSelfSent</c> (empty sender) or
|
|
/// <c>OnLocalSpeech</c> 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".
|
|
/// </summary>
|
|
private static bool IsOwnSpeaker(string sender) =>
|
|
string.IsNullOrEmpty(sender) || sender == "You";
|
|
|
|
/// <summary>
|
|
/// Friendly channel label for a Channel entry. Prefers the entry's
|
|
/// <c>ChannelName</c> (set by callers that know the room name)
|
|
/// and falls back to "ch {ChannelId}" so legacy paths still
|
|
/// produce a readable line.
|
|
/// </summary>
|
|
private static string ChannelLabel(ChatEntry entry) =>
|
|
string.IsNullOrEmpty(entry.ChannelName)
|
|
? $"ch {entry.ChannelId}"
|
|
: entry.ChannelName;
|
|
|
|
/// <summary>
|
|
/// Phase I.7: snapshot of the chat tail with kind metadata so
|
|
/// <see cref="ChatPanel"/> can pick the right rendering primitive
|
|
/// per entry (plain <c>Text</c> for most kinds; <c>TextColored</c>
|
|
/// for combat lines, with the rgba chosen from
|
|
/// <see cref="ChatEntry.CombatKind"/>). Campaign CH slice CH1 also
|
|
/// carries <see cref="ChatEntry.LogTextType"/> through — the retail
|
|
/// color key, keyed independently of <see cref="ChatKind"/>.
|
|
/// </summary>
|
|
public IReadOnlyList<FormattedLine> RecentLinesDetailed()
|
|
{
|
|
var snap = _log.Snapshot();
|
|
int start = Math.Max(0, snap.Length - _displayLimit);
|
|
int count = snap.Length - start;
|
|
if (count <= 0) return Array.Empty<FormattedLine>();
|
|
|
|
// 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];
|
|
string text = FormatEntry(entry);
|
|
if (timestamps)
|
|
text = ChatLog.FormatTimestampPrefix(entry.Received) + text;
|
|
lines[i] = new FormattedLine(
|
|
Text: text,
|
|
Kind: entry.Kind,
|
|
CombatKind: entry.CombatKind,
|
|
LogTextType: entry.LogTextType);
|
|
}
|
|
return lines;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Phase I.7: formatted chat line with kind metadata. The
|
|
/// <see cref="ChatPanel"/> switches on <see cref="Kind"/> +
|
|
/// <see cref="CombatKind"/> to pick a rendering primitive
|
|
/// (<c>Text</c> vs <c>TextColored(rgba)</c>).
|
|
/// </summary>
|
|
/// <param name="LogTextType">
|
|
/// Campaign CH slice CH1: the retail wire <c>LogTextType</c> that keys
|
|
/// <see cref="RetailChatColorTable"/> — see <see cref="ChatEntry.LogTextType"/>.
|
|
/// </param>
|
|
public readonly record struct FormattedLine(
|
|
string Text,
|
|
ChatKind Kind,
|
|
CombatLineKind? CombatKind,
|
|
uint LogTextType);
|