diff --git a/docs/plugin-api.md b/docs/plugin-api.md index c3611c21b..2cf8fad03 100644 --- a/docs/plugin-api.md +++ b/docs/plugin-api.md @@ -124,6 +124,58 @@ writes in one of the client's own text classes, so a plugin's own output can use the colour the class carries. `Submit(text)` still runs the full chat pipeline, commands included. +A line a plugin posts may carry a coordinate link. The tag's caption is shown +in the link colour, and clicking it raises `LinkClicked`: + +```csharp +host.Automation.Chat.PostSystemMessage( + @"Next waypoint: the Holtburg bridge<\COORD>"); +``` + +The only tag read is a well-formed ``, closed by +`<\COORD>`. Any other angle-bracket text ("Press ") is shown as +written. + +### Chat links + +`LinkClicked` is raised when the player clicks a coordinate link in the main +chat window. Only a line a plugin posted carries one: its `COORD` tags, and +every coordinate pair `PluginChatCoordinateParser` reads in its plain text +("meet at 42.1N 33.5E"). A pair is a number and a compass letter twice, +neither inside a longer word, so "10n 20east" is not one. + +What players say, tell and post on channels, and the server's own lines +("Your corpse is located at (42.1N, 33.5E).") are shown exactly as they +arrived and hold no coordinate link, even when a player types a `COORD` tag. +A plugin that wants the coordinates in those lines reads them from `Received` +with `PluginChatCoordinateParser`. `PluginChatCoordinateLinkRouter` hands the +coordinates of a clicked link to a callback: + +```csharp +var router = new PluginChatCoordinateLinkRouter( + host.Automation.Chat, + coordinate => SetWaypoint(coordinate.NorthSouth, coordinate.EastWest)); +``` + +Clicking a player's name still starts a tell and raises nothing. The floating +chat windows show no links. + +**Escape text you did not write.** A plugin's line is trusted as the plugin's +own, so a `COORD` tag in it is a working link. A plugin that repeats a +player's words (echoing a tell, relaying a channel) would turn a tag that +player typed into a link showing one place and reporting another. Pass such +text through `PluginChatMarkup.Escape` first; it puts a backslash before every +`<`, the chat drops that backslash, and the text shows exactly as the player +wrote it with no tag read: + +```csharp +host.Automation.Chat.PostSystemMessage( + $"{PluginChatMarkup.Escape(message.Sender)} told you: {PluginChatMarkup.Escape(message.Text)}"); +``` + +A coordinate pair the escaped text shows in plain words is still a link, to +exactly the place it shows. + ### Intercepting what the player types ```csharp diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 99d51404c..d9c016db3 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -632,7 +632,10 @@ AcDream.Core.Quests.ContractCatalog questCatalog() () => late.Session.Commands, d.Communication.ChatWindows, d.Communication.ChatEntryOwner, - layoutStore), + layoutStore, + d.Automation is { } automation + ? automation.RaiseChatLinkClicked + : null), Radar: new RadarRuntimeBindings( late.Radar.Snapshot, d.Actions.Selection, diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 7c5f326b9..d63287010 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -598,7 +598,7 @@ internal bool TryHandleTagClick(UiText.Pos position) { ChatLinkClicked?.Invoke(new PluginChatLinkClicked( PluginChatLinkKind.Coordinate, - $"{northSouth:0.###}{(northSouth < 0 ? 'S' : 'N')}, {eastWest:0.###}{(eastWest < 0 ? 'W' : 'E')}", + ChatCoordinateLinks.Describe(eastWest, northSouth), new PluginChatCoordinate(eastWest, northSouth))); return true; } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index fc0372945..675181170 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -52,7 +52,8 @@ public sealed record ChatRuntimeBindings( Func CommandBus, ChatWindowState Windows, AcDream.Runtime.Chat.RuntimeChatEntryOwner Entry, - SettingsStore? Store = null); + SettingsStore? Store = null, + Action? LinkClicked = null); public sealed record RadarRuntimeBindings( Func Snapshot, @@ -1618,6 +1619,7 @@ private void MountChat() controller.Transcript.Keyboard = Host.Keyboard; controller.Input.Keyboard = Host.Keyboard; + controller.ChatLinkClicked = _bindings.Chat.LinkClicked; UiElement root = controller.Root; RetailWindowHandle handle = RetailWindowFrame.Mount( Host.Root, diff --git a/src/AcDream.Core/Chat/ChatCoordinateLinks.cs b/src/AcDream.Core/Chat/ChatCoordinateLinks.cs new file mode 100644 index 000000000..878fbd01a --- /dev/null +++ b/src/AcDream.Core/Chat/ChatCoordinateLinks.cs @@ -0,0 +1,81 @@ +using System.Globalization; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Chat; + +/// +/// Turns the coordinate pairs written in a plugin's chat line ("meet at +/// 42.1N 33.5E") into the same COORD:World links the line's own markup +/// makes, so a click on one reaches plugins as +/// . Only a plugin's own line is +/// linked; what players and the server write is shown as it arrived. +/// +public static class ChatCoordinateLinks +{ + /// + /// Splits every plain body span at the coordinate pairs it holds, each pair + /// becoming a span of its own tagged as a coordinate. Tagged spans and the + /// timestamp are kept as they are. A pair is found exactly when + /// reads it. + /// + /// The new spans, which rejoin to the same text; null when the + /// line holds no pair. + public static IReadOnlyList? Link(IReadOnlyList spans) + { + ArgumentNullException.ThrowIfNull(spans); + List? result = null; + for (int i = 0; i < spans.Count; i++) + { + ChatTextSpan span = spans[i]; + if (span.Tag is not null || span.Role != ChatSpanRole.Body) + { + result?.Add(span); + continue; + } + + int runStart = 0; + foreach ((int index, int length, PluginChatCoordinate coordinate) + in PluginChatCoordinateParser.Find(span.Text)) + { + if (result is null) + { + result = new List(spans.Count + 2); + for (int j = 0; j < i; j++) + result.Add(spans[j]); + } + if (index > runStart) + result.Add(span with { Text = span.Text[runStart..index] }); + result.Add(span with + { + Text = span.Text.Substring(index, length), + Tag = TagFor(coordinate), + }); + runStart = index + length; + } + + if (result is null) + continue; + if (runStart < span.Text.Length) + result.Add(span with { Text = span.Text[runStart..] }); + } + return result; + } + + /// The link a coordinate is carried by, in the chat markup's own form. + public static ChatTextTag TagFor(PluginChatCoordinate coordinate) => + new( + "COORD", + "World", + $"{Axis(coordinate.NorthSouth, 'N', 'S')}, {Axis(coordinate.EastWest, 'E', 'W')}"); + + /// + /// How a clicked coordinate is named to plugins: "42.1N, 33.5W", to at most + /// three decimals, written the same in every locale. + /// + public static string Describe(double eastWest, double northSouth) => + $"{Axis(northSouth, 'N', 'S', "0.###")}, {Axis(eastWest, 'E', 'W', "0.###")}"; + + private static string Axis(double value, char positive, char negative, string format = "R") => + Math.Abs(value).ToString(format, CultureInfo.InvariantCulture) + + (value < 0d ? negative : positive); +} diff --git a/src/AcDream.Core/Chat/ChatLineWording.cs b/src/AcDream.Core/Chat/ChatLineWording.cs index e52338ba2..8bdf77c20 100644 --- a/src/AcDream.Core/Chat/ChatLineWording.cs +++ b/src/AcDream.Core/Chat/ChatLineWording.cs @@ -9,14 +9,20 @@ namespace AcDream.Core.Chat; /// public static class ChatLineWording { - /// The line as plain text, with no clickable sender marked up. + /// + /// The line as plain text, with no clickable sender marked up; a plugin's + /// line is given as it is shown, its link tags and escapes removed. + /// public static string Format(ChatEntry entry) - => Format(entry, static sender => sender); + => entry.AllowsMarkup + ? ChatTagMarkup.ToPlainText(Format(entry, static sender => sender)) + : Format(entry, static sender => sender); /// /// The line with another player's name wrapped so a front end that supports - /// it can make the name clickable. Identical to - /// for every line that does not name another player. + /// it can make the name clickable, and a plugin's line as the plugin wrote + /// it, its COORD tags and escapes kept. Identical to + /// for every other line. /// public static string FormatTagged(ChatEntry entry) => ShouldTagSender(entry) @@ -24,7 +30,7 @@ public static string FormatTagged(ChatEntry entry) entry, sender => $"{sender}<\\Tell>") - : Format(entry); + : Format(entry, static sender => sender); /// Whether this line names another player we could click to tell. // A channel line names its speaker without an object id (allegiance, diff --git a/src/AcDream.Core/Chat/ChatLog.cs b/src/AcDream.Core/Chat/ChatLog.cs index a7de185e7..ee288e03a 100644 --- a/src/AcDream.Core/Chat/ChatLog.cs +++ b/src/AcDream.Core/Chat/ChatLog.cs @@ -158,6 +158,14 @@ public void OnTellReceived( } public void OnSystemMessage(string text, uint chatType) + => OnSystemMessage(text, chatType, allowsMarkup: false); + + /// + /// True for a line the client itself wrote on a plugin's behalf. Only its + /// <COORD:World:pair> tags are read, as links; any other + /// angle-bracket text is shown as written. Server text is never read. + /// + public void OnSystemMessage(string text, uint chatType, bool allowsMarkup) { // Identical text can describe distinct events and must retain each occurrence. Append(new ChatEntry( @@ -168,6 +176,7 @@ public void OnSystemMessage(string text, uint chatType) ChannelId: chatType) { LogTextType = chatType, + AllowsMarkup = allowsMarkup, }); } @@ -315,6 +324,12 @@ public readonly record struct ChatEntry( public uint LogTextType { get; init; } = 0x00u; + /// + /// Whether may carry chat markup (a link such as + /// <COORD:World:42.1N, 33.5E>…). Only a line a plugin wrote does. + /// + public bool AllowsMarkup { get; init; } + /// /// Who a received tell was addressed to; 0 on every other line, and when /// the listener is not known. diff --git a/src/AcDream.Core/Chat/ChatTagMarkup.cs b/src/AcDream.Core/Chat/ChatTagMarkup.cs index a1307879a..8fbae132b 100644 --- a/src/AcDream.Core/Chat/ChatTagMarkup.cs +++ b/src/AcDream.Core/Chat/ChatTagMarkup.cs @@ -77,6 +77,120 @@ public readonly record struct ChatTextSpan( public static class ChatTagMarkup { + /// + /// Whether a plugin's line holds at least one link tag + /// reads: a well-formed + /// <COORD:World:42.1N, 33.5E>. Any other angle-bracket text, + /// such as "Press <Ctrl:F>", and an escaped tag are not tags. + /// + public static bool ContainsTag(string? text) => + !string.IsNullOrEmpty(text) + && text.IndexOf('<') >= 0 + && ParseLinks(text).Any(static span => span.Tag is not null); + + /// + /// Reads the link tags in a line a plugin posted. Only a well-formed + /// <COORD:World:pair> opens a link, and only its own + /// <\COORD> closes it; every other angle-bracket text is shown + /// as written. A link left open runs to the end of the line. A backslash + /// directly before < is dropped and that < is shown as + /// written, never read as a tag, which is how + /// makes + /// text show literally. + /// + public static IReadOnlyList ParseLinks(string? text) + { + if (string.IsNullOrEmpty(text)) + return Array.Empty(); + + var spans = new List(); + var run = new System.Text.StringBuilder(text.Length); + ChatTextTag? open = null; + int i = 0; + + while (i < text.Length) + { + char c = text[i]; + if (c == '\\' && i + 1 < text.Length && text[i + 1] == '<') + { + run.Append('<'); // escaped: shown, never a tag + i += 2; + continue; + } + + if (c == '<') + { + int close = text.IndexOf('>', i + 1); + if (close >= 0) + { + string inner = text[(i + 1)..close]; + bool markup = true; + ChatTextTag? next = null; + if (TryParseLinkTag(inner) is { } start) + next = start; + else if (open is not { } current || !IsEndTag(inner, current)) + markup = false; // not markup: shown as written + + if (markup) + { + if (run.Length > 0) + spans.Add(new ChatTextSpan(run.ToString(), open)); + run.Clear(); + open = next; + i = close + 1; + continue; + } + } + } + + run.Append(c); + i++; + } + + if (run.Length > 0) + spans.Add(new ChatTextSpan(run.ToString(), open)); + + return spans; + } + + /// A plugin's line with its link tags removed: what shows. + public static string ToPlainText(string? text) => + string.Concat(ParseLinks(text).Select(static span => span.Text)); + + /// + /// The spans with every coordinate tag dropped and its caption kept as + /// plain text. What a player or the server writes may hold a coordinate + /// tag; it is shown in the line's colour and is not a link. + /// + public static IReadOnlyList WithoutCoordinateTags(IReadOnlyList spans) + { + ArgumentNullException.ThrowIfNull(spans); + ChatTextSpan[]? result = null; + for (int i = 0; i < spans.Count; i++) + { + if (spans[i].Tag is not { } tag || !IsCoordinateType(tag.Type)) + continue; + result ??= spans.ToArray(); + result[i] = spans[i] with { Tag = null }; + } + return result ?? spans; + } + + private static bool IsCoordinateType(string type) => + string.Equals(type, "COORD", StringComparison.OrdinalIgnoreCase); + + private static ChatTextTag? TryParseLinkTag(string inner) => + TryParseStartTag(inner) is { } tag + && IsCoordinateType(tag.Type) + && tag.TryGetCoordinate(out _, out _) + ? tag + : null; + + private static bool IsEndTag(string inner, ChatTextTag open) => + inner.Length == open.Type.Length + 1 + && inner[0] == '\\' + && string.Equals(inner[1..], open.Type, StringComparison.OrdinalIgnoreCase); + public static IReadOnlyList Parse(string? text) { if (string.IsNullOrEmpty(text)) diff --git a/src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj b/src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj index 28557b236..93bf65935 100644 --- a/src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj +++ b/src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj @@ -32,4 +32,10 @@ + + + + + \ No newline at end of file diff --git a/src/AcDream.Plugin.Abstractions/Automation.cs b/src/AcDream.Plugin.Abstractions/Automation.cs index 6833d4d91..337d86536 100644 --- a/src/AcDream.Plugin.Abstractions/Automation.cs +++ b/src/AcDream.Plugin.Abstractions/Automation.cs @@ -833,7 +833,11 @@ public readonly record struct PluginChatMessage( /// The whole line as the client's chat window prints it: the channel /// prefix, the speaker, the verb and the quoted words, with another /// player's name wrapped in the client's tell link - /// (<Tell:IIDString:id:Name>Name<\Tell>). It carries no + /// (<Tell:IIDString:id:Name>Name<\Tell>). The quoted + /// words are as the speaker wrote them, angle brackets included, so text + /// in them that looks like a tag is the speaker's, not the client's. A + /// line a plugin posted is given as the plugin wrote it, its + /// <COORD:World:…> tags kept. It carries no /// timestamp and no closing line break. Empty on a line the host did not /// word. A chat filter is offered the line before the language filter /// runs, so there this and are the words as they @@ -927,9 +931,15 @@ IReadOnlyList CaptureMessages(ulong afterSequence) => Array.Empty(); /// - /// Raised for every line the client takes delivery of, in order, on the - /// thread that raises . Unlike - /// , nothing is dropped between polls. + /// Raised when the player clicks a coordinate link in the main chat + /// window. Only a line a plugin posted (, + /// ) carries one: its + /// <COORD:World:42.1N, 33.5E>caption<\COORD> tags, and + /// every coordinate pair reads in + /// its plain text ("meet at 42.1N 33.5E"). What players say, tell and post + /// on channels, and the server's own lines, are shown as they arrived and + /// hold no coordinate link, even when they hold tag text. Clicking a + /// player's name starts a tell instead and raises nothing. /// event Action LinkClicked { @@ -937,7 +947,11 @@ event Action LinkClicked remove { } } - /// Raised for each delivered chat line. + /// + /// Raised for every line the client takes delivery of, in order, on the + /// thread that raises . Unlike + /// , nothing is dropped between polls. + /// event Action Received { add { } @@ -998,13 +1012,23 @@ IDisposable RegisterInputInterceptor( /// /// Writes a line into the client's own text, visible only to this - /// player. Nothing is sent to the server. + /// player. Nothing is sent to the server. A + /// <COORD:World:42.1N, 33.5E>caption<\COORD> tag in the + /// line shows its caption as a link that raises , + /// and so does a coordinate pair written in plain text. No other + /// angle-bracket text is a tag; it is shown as written, and a backslash + /// directly before a < is dropped. Escape any text the plugin did + /// not write itself (a player's words it repeats) with + /// : a tag in that text would + /// otherwise become a link the plugin vouches for. /// void PostSystemMessage(string text); /// /// Writes a line in one of the client's own text classes, so a plugin can - /// print in the colour that class carries. + /// print in the colour that class carries. Tags are read as in + /// , and text the plugin did not write + /// should be escaped the same way. /// void PostMessage(string text, int logTextType) => PostSystemMessage(text); diff --git a/src/AcDream.Plugin.Abstractions/PluginChatCoordinateWorkflow.cs b/src/AcDream.Plugin.Abstractions/PluginChatCoordinateWorkflow.cs index d844b95f9..09b8dee05 100644 --- a/src/AcDream.Plugin.Abstractions/PluginChatCoordinateWorkflow.cs +++ b/src/AcDream.Plugin.Abstractions/PluginChatCoordinateWorkflow.cs @@ -6,7 +6,10 @@ namespace AcDream.Plugin.Abstractions; /// Parses compass coordinate pairs from ordinary chat text. public static partial class PluginChatCoordinateParser { - [GeneratedRegex(@"(?\d+(?:\.\d+)?)\s*(?[NSEW])\s*(?:[,;/|]|\s+)\s*(?\d+(?:\.\d+)?)\s*(?[NSEW])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + // A number and a compass letter, twice. A number may not continue a word + // or another number and a letter may not start a word, so "10n 20east" + // and "v2.1N 3E" are not pairs. + [GeneratedRegex(@"(?\d+(?:\.\d+)?)\s*(?[NSEW])(?!\w)\s*(?:[,;/|]|\s+)\s*(?\d+(?:\.\d+)?)\s*(?[NSEW])(?!\w)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex PairPattern(); /// Finds every unambiguous coordinate pair in a message. @@ -15,11 +18,24 @@ public static partial class PluginChatCoordinateParser /// Each pair found, in the order it appears; empty for none. A pair is one /// north-or-south value and one east-or-west value, in either order, such /// as "42.1N 33.5E" or "33.5E, 42.1N". South and west come back negative. + /// Neither half may sit inside a longer word or number: "10n 20east" and + /// "v2.1N 3E" hold no pair. /// public static IReadOnlyList ParseAll(string? text) { if (string.IsNullOrWhiteSpace(text)) return Array.Empty(); var result = new List(); + foreach ((_, _, PluginChatCoordinate coordinate) in Find(text)) + result.Add(coordinate); + return result; + } + + /// + /// Every pair reads, with where it stands in the + /// text, so the client's chat links exactly the pairs a plugin would read. + /// + internal static IEnumerable<(int Index, int Length, PluginChatCoordinate Coordinate)> Find(string text) + { foreach (Match match in PairPattern().Matches(text)) { if (!double.TryParse(match.Groups["a"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double a) @@ -31,9 +47,8 @@ public static IReadOnlyList ParseAll(string? text) if (ad is not ('E' or 'W') || bd is not ('N' or 'S')) continue; (a, b) = (b, a); (ad, bd) = (bd, ad); } - result.Add(new PluginChatCoordinate(bd == 'W' ? -b : b, ad == 'S' ? -a : a)); + yield return (match.Index, match.Length, new PluginChatCoordinate(bd == 'W' ? -b : b, ad == 'S' ? -a : a)); } - return result; } /// Parses exactly one coordinate pair and rejects ambiguous text. diff --git a/src/AcDream.Plugin.Abstractions/PluginChatMarkup.cs b/src/AcDream.Plugin.Abstractions/PluginChatMarkup.cs new file mode 100644 index 000000000..d45f7ad49 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/PluginChatMarkup.cs @@ -0,0 +1,26 @@ +namespace AcDream.Plugin.Abstractions; + +/// +/// Helpers for the markup a plugin's own chat line may carry +/// (, ). +/// +public static class PluginChatMarkup +{ + /// + /// Escapes text so that it shows exactly as written in a line a plugin + /// posts, with no tag in it read as a link. Use it on every piece of text + /// the plugin did not write itself, such as a player's tell it repeats: + /// otherwise a <COORD:World:…> tag that player typed becomes + /// a working link under the plugin's name, showing one place and reporting + /// another. A coordinate pair the escaped text shows in plain words is + /// still a link to exactly the place it shows. + /// + /// The text to escape; null gives an empty string. + /// + /// The text with a backslash put before every <. The chat drops + /// that backslash when it shows the line, so the reader sees the original + /// text. + /// + public static string Escape(string? text) => + string.IsNullOrEmpty(text) ? string.Empty : text.Replace("<", "\\<", StringComparison.Ordinal); +} diff --git a/src/AcDream.Runtime/Chat/RuntimeChatFeed.cs b/src/AcDream.Runtime/Chat/RuntimeChatFeed.cs index eb62df50e..0eddcae6f 100644 --- a/src/AcDream.Runtime/Chat/RuntimeChatFeed.cs +++ b/src/AcDream.Runtime/Chat/RuntimeChatFeed.cs @@ -17,8 +17,10 @@ namespace AcDream.Runtime.Chat; /// The text class the line belongs to; what a chat window's filter is keyed on. /// /// -/// The line split at its clickable sender, or null when it has none. The spans -/// always rejoin to . +/// The line split at its links (a clickable sender; on a plugin's own line, +/// its coordinate tags and the coordinate pairs it writes), or null when it +/// has none. The spans always rejoin to +/// . /// public readonly record struct RuntimeChatLine( long Sequence, @@ -118,11 +120,36 @@ public RuntimeChatLine BuildLine(ChatEntry entry) private RuntimeChatLine BuildLine(ChatEntry entry, bool timestamps) { - bool tagged = ShouldTagSender(entry); string markup = FormatTagged(entry); - IReadOnlyList? spans = tagged - ? ChatTagMarkup.Parse(markup) - : null; + IReadOnlyList? spans; + if (entry.AllowsMarkup) + { + // A plugin's own line: its link tags and escapes are read, and a + // coordinate pair it writes in plain text is a link too. + IReadOnlyList read = ChatTagMarkup.ParseLinks(markup); + read = ChatCoordinateLinks.Link(read) ?? read; + if (read.Any(static span => span.Tag is not null)) + { + spans = read; + } + else + { + spans = null; + markup = string.Concat(read.Select(static span => span.Text)); + } + } + else if (ShouldTagSender(entry)) + { + // The speaker's name is a link that starts a tell. The words are + // the player's own and may hold tag text too; a coordinate tag + // among them is shown as plain text, never as a link a click + // would hand to plugins. + spans = ChatTagMarkup.WithoutCoordinateTags(ChatTagMarkup.Parse(markup)); + } + else + { + spans = null; + } string text = spans is null ? markup : string.Concat(spans.Select(span => span.Text)); @@ -156,13 +183,17 @@ private void OnEntryAppended(ChatEntry entry) // -- Wording ----------------------------------------------------------- - /// The line as plain text, with no clickable sender marked up. + /// + /// The line as plain text, with no clickable sender marked up; a plugin's + /// line as it is shown, its COORD tags and escapes removed. + /// public static string Format(ChatEntry entry) => ChatLineWording.Format(entry); /// /// The line with another player's name wrapped so a front end that supports - /// it can make the name clickable. Identical to for - /// every line that does not name another player. + /// it can make the name clickable, and a plugin's line as the plugin wrote + /// it, its COORD tags and escapes kept. Identical to + /// for every other line. /// public static string FormatTagged(ChatEntry entry) => ChatLineWording.FormatTagged(entry); diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs index 7865ca456..ed9f8e348 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs @@ -174,6 +174,18 @@ public void ReportLocalPlayerDeath(string deathMessage) } public void AddText(string text, RetailLogTextType type, uint windowId = 0) + => AddText(text, type, allowsMarkup: false); + + /// + /// Writes a line a plugin posted. Only its <COORD:World:pair> + /// tags are read, as links in the transcript; any other angle-bracket text + /// is shown as written. A status notice has no links, so there the tags + /// are removed and only their captions shown. + /// + public void AddPluginText(string text, RetailLogTextType type) + => AddText(text, type, allowsMarkup: true); + + private void AddText(string text, RetailLogTextType type, bool allowsMarkup) { ArgumentNullException.ThrowIfNull(text); @@ -181,6 +193,11 @@ public void AddText(string text, RetailLogTextType type, uint windowId = 0) if (type == RetailLogTextType.ClientLocal) { + // The status line shows text, not links: a plugin's tags would + // otherwise appear there as raw markup. + if (allowsMarkup) + text = ChatTagMarkup.ToPlainText(text); + // A status notice never enters the log, so it would otherwise // escape the filters entirely. Offer it under its own kind. if (Chat.Filters.ShouldSuppress(new PluginChatMessage( @@ -203,7 +220,7 @@ public void AddText(string text, RetailLogTextType type, uint windowId = 0) return; } - Chat.OnSystemMessage(text, (uint)type); + Chat.OnSystemMessage(text, (uint)type, allowsMarkup); } public void Dispose() diff --git a/src/AcDream.Runtime/Plugins/RuntimeAutomationSurface.cs b/src/AcDream.Runtime/Plugins/RuntimeAutomationSurface.cs index 9e36c619e..0f475e5fa 100644 --- a/src/AcDream.Runtime/Plugins/RuntimeAutomationSurface.cs +++ b/src/AcDream.Runtime/Plugins/RuntimeAutomationSurface.cs @@ -3119,7 +3119,7 @@ public void PostMessage(string text, int logTextType) RuntimeCommunicationState? communication; lock (_gate) communication = _communication; - communication?.AddText( + communication?.AddPluginText( text, RetailLogTextTypeCodec.FromPluginValue(logTextType)); } diff --git a/tests/AcDream.App.Tests/Plugins/RuntimeAutomationSurfacePluginApiTests.cs b/tests/AcDream.App.Tests/Plugins/RuntimeAutomationSurfacePluginApiTests.cs index 4ad54d6c2..c9da259d6 100644 --- a/tests/AcDream.App.Tests/Plugins/RuntimeAutomationSurfacePluginApiTests.cs +++ b/tests/AcDream.App.Tests/Plugins/RuntimeAutomationSurfacePluginApiTests.cs @@ -372,6 +372,23 @@ public void PostMessageUsesTheRequestedTextClass() Assert.Equal((uint)RetailLogTextType.Magic, entry.LogTextType); } + [Fact] + public void PostMessageMarksTheLineAsCarryingMarkupAndServerTextDoesNot() + { + using var runtime = GameRuntimeTestFactory.Create(); + using var surface = new RuntimeAutomationSurface(); + surface.Bind(runtime, runtime.CharacterOwner, runtime.ActionOwner.SpellCast); + + surface.Chat.PostMessage("here<\\COORD>", (int)RetailLogTextType.Magic); + surface.Chat.PostSystemMessage("there<\\COORD>"); + runtime.CommunicationOwner.AddText("server<\\COORD>", RetailLogTextType.Default); + + IReadOnlyList entries = runtime.CommunicationOwner.Chat.Snapshot(); + Assert.True(entries[0].AllowsMarkup); + Assert.True(entries[1].AllowsMarkup); + Assert.False(entries[2].AllowsMarkup); + } + [Fact] public void PostMessageRejectsTheStatusOnlyClientLocalClassAndFallsBackToDefault() { @@ -393,6 +410,32 @@ public void PostMessageRejectsTheStatusOnlyClientLocalClassAndFallsBackToDefault Assert.Equal(0, runtime.CommunicationOwner.SpewBox.Count); } + [Fact] + public void APluginsStatusLinePostShowsTheCaptionNotTheTag() + { + using var runtime = GameRuntimeTestFactory.Create(); + using var surface = new RuntimeAutomationSurface(); + surface.Bind(runtime, runtime.CharacterOwner, runtime.ActionOwner.SpellCast); + + // The surface sends a status-line class to the transcript, where the + // tag is a link; the status line itself, which has no links, shows + // only the caption. + surface.Chat.PostMessage( + "Go to the bridge<\\COORD> now.", + (int)RetailLogTextType.ClientLocal); + runtime.CommunicationOwner.AddPluginText( + "Go to the bridge<\\COORD> now.", + RetailLogTextType.ClientLocal); + + ChatEntry entry = Assert.Single(runtime.CommunicationOwner.Chat.Snapshot()); + Assert.True(entry.AllowsMarkup); + Assert.Equal("Go to the bridge now.", ChatLineWording.Format(entry)); + runtime.CommunicationOwner.SpewBox.Tick(0); + Assert.Equal( + "Go to the bridge now.", + Assert.Single(runtime.CommunicationOwner.SpewBox.Snapshot()).Text); + } + [Fact] public void PostMessageRejectsAnOutOfRangeTextClassAndFallsBackToDefault() { diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index b43931f27..9c2b6fac0 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -574,6 +574,96 @@ public void TranscriptLayout_IsReusedUntilContentOrWidthChanges() Assert.Equal(4, ctrl.TranscriptLayoutBuildCount); } + [Fact] + public void ClickingACoordinateAPluginWroteRaisesACoordinateLink() + { + var log = new ChatLog(); + var (rootInfo, layout, vm) = BuildTestTree(log); + var filters = new ChatWindowState(); + filters.SetFilter(ChatWindowState.MainWindowId, ulong.MaxValue); + var ctrl = ChatWindowController.Bind( + rootInfo, layout, vm, () => new CaptureBus(), filters, null, null, + NoTex, new RuntimeChatEntryOwner())!; + var clicked = new List(); + ctrl.ChatLinkClicked = clicked.Add; + + log.OnSystemMessage("Your corpse is at 42.1N, 33.5W.", chatType: 0u, allowsMarkup: true); + UiText.Line line = Assert.Single(ctrl.Transcript.LinesProvider()); + int column = line.Text.IndexOf("42.1N", StringComparison.Ordinal); + + Assert.False(ctrl.TryHandleTagClick(new UiText.Pos(0, 2))); + Assert.True(ctrl.TryHandleTagClick(new UiText.Pos(0, column + 3))); + + var link = Assert.Single(clicked); + Assert.Equal(AcDream.Plugin.Abstractions.PluginChatLinkKind.Coordinate, link.Kind); + Assert.Equal(new AcDream.Plugin.Abstractions.PluginChatCoordinate(-33.5, 42.1), link.Coordinate); + Assert.Equal("42.1N, 33.5W", link.DisplayText); + } + + [Fact] + public void ACoordinateLinkIsNamedTheSameUnderACommaDecimalLocale() + { + var previous = System.Globalization.CultureInfo.CurrentCulture; + System.Globalization.CultureInfo.CurrentCulture = new System.Globalization.CultureInfo("sv-SE"); + try + { + var log = new ChatLog(); + var (rootInfo, layout, vm) = BuildTestTree(log); + var filters = new ChatWindowState(); + filters.SetFilter(ChatWindowState.MainWindowId, ulong.MaxValue); + var ctrl = ChatWindowController.Bind( + rootInfo, layout, vm, () => new CaptureBus(), filters, null, null, + NoTex, new RuntimeChatEntryOwner())!; + var clicked = new List(); + ctrl.ChatLinkClicked = clicked.Add; + + log.OnSystemMessage( + "Go to the bridge<\\COORD>.", + chatType: 0u, + allowsMarkup: true); + UiText.Line line = Assert.Single(ctrl.Transcript.LinesProvider()); + int column = line.Text.IndexOf("bridge", StringComparison.Ordinal); + + Assert.True(ctrl.TryHandleTagClick(new UiText.Pos(0, column))); + + var link = Assert.Single(clicked); + Assert.Equal("42.125S, 33.5E", link.DisplayText); + Assert.Equal(new AcDream.Plugin.Abstractions.PluginChatCoordinate(33.5, -42.125), link.Coordinate); + } + finally + { + System.Globalization.CultureInfo.CurrentCulture = previous; + } + } + + [Fact] + public void ClickingACoordinateOrCoordinateTagAPlayerTypedRaisesNothing() + { + var log = new ChatLog(); + var (rootInfo, layout, vm) = BuildTestTree(log); + var filters = new ChatWindowState(); + filters.SetFilter(ChatWindowState.MainWindowId, ulong.MaxValue); + var ctrl = ChatWindowController.Bind( + rootInfo, layout, vm, () => new CaptureBus(), filters, null, null, + NoTex, new RuntimeChatEntryOwner())!; + var clicked = new List(); + ctrl.ChatLinkClicked = clicked.Add; + + log.OnLocalSpeech( + "Bob", "42.1N 33.5E<\\COORD>", 0x5000000Au, + isRanged: false, logTextType: 2u); + log.OnSystemMessage("Your corpse is at 42.1N, 33.5W.", chatType: 0u); + IReadOnlyList lines = ctrl.Transcript.LinesProvider(); + Assert.Equal("Bob says, \"42.1N 33.5E\"", lines[0].Text); + int typed = lines[0].Text.IndexOf("42.1N", StringComparison.Ordinal); + int server = lines[1].Text.IndexOf("42.1N", StringComparison.Ordinal); + + Assert.False(ctrl.TryHandleTagClick(new UiText.Pos(0, typed + 3))); + Assert.False(ctrl.TryHandleTagClick(new UiText.Pos(1, server + 3))); + + Assert.Empty(clicked); + } + [Fact] public void TranscriptLines_OutOfRangeLogTextType_CarriesPreviousLinesColor() { diff --git a/tests/AcDream.Core.Tests/Chat/ChatCoordinateLinksTests.cs b/tests/AcDream.Core.Tests/Chat/ChatCoordinateLinksTests.cs new file mode 100644 index 000000000..da1f7a135 --- /dev/null +++ b/tests/AcDream.Core.Tests/Chat/ChatCoordinateLinksTests.cs @@ -0,0 +1,118 @@ +using AcDream.Core.Chat; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Tests.Chat; + +public sealed class ChatCoordinateLinksTests +{ + private static IReadOnlyList Plain(string text) => + new[] { new ChatTextSpan(text, null) }; + + [Fact] + public void APairInPlainTextBecomesACoordinateLink() + { + IReadOnlyList spans = ChatCoordinateLinks.Link( + Plain("meet at 42.1N 33.5E now"))!; + + Assert.Equal(new[] { "meet at ", "42.1N 33.5E", " now" }, spans.Select(s => s.Text)); + Assert.Null(spans[0].Tag); + Assert.Null(spans[2].Tag); + ChatTextTag tag = spans[1].Tag!.Value; + Assert.True(tag.TryGetCoordinate(out double eastWest, out double northSouth)); + Assert.Equal(33.5, eastWest); + Assert.Equal(42.1, northSouth); + } + + [Fact] + public void SouthAndWestAndEitherOrderAreRead() + { + IReadOnlyList spans = ChatCoordinateLinks.Link( + Plain("go 12.5W, 3.25S"))!; + + ChatTextTag tag = Assert.Single(spans, s => s.Tag is not null).Tag!.Value; + Assert.True(tag.TryGetCoordinate(out double eastWest, out double northSouth)); + Assert.Equal(-12.5, eastWest); + Assert.Equal(-3.25, northSouth); + } + + [Fact] + public void EveryPairOnTheLineIsLinked() + { + IReadOnlyList spans = ChatCoordinateLinks.Link( + Plain("from 1N 2E to 3S 4W"))!; + + Assert.Equal(2, spans.Count(s => s.Tag is not null)); + Assert.Equal("from 1N 2E to 3S 4W", string.Concat(spans.Select(s => s.Text))); + } + + [Fact] + public void ALineWithNoPairIsLeftAlone() + { + Assert.Null(ChatCoordinateLinks.Link(Plain("hello there"))); + Assert.Null(ChatCoordinateLinks.Link(Plain("42N 33N is not a pair"))); + } + + [Theory] + [InlineData("10n 20east")] + [InlineData("10north 20e")] + [InlineData("v2.1N 3E")] + [InlineData("x10n 20e")] + public void APairInsideAWordIsNotLinked(string text) + { + Assert.Null(ChatCoordinateLinks.Link(Plain(text))); + } + + [Fact] + public void AnAlreadyTaggedSpanAndTheTimestampAreNotSearched() + { + var tell = new ChatTextTag("Tell", "IIDString", "1:Bob 1N 2E"); + IReadOnlyList spans = new[] + { + new ChatTextSpan("[10:00] 1N 2E ", null, ChatSpanRole.Timestamp), + new ChatTextSpan("Bob 1N 2E", tell), + new ChatTextSpan(" says, \"hi\"", null), + }; + + Assert.Null(ChatCoordinateLinks.Link(spans)); + } + + [Fact] + public void ATaggedSpanIsKeptAndThePlainSpanAfterItIsLinked() + { + var tell = new ChatTextTag("Tell", "IIDString", "1:Bob"); + IReadOnlyList spans = ChatCoordinateLinks.Link(new[] + { + new ChatTextSpan("Bob", tell), + new ChatTextSpan(" says, \"at 5N 6E\"", null), + })!; + + Assert.Equal(new[] { "Bob", " says, \"at ", "5N 6E", "\"" }, spans.Select(s => s.Text)); + Assert.Equal(tell, spans[0].Tag); + Assert.Equal("COORD", spans[2].Tag!.Value.Type); + } + + [Theory] + [InlineData("42.1N 33.5E")] + [InlineData("42.1n, 33.5e")] + [InlineData("33.5E; 42.1N")] + [InlineData("0.5S/101.9W")] + [InlineData("a 1N 2E b 3N 4W c")] + [InlineData("no pair 1N here")] + [InlineData("1E 2W")] + [InlineData("10n 20east and (42.1N, 33.5E).")] + public void ItLinksExactlyThePairsThePluginParserReads(string text) + { + IReadOnlyList parsed = PluginChatCoordinateParser.ParseAll(text); + IReadOnlyList spans = ChatCoordinateLinks.Link(Plain(text)) ?? Plain(text); + + PluginChatCoordinate[] linked = spans + .Where(s => s.Tag is not null) + .Select(s => + { + Assert.True(s.Tag!.Value.TryGetCoordinate(out double ew, out double ns)); + return new PluginChatCoordinate(ew, ns); + }) + .ToArray(); + Assert.Equal(parsed, linked); + } +} diff --git a/tests/AcDream.Core.Tests/Chat/ChatTagMarkupTests.cs b/tests/AcDream.Core.Tests/Chat/ChatTagMarkupTests.cs index 6b2320a41..40637c5d7 100644 --- a/tests/AcDream.Core.Tests/Chat/ChatTagMarkupTests.cs +++ b/tests/AcDream.Core.Tests/Chat/ChatTagMarkupTests.cs @@ -98,4 +98,67 @@ public void ANameContainingAColonSurvivesIntact() Assert.Equal(5u, id); Assert.Equal("Odd:Name", name); } + + [Theory] + [InlineData("Press to follow", false)] + [InlineData("", false)] + [InlineData(@"Bob<\Tell>", false)] + [InlineData(@"x<\COORD>", false)] + [InlineData(@"x<\COORD>", true)] + [InlineData(@"Press , then x<\coord>", true)] + public void OnlyAWellFormedCoordinateTagIsALinkTag(string text, bool expected) + { + Assert.Equal(expected, ChatTagMarkup.ContainsTag(text)); + } + + [Fact] + public void ParseLinksKeepsEveryOtherAngleBracketTextAsWritten() + { + IReadOnlyList spans = ChatTagMarkup.ParseLinks( + @" go there<\Other><\COORD> <\COORD>end"); + + Assert.Equal( + new[] { " ", @"go there<\Other>", @" <\COORD>end" }, + spans.Select(span => span.Text)); + Assert.Null(spans[0].Tag); + Assert.Equal("COORD", spans[1].Tag!.Value.Type); + Assert.Null(spans[2].Tag); + } + + [Theory] + [InlineData(@"the bridge<\COORD>")] + [InlineData(@"x")] + [InlineData(@"a\b<\COORD>")] + [InlineData(@"trailing \ and \\< and <>")] + [InlineData(@"Press ")] + [InlineData("no markup at all")] + public void EscapedTextShowsAsWrittenAndHoldsNoTag(string text) + { + string escaped = AcDream.Plugin.Abstractions.PluginChatMarkup.Escape(text); + + Assert.False(ChatTagMarkup.ContainsTag(escaped)); + Assert.Equal(text, ChatTagMarkup.ToPlainText(escaped)); + Assert.All(ChatTagMarkup.ParseLinks(escaped), span => Assert.Null(span.Tag)); + } + + [Fact] + public void EscapeOfNothingIsEmpty() + { + Assert.Equal(string.Empty, AcDream.Plugin.Abstractions.PluginChatMarkup.Escape(null)); + Assert.Equal(@"\", AcDream.Plugin.Abstractions.PluginChatMarkup.Escape("")); + } + + [Fact] + public void WithoutCoordinateTagsKeepsTheCaptionAndOtherTags() + { + IReadOnlyList spans = ChatTagMarkup.WithoutCoordinateTags( + ChatTagMarkup.Parse( + @"Bob<\Tell> says, ""42.1N 33.5E<\COORD>""")); + + Assert.Equal( + new[] { "Bob", " says, \"", "42.1N 33.5E", "\"" }, + spans.Select(span => span.Text)); + Assert.Equal("Tell", spans[0].Tag!.Value.Type); + Assert.All(spans.Skip(1), span => Assert.Null(span.Tag)); + } } diff --git a/tests/AcDream.Plugin.Tests/PluginChatCoordinateWorkflowTests.cs b/tests/AcDream.Plugin.Tests/PluginChatCoordinateWorkflowTests.cs index 33b8ad3b9..104accbfd 100644 --- a/tests/AcDream.Plugin.Tests/PluginChatCoordinateWorkflowTests.cs +++ b/tests/AcDream.Plugin.Tests/PluginChatCoordinateWorkflowTests.cs @@ -24,6 +24,22 @@ public void MultipleCoordinatesAreReturnedAndSingleParseRejectsAmbiguity() Assert.False(PluginChatCoordinateParser.TryParse("1N, 2E and 3N, 4E", out _)); } + [Theory] + [InlineData("10n 20east")] + [InlineData("10north 20e")] + [InlineData("v2.1N 3E")] + public void CoordinateParserDoesNotReadAPairInsideAWord(string text) + { + Assert.Empty(PluginChatCoordinateParser.ParseAll(text)); + } + + [Fact] + public void CoordinateParserReadsAPairBetweenPunctuation() + { + Assert.True(PluginChatCoordinateParser.TryParse("Your corpse is located at (42.1N, 33.5E).", out var coordinate)); + Assert.Equal(new PluginChatCoordinate(33.5, 42.1), coordinate); + } + [Fact] public void LinkRouterForwardsCoordinatesUntilDisposed() { diff --git a/tests/AcDream.Runtime.Tests/Chat/RuntimeChatFeedTests.cs b/tests/AcDream.Runtime.Tests/Chat/RuntimeChatFeedTests.cs index cd133d075..24828373b 100644 --- a/tests/AcDream.Runtime.Tests/Chat/RuntimeChatFeedTests.cs +++ b/tests/AcDream.Runtime.Tests/Chat/RuntimeChatFeedTests.cs @@ -74,6 +74,220 @@ public void AnotherPlayersNameIsMarkedUpAndTheVisibleTextIsUnchanged() .Select(span => span.Text))); } + // -- Coordinate links: only a plugin's own line carries one ---------- + + private static ChatTextSpan CoordinateSpan(RuntimeChatLine line) => + Assert.Single(line.Spans!, span => span.Tag is { Type: "COORD" }); + + private static bool HasCoordinateLink(RuntimeChatLine line) => + line.Spans?.Any(span => span.Tag is { } tag + && string.Equals(tag.Type, "COORD", StringComparison.OrdinalIgnoreCase)) == true; + + [Fact] + public void ACoordinateAPlayerTypesIsNotALink() + { + var log = new ChatLog(); + using var feed = new RuntimeChatFeed(log); + log.OnLocalSpeech("Bob", "meet at 42.1N 33.5E", OtherPlayerGuid, isRanged: false, logTextType: (uint)RetailLogTextType.Speech); + log.OnTellReceived("Bob", "meet at 42.1N 33.5E", OtherPlayerGuid, (uint)RetailLogTextType.Tell); + log.OnChannelBroadcast(7u, "Bob", "meet at 42.1N 33.5E", channelName: "Fellowship"); + + IReadOnlyList lines = feed.Snapshot(); + + Assert.Equal(3, lines.Count); + Assert.Equal("Bob says, \"meet at 42.1N 33.5E\"", lines[0].Text); + foreach (RuntimeChatLine line in lines) + { + Assert.False(HasCoordinateLink(line)); + // The speaker's name is still the tell link it always was. + Assert.Equal("Bob", Assert.Single(line.Spans!, span => span.Tag is not null).Text); + } + } + + [Fact] + public void ACoordinateInTheCharactersOwnLineOrAServerLineIsNotALink() + { + var log = new ChatLog(); + using var feed = new RuntimeChatFeed(log); + log.OnLocalSpeech("You", "here 1N 2E", 0u, isRanged: false, logTextType: (uint)RetailLogTextType.Speech); + log.OnSystemMessage("Your corpse is located at (42.1N, 33.5E).", chatType: 0u); + + IReadOnlyList lines = feed.Snapshot(); + + Assert.Null(lines[0].Spans); + Assert.Null(lines[1].Spans); + Assert.Equal("Your corpse is located at (42.1N, 33.5E).", lines[1].Text); + } + + [Fact] + public void ACoordinateTagAPlayerTypesIsNotALink() + { + // A player who types the tag must not be able to show one place and + // hand plugins another: the caption is plain text, the tag goes nowhere. + const string Typed = "42.1N 33.5E<\\COORD>"; + var log = new ChatLog(); + using var feed = new RuntimeChatFeed(log); + log.OnLocalSpeech("Bob", Typed, OtherPlayerGuid, isRanged: false, logTextType: (uint)RetailLogTextType.Speech); + log.OnTellReceived("Bob", Typed, OtherPlayerGuid, (uint)RetailLogTextType.Tell); + log.OnChannelBroadcast(7u, "Bob", Typed, channelName: "Fellowship"); + + IReadOnlyList lines = feed.Snapshot(); + + Assert.Equal(3, lines.Count); + Assert.Equal("Bob says, \"42.1N 33.5E\"", lines[0].Text); + foreach (RuntimeChatLine line in lines) + { + Assert.False(HasCoordinateLink(line)); + Assert.Equal("Bob", Assert.Single(line.Spans!, span => span.Tag is not null).Text); + Assert.Equal(line.Text, string.Concat(line.Spans!.Select(span => span.Text))); + } + } + + [Fact] + public void ACombatLineIsNotSearchedForCoordinates() + { + var log = new ChatLog(); + using var feed = new RuntimeChatFeed(log); + log.OnCombatLine("You hit 3N 4E for 9 points.", (uint)RetailLogTextType.CombatSelf); + + Assert.Null(Assert.Single(feed.Snapshot()).Spans); + } + + // -- A plugin's own line ---------------------------------------------- + + [Fact] + public void ACoordinateAPluginWritesIsALink() + { + var log = new ChatLog(); + using var feed = new RuntimeChatFeed(log); + log.OnSystemMessage("meet at 42.1N 33.5E", chatType: 0u, allowsMarkup: true); + + RuntimeChatLine line = Assert.Single(feed.Snapshot()); + + Assert.Equal("meet at 42.1N 33.5E", line.Text); + ChatTextSpan coordinate = CoordinateSpan(line); + Assert.Equal("42.1N 33.5E", coordinate.Text); + Assert.True(coordinate.Tag!.Value.TryGetCoordinate(out double eastWest, out double northSouth)); + Assert.Equal(33.5, eastWest); + Assert.Equal(42.1, northSouth); + } + + [Fact] + public void ATimestampedPluginLineKeepsItsStampAndLinksTheCoordinate() + { + var log = new ChatLog { DisplayTimestampsSource = static () => true }; + using var feed = new RuntimeChatFeed(log); + log.OnSystemMessage("at 5S 6W", chatType: 0u, allowsMarkup: true); + + RuntimeChatLine line = Assert.Single(feed.Snapshot()); + + Assert.Equal(ChatSpanRole.Timestamp, line.Spans![0].Role); + Assert.Equal("5S 6W", CoordinateSpan(line).Text); + Assert.Equal(line.Text, string.Concat(line.Spans.Select(span => span.Text))); + } + + [Fact] + public void APluginLinesMarkupIsReadAsALink() + { + var log = new ChatLog(); + using var feed = new RuntimeChatFeed(log); + log.OnSystemMessage( + "Waypoint: the bridge<\\COORD>.", + chatType: 0u, + allowsMarkup: true); + + RuntimeChatLine line = Assert.Single(feed.Snapshot()); + + Assert.Equal("Waypoint: the bridge.", line.Text); + ChatTextSpan coordinate = CoordinateSpan(line); + Assert.Equal("the bridge", coordinate.Text); + Assert.True(coordinate.Tag!.Value.TryGetCoordinate(out double eastWest, out double northSouth)); + Assert.Equal(-3.25, eastWest); + Assert.Equal(12.5, northSouth); + Assert.Equal("Waypoint: the bridge.", RuntimeChatFeed.Format(log.Snapshot()[0])); + } + + [Fact] + public void AServerLinesMarkupIsShownAsItArrived() + { + var log = new ChatLog(); + using var feed = new RuntimeChatFeed(log); + log.OnSystemMessage("Bob<\\Tell>", chatType: 0u); + + RuntimeChatLine line = Assert.Single(feed.Snapshot()); + + Assert.Equal("Bob<\\Tell>", line.Text); + Assert.Null(line.Spans); + } + + [Fact] + public void APluginLineWithNoTagKeepsItsAngleBrackets() + { + var log = new ChatLog(); + using var feed = new RuntimeChatFeed(log); + log.OnSystemMessage("a < b > c ", chatType: 0u, allowsMarkup: true); + + RuntimeChatLine line = Assert.Single(feed.Snapshot()); + + Assert.Equal("a < b > c ", line.Text); + Assert.Null(line.Spans); + } + + [Fact] + public void APlayersTagAPluginRepostsEscapedIsShownAsWrittenAndIsNotALink() + { + // A plugin echoing a captured tell escapes the player's words; the + // tag they typed is shown literally and reports nothing hidden. + const string Typed = "the bridge<\\COORD>"; + var log = new ChatLog(); + using var feed = new RuntimeChatFeed(log); + log.OnSystemMessage( + "Bob told you: " + AcDream.Plugin.Abstractions.PluginChatMarkup.Escape(Typed), + chatType: 0u, + allowsMarkup: true); + log.OnSystemMessage( + AcDream.Plugin.Abstractions.PluginChatMarkup.Escape("press "), + chatType: 0u, + allowsMarkup: true); + + IReadOnlyList lines = feed.Snapshot(); + + Assert.Equal("Bob told you: " + Typed, lines[0].Text); + Assert.Equal("Bob told you: " + Typed, RuntimeChatFeed.Format(log.Snapshot()[0])); + // The caption is not a link. The tag's own text, now shown, holds a + // plain pair, and that pair links to exactly what it shows. + Assert.DoesNotContain(lines[0].Spans!, span => span.Tag is not null && span.Text.Contains("bridge")); + ChatTextSpan shown = CoordinateSpan(lines[0]); + Assert.Equal("0N, 0E", shown.Text); + Assert.True(shown.Tag!.Value.TryGetCoordinate(out double eastWest, out double northSouth)); + Assert.Equal((0d, 0d), (eastWest, northSouth)); + + Assert.Equal("press ", lines[1].Text); + Assert.Null(lines[1].Spans); + } + + [Fact] + public void APluginLinesOtherAngleBracketTextIsShownAsWritten() + { + var log = new ChatLog(); + using var feed = new RuntimeChatFeed(log); + log.OnSystemMessage("Press to follow", chatType: 0u, allowsMarkup: true); + log.OnSystemMessage( + "Press , then go<\\COORD> ", + chatType: 0u, + allowsMarkup: true); + + IReadOnlyList lines = feed.Snapshot(); + + Assert.Equal("Press to follow", lines[0].Text); + Assert.Null(lines[0].Spans); + Assert.Equal("Press , then go ", lines[1].Text); + Assert.Equal("go", CoordinateSpan(lines[1]).Text); + Assert.Equal( + "Press , then go ", + RuntimeChatFeed.Format(log.Snapshot()[1])); + } + // -- Pull ------------------------------------------------------------- [Fact]