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