acdream/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs

288 lines
12 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
{
/// <summary>Default number of tail entries rendered.</summary>
public const int DefaultDisplayLimit = 20;
private readonly ChatLog _log;
private readonly int _displayLimit;
/// <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 { get; private set; }
/// <summary>
/// Target of the most recent OUTGOING Tell (the player's own
/// <c>/tell &lt;name&gt; …</c>). Drives the <c>/retell &lt;msg&gt;</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 { get; private set; }
/// <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>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 &gt;= 1. Defaults to
/// <see cref="DefaultDisplayLimit"/>.
/// </param>
public ChatVM(ChatLog log, int displayLimit = DefaultDisplayLimit)
{
_log = log ?? throw new ArgumentNullException(nameof(log));
if (displayLimit < 1)
throw new ArgumentOutOfRangeException(nameof(displayLimit), displayLimit, "must be >= 1");
_displayLimit = displayLimit;
_log.EntryAppended += OnEntryAppended;
}
private void OnEntryAppended(ChatEntry entry)
{
// Tell-tracking discriminator (SenderGuid):
// != 0 = real incoming whisper; capture sender for /reply.
// == 0 = our own outgoing echo via OnSelfSent; capture the
// target (Sender field) for /retell.
if (entry.Kind != ChatKind.Tell) return;
if (string.IsNullOrEmpty(entry.Sender)) return;
if (entry.SenderGuid != 0)
LastIncomingTellSender = entry.Sender;
else
LastOutgoingTellTarget = entry.Sender;
}
/// <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>
public void ShowSystemMessage(string text) => _log.OnSystemMessage(text, chatType: 0);
/// <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()
{
LastIncomingTellSender = null;
LastOutgoingTellTarget = null;
}
/// <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>();
var lines = new string[count];
for (int i = 0; i < count; i++)
{
lines[i] = FormatEntry(snap[start + i]);
}
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}\"",
ChatKind.System => $"[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"/>).
/// </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>();
var lines = new FormattedLine[count];
for (int i = 0; i < count; i++)
{
var entry = snap[start + i];
lines[i] = new FormattedLine(
Text: FormatEntry(entry),
Kind: entry.Kind,
CombatKind: entry.CombatKind);
}
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>
public readonly record struct FormattedLine(
string Text,
ChatKind Kind,
CombatLineKind? CombatKind);