feat(chat): CT-A3 — a chat line keeps the identity of who said it
Campaign CT slice A3. Still nothing visible; this removes the blocker.
ChatEntry carried Sender and SenderGuid the whole way through ChatLog, and
RecentLinesDetailed dropped both one step before the transcript. That is why a
speaker's name could never be coloured or clicked apart from the sentence
around it — by the time anything could draw it, there was no name left, only
prose.
FormattedLine now carries a Spans sidecar: the line split into stretches, one
of which may hold a retail text tag. Text remains the VISIBLE line with markup
consumed, so wrapping, selection, hit-testing and the caret are untouched, and
Spans is null for the ordinary single-colour line, which is most of them.
FormatEntryTagged shares its format strings with FormatEntry through a sender
decorator rather than duplicating retail's wording. Two copies would be two
things to keep in step, and these two renderings MUST show the same characters:
the transcript selects against the flat text, so any drift would mean clicking
one character and selecting another.
Tagging is gated on AC1's player id range (0x50000001..0x6FFFFFFF), read off
the guard in Handle_Communication__HearSpeech @0x005712A0 — so monsters and
NPCs never become clickable, which a "has a name" test would get wrong.
One real bug came out of a defensive test rather than a report. A sender name
containing '<' re-parsed as a marker and SWALLOWED characters from the visible
line ("Od<d says" rendered as "Od says"). The markup has no escape mechanism,
and retail's has none either because AC name validation makes the case
unreachable there — but sender names are server data. Such a name is now simply
not tagged, so the line renders plain like any other untaggable sender instead
of rendering corrupted.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
2621fbf4a7
commit
44fb74f8a6
2 changed files with 209 additions and 9 deletions
|
|
@ -259,7 +259,66 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback
|
|||
/// 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
|
||||
public static string FormatEntry(ChatEntry entry)
|
||||
=> FormatEntry(entry, static sender => sender);
|
||||
|
||||
/// <summary>
|
||||
/// The lowest and highest object ids retail treats as a player, and
|
||||
/// therefore the only senders it makes clickable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// AC1's dynamic/player id range, read off the guard in
|
||||
/// <c>Handle_Communication__HearSpeech @0x005712A0</c>: 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.
|
||||
/// </remarks>
|
||||
private const uint FirstPlayerObjectId = 0x50000001u;
|
||||
private const uint LastPlayerObjectId = 0x6FFFFFFFu;
|
||||
|
||||
/// <summary>
|
||||
/// Formats an entry with retail's tag markup around the sender's name,
|
||||
/// when that sender is a player.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Shares its format strings with <see cref="FormatEntry(ChatEntry)"/>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public static string FormatEntryTagged(ChatEntry entry)
|
||||
=> ShouldTagSender(entry)
|
||||
? FormatEntry(
|
||||
entry,
|
||||
sender =>
|
||||
$"<Tell:IIDString:{entry.SenderGuid}:{sender}>{sender}<\\Tell>")
|
||||
: FormatEntry(entry);
|
||||
|
||||
/// <summary>Whether this entry's sender is a clickable player.</summary>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// <c>Od<d</c> 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.
|
||||
/// </remarks>
|
||||
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<string, string> 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<ChatTextSpan>? 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 <c>LogTextType</c> that keys
|
||||
/// <see cref="RetailChatColorTable"/> — see <see cref="ChatEntry.LogTextType"/>.
|
||||
/// </param>
|
||||
/// <param name="Spans">
|
||||
/// Campaign CT slice A3: the line split into stretches, where a stretch may
|
||||
/// carry a retail text tag (a clickable speaker name). <see langword="null"/>
|
||||
/// for the ordinary single-colour line, which is most of them.
|
||||
/// <b>Invariant:</b> concatenating the span text reproduces
|
||||
/// <paramref name="Text"/> exactly — the transcript wraps, selects and
|
||||
/// hit-tests against that flat string.
|
||||
/// </param>
|
||||
public readonly record struct FormattedLine(
|
||||
string Text,
|
||||
ChatKind Kind,
|
||||
CombatLineKind? CombatKind,
|
||||
uint LogTextType);
|
||||
uint LogTextType,
|
||||
IReadOnlyList<ChatTextSpan>? Spans = null);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
using AcDream.Core.Chat;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CT slice A3: a chat line keeps the identity of who said it, all
|
||||
/// the way to the renderer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>ChatEntry</c> carried <c>Sender</c> and <c>SenderGuid</c> the whole way
|
||||
/// through <c>ChatLog</c>, and <c>RecentLinesDetailed</c> 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.
|
||||
/// </remarks>
|
||||
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<ChatTextSpan> 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<d", "hi", PlayerGuid);
|
||||
|
||||
string visible = string.Concat(
|
||||
ChatTagMarkup.Parse(ChatVM.FormatEntryTagged(entry)).Select(s => s.Text));
|
||||
|
||||
Assert.Equal(ChatVM.FormatEntry(entry), visible);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue