diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index 3aca8999..5c17d437 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -259,7 +259,66 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback /// 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) => entry.Kind switch + 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 @@ -268,10 +327,10 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback // collapse to the singular "You say" verb here. ChatKind.LocalSpeech => IsOwnSpeaker(entry.Sender) ? $"You say, \"{entry.Text}\"" - : $"{entry.Sender} says, \"{entry.Text}\"", + : $"{decorateSender(entry.Sender)} says, \"{entry.Text}\"", ChatKind.RangedSpeech => IsOwnSpeaker(entry.Sender) ? $"You shout, \"{entry.Text}\"" - : $"{entry.Sender} shouts, \"{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 @@ -283,12 +342,12 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback // empty so the formatter substitutes here). ChatKind.Channel => IsOwnSpeaker(entry.Sender) ? $"[{ChannelLabel(entry)}] You say, \"{entry.Text}\"" - : $"[{ChannelLabel(entry)}] {entry.Sender} says, \"{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 - ? $"{entry.Sender} tells you, \"{entry.Text}\"" + ? $"{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 @@ -358,14 +417,39 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback for (int i = 0; i < count; i++) { var entry = snap[start + i]; - string text = FormatEntry(entry); + + // 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. + string markup = FormatEntryTagged(entry); + IReadOnlyList? spans = ShouldTagSender(entry) + ? ChatTagMarkup.Parse(markup) + : null; + string text = spans is null + ? markup + : string.Concat(spans.Select(span => span.Text)); + if (timestamps) - text = ChatLog.FormatTimestampPrefix(entry.Received) + text; + { + string prefix = ChatLog.FormatTimestampPrefix(entry.Received); + text = prefix + text; + // The prefix is its own untagged stretch in front, so the + // spans keep lining up with the visible text. + if (spans is not null) + spans = new[] { new ChatTextSpan(prefix, null) } + .Concat(spans) + .ToArray(); + } + lines[i] = new FormattedLine( Text: text, Kind: entry.Kind, CombatKind: entry.CombatKind, - LogTextType: entry.LogTextType); + LogTextType: entry.LogTextType, + Spans: spans); } return lines; } @@ -381,8 +465,17 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback /// 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); + uint LogTextType, + IReadOnlyList? Spans = null); diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVmTaggedSenderTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVmTaggedSenderTests.cs new file mode 100644 index 00000000..7b171a47 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVmTaggedSenderTests.cs @@ -0,0 +1,107 @@ +using AcDream.Core.Chat; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.UI.Abstractions.Tests.Panels.Chat; + +/// +/// Campaign CT slice A3: a chat line keeps the identity of who said it, all +/// the way to the renderer. +/// +/// +/// ChatEntry carried Sender and SenderGuid the whole way +/// through ChatLog, and RecentLinesDetailed then dropped both one +/// step before the transcript — which is why a speaker's name could never be +/// coloured or clicked separately from the sentence around it. +/// +public sealed class ChatVmTaggedSenderTests +{ + private const uint PlayerGuid = 0x5000000Au; + + private static ChatEntry Speech( + string sender, string text, uint senderGuid, ChatKind kind = ChatKind.LocalSpeech) + => new(kind, sender, text, senderGuid, 0u); + + [Fact] + public void APlayersNameBecomesItsOwnSpan() + { + string markup = ChatVM.FormatEntryTagged(Speech("Dww", "hello", PlayerGuid)); + IReadOnlyList spans = ChatTagMarkup.Parse(markup); + + Assert.Equal("Dww", spans[0].Text); + Assert.NotNull(spans[0].Tag); + Assert.True(spans[0].Tag!.Value.TryGetIidString(out uint id, out string name)); + Assert.Equal(PlayerGuid, id); + Assert.Equal("Dww", name); + } + + [Fact] + public void TheTaggedLineShowsExactlyTheSameCharactersAsThePlainOne() + { + // The whole design rests on this: the transcript wraps, selects and + // hit-tests against the flat text, so markup must be invisible in the + // result. If these ever diverge, clicks land on the wrong characters. + ChatEntry entry = Speech("Dww", "hello", PlayerGuid); + + string plain = ChatVM.FormatEntry(entry); + string visible = string.Concat( + ChatTagMarkup.Parse(ChatVM.FormatEntryTagged(entry)).Select(s => s.Text)); + + Assert.Equal(plain, visible); + Assert.Equal("Dww says, \"hello\"", visible); + } + + [Theory] + [InlineData(ChatKind.LocalSpeech)] + [InlineData(ChatKind.RangedSpeech)] + [InlineData(ChatKind.Channel)] + [InlineData(ChatKind.Tell)] + public void EveryKindThatNamesASpeakerTagsIt(ChatKind kind) + => Assert.True(ChatVM.ShouldTagSender(Speech("Dww", "hi", PlayerGuid, kind))); + + [Fact] + public void NonPlayerSendersAreNeverTagged() + { + // Retail gates on AC1's player id range, so a monster or NPC speaking + // never becomes clickable. A "has a name" test would tag them. + Assert.False(ChatVM.ShouldTagSender(Speech("A Drudge", "hi", 0x80000001u))); + Assert.False(ChatVM.ShouldTagSender(Speech("A Drudge", "hi", 0x4FFFFFFFu))); + + // Boundaries of the range itself. + Assert.True(ChatVM.ShouldTagSender(Speech("P", "hi", 0x50000001u))); + Assert.True(ChatVM.ShouldTagSender(Speech("P", "hi", 0x6FFFFFFFu))); + Assert.False(ChatVM.ShouldTagSender(Speech("P", "hi", 0x70000000u))); + } + + [Fact] + public void OurOwnLinesAndUnnamedSendersAreNotTagged() + { + // No guid at all (our own echo), and the substituted "You". + Assert.False(ChatVM.ShouldTagSender(Speech("Dww", "hi", 0u))); + Assert.False(ChatVM.ShouldTagSender(Speech("You", "hi", PlayerGuid))); + Assert.False(ChatVM.ShouldTagSender(Speech(string.Empty, "hi", PlayerGuid))); + } + + [Fact] + public void AnUntaggedLineIsFormattedExactlyAsBefore() + { + // The overwhelming majority of lines take this path; it must be + // byte-identical to the pre-CT formatting. + ChatEntry system = new(ChatKind.System, string.Empty, "Welcome.", 0u, 0u); + + Assert.Equal(ChatVM.FormatEntry(system), ChatVM.FormatEntryTagged(system)); + Assert.Equal("Welcome.", ChatVM.FormatEntryTagged(system)); + } + + [Fact] + public void ANameContainingMarkupCharactersStillRoundTrips() + { + // Defensive: a name is server data. Whatever it contains, the visible + // line must still equal the plain formatting, or selection desyncs. + ChatEntry entry = Speech("Od s.Text)); + + Assert.Equal(ChatVM.FormatEntry(entry), visible); + } +}