From dcdb31fcfee91e88e736b6b0637972a019156315 Mon Sep 17 00:00:00 2001 From: David Smith Date: Wed, 14 Oct 2026 15:00:37 +0200 Subject: [PATCH 1/3] feat(chat): coordinates in chat are links that reach plugins A coordinate pair written in any chat line except combat text ("meet at 42.1N 33.5E", "Your corpse is located at (42.1N, 33.5E).") is now a COORD:World link, split out by the same rule PluginChatCoordinateParser reads. A line a plugin posts has its own markup read as a link; server text is never read as markup, and a plugin line with no tag keeps its angle brackets. Clicking a coordinate in the main chat window now raises IPluginChat.LinkClicked. The window's ChatLinkClicked hook was never assigned, so until now no click reached a plugin. Contract: XML docs only (LinkClicked's summary described Received). Claude-Session: https://claude.ai/code/session_01Lm9T9oP2Hi5FEjwsLmXeyN --- docs/plugin-api.md | 30 +++++ .../InteractionRetainedUiComposition.cs | 5 +- src/AcDream.App/UI/RetailUiRuntime.cs | 4 +- src/AcDream.Core/Chat/ChatCoordinateLinks.cs | 74 ++++++++++++ src/AcDream.Core/Chat/ChatLineWording.cs | 14 ++- src/AcDream.Core/Chat/ChatLog.cs | 14 +++ src/AcDream.Core/Chat/ChatTagMarkup.cs | 24 ++++ src/AcDream.Plugin.Abstractions/Automation.cs | 21 +++- src/AcDream.Runtime/Chat/RuntimeChatFeed.cs | 12 +- .../Gameplay/RuntimeCommunicationState.cs | 12 +- .../Plugins/RuntimeAutomationSurface.cs | 2 +- .../RuntimeAutomationSurfacePluginApiTests.cs | 17 +++ .../UI/Layout/ChatWindowControllerTests.cs | 25 ++++ .../Chat/ChatCoordinateLinksTests.cs | 107 +++++++++++++++++ .../Chat/RuntimeChatFeedTests.cs | 111 ++++++++++++++++++ 15 files changed, 455 insertions(+), 17 deletions(-) create mode 100644 src/AcDream.Core/Chat/ChatCoordinateLinks.cs create mode 100644 tests/AcDream.Core.Tests/Chat/ChatCoordinateLinksTests.cs diff --git a/docs/plugin-api.md b/docs/plugin-api.md index c3611c21b..c8d20ca55 100644 --- a/docs/plugin-api.md +++ b/docs/plugin-api.md @@ -124,6 +124,36 @@ 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>"); +``` + +A plugin's line is read as markup only when it holds a tag of the form +``, so an ordinary `<` in a line is shown as written. Server +text is never read as markup. + +### Chat links + +`LinkClicked` is raised when the player clicks a coordinate link in the main +chat window. Every coordinate pair `PluginChatCoordinateParser` reads is a +link, in any line except combat text: what players say, tell and post on +channels, the server's own lines ("Your corpse is located at (42.1N, 33.5E).") +and plugins' lines. `PluginChatCoordinateLinkRouter` hands just the +coordinates 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. + ### 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/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..dfc4426b3 --- /dev/null +++ b/src/AcDream.Core/Chat/ChatCoordinateLinks.cs @@ -0,0 +1,74 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Chat; + +/// +/// Turns the coordinate pairs written in a chat line ("meet at 42.1N 33.5E") +/// into the same COORD:World links a line's own markup makes, so a click +/// on one reaches plugins as . +/// +public static partial class ChatCoordinateLinks +{ + // The pair pattern PluginChatCoordinateParser reads; each match is handed + // back to it, so a pair is linked exactly when the parser reads it. + [GeneratedRegex(@"(?\d+(?:\.\d+)?)\s*(?[NSEW])\s*(?:[,;/|]|\s+)\s*(?\d+(?:\.\d+)?)\s*(?[NSEW])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex PairPattern(); + + /// + /// 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. + /// + /// 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 (Match match in PairPattern().Matches(span.Text)) + { + if (!PluginChatCoordinateParser.TryParse(match.Value, out PluginChatCoordinate coordinate)) + continue; + if (result is null) + { + result = new List(spans.Count + 2); + for (int j = 0; j < i; j++) + result.Add(spans[j]); + } + if (match.Index > runStart) + result.Add(span with { Text = span.Text[runStart..match.Index] }); + result.Add(span with { Text = match.Value, Tag = TagFor(coordinate) }); + runStart = match.Index + match.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')}"); + + private static string Axis(double value, char positive, char negative) => + Math.Abs(value).ToString("R", CultureInfo.InvariantCulture) + + (value < 0d ? negative : positive); +} diff --git a/src/AcDream.Core/Chat/ChatLineWording.cs b/src/AcDream.Core/Chat/ChatLineWording.cs index e52338ba2..24a28041c 100644 --- a/src/AcDream.Core/Chat/ChatLineWording.cs +++ b/src/AcDream.Core/Chat/ChatLineWording.cs @@ -11,12 +11,14 @@ public static class ChatLineWording { /// The line as plain text, with no clickable sender marked up. public static string Format(ChatEntry entry) - => Format(entry, static sender => sender); + => CarriesMarkup(entry) + ? 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 with its own markup + /// kept. Identical to for every other line. /// public static string FormatTagged(ChatEntry entry) => ShouldTagSender(entry) @@ -24,7 +26,11 @@ public static string FormatTagged(ChatEntry entry) entry, sender => $"{sender}<\\Tell>") - : Format(entry); + : Format(entry, static sender => sender); + + /// Whether this line's own text holds markup to read as links. + public static bool CarriesMarkup(ChatEntry entry) + => entry.AllowsMarkup && ChatTagMarkup.ContainsTag(entry.Text); /// 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..92f2666bb 100644 --- a/src/AcDream.Core/Chat/ChatLog.cs +++ b/src/AcDream.Core/Chat/ChatLog.cs @@ -158,6 +158,13 @@ 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, whose + /// <TYPE:FORMAT:data> tags are read as links. Server text never is. + /// + 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 +175,7 @@ public void OnSystemMessage(string text, uint chatType) ChannelId: chatType) { LogTextType = chatType, + AllowsMarkup = allowsMarkup, }); } @@ -315,6 +323,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; } + /// void PostSystemMessage(string text); diff --git a/src/AcDream.Plugin.Abstractions/PluginChatCoordinateWorkflow.cs b/src/AcDream.Plugin.Abstractions/PluginChatCoordinateWorkflow.cs index d844b95f9..f687ce685 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. @@ -20,6 +23,17 @@ 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 +45,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.Runtime/Chat/RuntimeChatFeed.cs b/src/AcDream.Runtime/Chat/RuntimeChatFeed.cs index ae85bb400..930c0b2bf 100644 --- a/src/AcDream.Runtime/Chat/RuntimeChatFeed.cs +++ b/src/AcDream.Runtime/Chat/RuntimeChatFeed.cs @@ -17,8 +17,9 @@ 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 links (a clickable sender, a coordinate, a plugin's -/// own markup), 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( @@ -119,16 +120,30 @@ public RuntimeChatLine BuildLine(ChatEntry entry) private RuntimeChatLine BuildLine(ChatEntry entry, bool timestamps) { - bool tagged = ShouldTagSender(entry) || ChatLineWording.CarriesMarkup(entry); string markup = FormatTagged(entry); - IReadOnlyList? spans = tagged - ? ChatTagMarkup.Parse(markup) - : null; - // A coordinate pair written anywhere in the line is a link too, except - // in combat text, which never carries one. - if (entry.Kind != ChatKind.Combat) + IReadOnlyList? spans; + if (entry.AllowsMarkup) + { + // A plugin's own line: its link tags are read, and a coordinate + // pair it writes in plain text is a link too. + spans = ChatLineWording.CarriesMarkup(entry) + ? ChatTagMarkup.ParseLinks(markup) + : null; spans = ChatCoordinateLinks.Link(spans ?? new[] { new ChatTextSpan(markup, null) }) ?? spans; + } + 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)); diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index c5ce100d7..f4082ccf4 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -575,7 +575,7 @@ public void TranscriptLayout_IsReusedUntilContentOrWidthChanges() } [Fact] - public void ClickingACoordinateSomeoneTypedRaisesACoordinateLink() + public void ClickingACoordinateAPluginWroteRaisesACoordinateLink() { var log = new ChatLog(); var (rootInfo, layout, vm) = BuildTestTree(log); @@ -587,7 +587,7 @@ public void ClickingACoordinateSomeoneTypedRaisesACoordinateLink() var clicked = new List(); ctrl.ChatLinkClicked = clicked.Add; - log.OnSystemMessage("Your corpse is at 42.1N, 33.5W.", chatType: 0u); + 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); @@ -599,6 +599,34 @@ public void ClickingACoordinateSomeoneTypedRaisesACoordinateLink() Assert.Equal(new AcDream.Plugin.Abstractions.PluginChatCoordinate(-33.5, 42.1), link.Coordinate); } + [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 index 92ac9e775..da1f7a135 100644 --- a/tests/AcDream.Core.Tests/Chat/ChatCoordinateLinksTests.cs +++ b/tests/AcDream.Core.Tests/Chat/ChatCoordinateLinksTests.cs @@ -52,6 +52,16 @@ public void ALineWithNoPairIsLeftAlone() 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() { @@ -89,6 +99,7 @@ public void ATaggedSpanIsKeptAndThePlainSpanAfterItIsLinked() [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); diff --git a/tests/AcDream.Core.Tests/Chat/ChatTagMarkupTests.cs b/tests/AcDream.Core.Tests/Chat/ChatTagMarkupTests.cs index 6b2320a41..a9f36df57 100644 --- a/tests/AcDream.Core.Tests/Chat/ChatTagMarkupTests.cs +++ b/tests/AcDream.Core.Tests/Chat/ChatTagMarkupTests.cs @@ -98,4 +98,44 @@ 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); + } + + [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 3e3a96176..feaae9d66 100644 --- a/tests/AcDream.Runtime.Tests/Chat/RuntimeChatFeedTests.cs +++ b/tests/AcDream.Runtime.Tests/Chat/RuntimeChatFeedTests.cs @@ -74,31 +74,38 @@ public void AnotherPlayersNameIsMarkedUpAndTheVisibleTextIsUnchanged() .Select(span => span.Text))); } - // -- Coordinate links ------------------------------------------------- + // -- 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 ACoordinateAPlayerTypesIsALinkAndTheTextIsUnchanged() + 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"); - RuntimeChatLine line = Assert.Single(feed.Snapshot()); + IReadOnlyList lines = feed.Snapshot(); - Assert.Equal("Bob says, \"meet at 42.1N 33.5E\"", line.Text); - Assert.Equal("Bob", line.Spans![0].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); + 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 ACoordinateInTheCharactersOwnLineAndInAServerLineIsALink() + public void ACoordinateInTheCharactersOwnLineOrAServerLineIsNotALink() { var log = new ChatLog(); using var feed = new RuntimeChatFeed(log); @@ -107,11 +114,35 @@ public void ACoordinateInTheCharactersOwnLineAndInAServerLineIsALink() IReadOnlyList lines = feed.Snapshot(); - Assert.Equal("1N 2E", CoordinateSpan(lines[0]).Text); - Assert.Equal("42.1N, 33.5E", CoordinateSpan(lines[1]).Text); + 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() { @@ -122,12 +153,31 @@ public void ACombatLineIsNotSearchedForCoordinates() 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 ATimestampedLineKeepsItsStampAndLinksTheCoordinate() + public void ATimestampedPluginLineKeepsItsStampAndLinksTheCoordinate() { var log = new ChatLog { DisplayTimestampsSource = static () => true }; using var feed = new RuntimeChatFeed(log); - log.OnSystemMessage("at 5S 6W", chatType: 0u); + log.OnSystemMessage("at 5S 6W", chatType: 0u, allowsMarkup: true); RuntimeChatLine line = Assert.Single(feed.Snapshot()); @@ -136,8 +186,6 @@ public void ATimestampedLineKeepsItsStampAndLinksTheCoordinate() Assert.Equal(line.Text, string.Concat(line.Spans.Select(span => span.Text))); } - // -- A plugin's markup ------------------------------------------------ - [Fact] public void APluginLinesMarkupIsReadAsALink() { @@ -185,6 +233,28 @@ public void APluginLineWithNoTagKeepsItsAngleBrackets() Assert.Null(line.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] From ca0cc66a5eafd43bb9028c39a81f9059a0d62999 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 19 Oct 2026 18:18:58 +0200 Subject: [PATCH 3/3] fix(chat): escape helper for relayed text, invariant link names, status-line captions A plugin that reposts what a player wrote (echoing a tell, relaying a channel) re-armed any COORD tag the player typed, because a plugin's line is trusted as the plugin's own. PluginChatMarkup.Escape puts a backslash before every '<'; the plugin-line reader drops that backslash and shows the '<' as written, never as a tag. Nothing calls it implicitly; the PostMessage/PostSystemMessage docs and the plugin guide tell plugins to escape text they did not write. The name a clicked coordinate is reported with was formatted with the current culture and kept the sign: "-33,5W" on a Swedish machine. It is now built once, invariantly, from the absolute values ("42.1N, 33.5W"). A plugin line routed to the status line, which has no links, showed the raw tag; it now shows the caption only. The plugin surface already sends that class to the transcript, so this guards the lower-level path. Doc comments now say only COORD tags are read on plugin lines, the tagged wording keeps a plugin's line as written, and ParseAll states the word-boundary rule. Co-Authored-By: Claude Opus 5.5 --- docs/plugin-api.md | 16 ++++ .../UI/Layout/ChatWindowController.cs | 2 +- src/AcDream.Core/Chat/ChatCoordinateLinks.cs | 11 ++- src/AcDream.Core/Chat/ChatLineWording.cs | 16 ++-- src/AcDream.Core/Chat/ChatLog.cs | 5 +- src/AcDream.Core/Chat/ChatTagMarkup.cs | 88 +++++++++++-------- src/AcDream.Plugin.Abstractions/Automation.cs | 9 +- .../PluginChatCoordinateWorkflow.cs | 2 + .../PluginChatMarkup.cs | 26 ++++++ src/AcDream.Runtime/Chat/RuntimeChatFeed.cs | 30 ++++--- .../Gameplay/RuntimeCommunicationState.cs | 11 ++- .../RuntimeAutomationSurfacePluginApiTests.cs | 26 ++++++ .../UI/Layout/ChatWindowControllerTests.cs | 37 ++++++++ .../Chat/ChatTagMarkupTests.cs | 23 +++++ .../Chat/RuntimeChatFeedTests.cs | 33 +++++++ 15 files changed, 270 insertions(+), 65 deletions(-) create mode 100644 src/AcDream.Plugin.Abstractions/PluginChatMarkup.cs diff --git a/docs/plugin-api.md b/docs/plugin-api.md index f99909a1e..2cf8fad03 100644 --- a/docs/plugin-api.md +++ b/docs/plugin-api.md @@ -160,6 +160,22 @@ var router = new PluginChatCoordinateLinkRouter( 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/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.Core/Chat/ChatCoordinateLinks.cs b/src/AcDream.Core/Chat/ChatCoordinateLinks.cs index c24fa19cb..878fbd01a 100644 --- a/src/AcDream.Core/Chat/ChatCoordinateLinks.cs +++ b/src/AcDream.Core/Chat/ChatCoordinateLinks.cs @@ -68,7 +68,14 @@ public static ChatTextTag TagFor(PluginChatCoordinate coordinate) => "World", $"{Axis(coordinate.NorthSouth, 'N', 'S')}, {Axis(coordinate.EastWest, 'E', 'W')}"); - private static string Axis(double value, char positive, char negative) => - Math.Abs(value).ToString("R", CultureInfo.InvariantCulture) + /// + /// 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 24a28041c..8bdf77c20 100644 --- a/src/AcDream.Core/Chat/ChatLineWording.cs +++ b/src/AcDream.Core/Chat/ChatLineWording.cs @@ -9,16 +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) - => CarriesMarkup(entry) + => 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, and a plugin's line with its own markup - /// kept. Identical to for every other line. + /// 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) @@ -28,10 +32,6 @@ public static string FormatTagged(ChatEntry entry) $"{sender}<\\Tell>") : Format(entry, static sender => sender); - /// Whether this line's own text holds markup to read as links. - public static bool CarriesMarkup(ChatEntry entry) - => entry.AllowsMarkup && ChatTagMarkup.ContainsTag(entry.Text); - /// Whether this line names another player we could click to tell. // A channel line names its speaker without an object id (allegiance, // fellowship and the other channels carry only the name), yet the name diff --git a/src/AcDream.Core/Chat/ChatLog.cs b/src/AcDream.Core/Chat/ChatLog.cs index 92f2666bb..ee288e03a 100644 --- a/src/AcDream.Core/Chat/ChatLog.cs +++ b/src/AcDream.Core/Chat/ChatLog.cs @@ -161,8 +161,9 @@ 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, whose - /// <TYPE:FORMAT:data> tags are read as links. Server text never is. + /// 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) { diff --git a/src/AcDream.Core/Chat/ChatTagMarkup.cs b/src/AcDream.Core/Chat/ChatTagMarkup.cs index e9cdb27bf..8fbae132b 100644 --- a/src/AcDream.Core/Chat/ChatTagMarkup.cs +++ b/src/AcDream.Core/Chat/ChatTagMarkup.cs @@ -81,28 +81,22 @@ 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>", is not a tag. + /// such as "Press <Ctrl:F>", and an escaped tag are not tags. /// - public static bool ContainsTag(string? text) - { - if (string.IsNullOrEmpty(text)) - return false; - for (int open = text.IndexOf('<'); open >= 0; open = text.IndexOf('<', open + 1)) - { - int close = text.IndexOf('>', open + 1); - if (close < 0) - return false; - if (TryParseLinkTag(text[(open + 1)..close]) is not null) - return true; - } - return false; - } + 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. + /// 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) { @@ -110,33 +104,51 @@ public static IReadOnlyList ParseLinks(string? text) return Array.Empty(); var spans = new List(); + var run = new System.Text.StringBuilder(text.Length); ChatTextTag? open = null; - int runStart = 0; + int i = 0; - for (int i = text.IndexOf('<'); i >= 0; i = text.IndexOf('<', i + 1)) + while (i < text.Length) { - int close = text.IndexOf('>', i + 1); - if (close < 0) - break; - - string inner = text[(i + 1)..close]; - ChatTextTag? next; - if (TryParseLinkTag(inner) is { } start) - next = start; - else if (open is { } current && IsEndTag(inner, current)) - next = null; - else - continue; // not markup: shown as written - - if (i > runStart) - spans.Add(new ChatTextSpan(text[runStart..i], open)); - open = next; - runStart = close + 1; - i = close; + 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 (runStart < text.Length) - spans.Add(new ChatTextSpan(text[runStart..], open)); + if (run.Length > 0) + spans.Add(new ChatTextSpan(run.ToString(), open)); return spans; } diff --git a/src/AcDream.Plugin.Abstractions/Automation.cs b/src/AcDream.Plugin.Abstractions/Automation.cs index 7eab6a369..337d86536 100644 --- a/src/AcDream.Plugin.Abstractions/Automation.cs +++ b/src/AcDream.Plugin.Abstractions/Automation.cs @@ -1016,14 +1016,19 @@ IDisposable RegisterInputInterceptor( /// <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. + /// 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. 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 f687ce685..09b8dee05 100644 --- a/src/AcDream.Plugin.Abstractions/PluginChatCoordinateWorkflow.cs +++ b/src/AcDream.Plugin.Abstractions/PluginChatCoordinateWorkflow.cs @@ -18,6 +18,8 @@ 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) { 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 930c0b2bf..0eddcae6f 100644 --- a/src/AcDream.Runtime/Chat/RuntimeChatFeed.cs +++ b/src/AcDream.Runtime/Chat/RuntimeChatFeed.cs @@ -124,13 +124,19 @@ private RuntimeChatLine BuildLine(ChatEntry entry, bool timestamps) IReadOnlyList? spans; if (entry.AllowsMarkup) { - // A plugin's own line: its link tags are read, and a coordinate - // pair it writes in plain text is a link too. - spans = ChatLineWording.CarriesMarkup(entry) - ? ChatTagMarkup.ParseLinks(markup) - : null; - spans = ChatCoordinateLinks.Link(spans ?? new[] { new ChatTextSpan(markup, null) }) - ?? spans; + // 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)) { @@ -177,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 a0875d792..ed9f8e348 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs @@ -177,8 +177,10 @@ public void AddText(string text, RetailLogTextType type, uint windowId = 0) => AddText(text, type, allowsMarkup: false); /// - /// Writes a line a plugin posted, whose <TYPE:FORMAT:data> - /// tags are read as links in the transcript. + /// 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); @@ -191,6 +193,11 @@ private void AddText(string text, RetailLogTextType type, bool allowsMarkup) 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( diff --git a/tests/AcDream.App.Tests/Plugins/RuntimeAutomationSurfacePluginApiTests.cs b/tests/AcDream.App.Tests/Plugins/RuntimeAutomationSurfacePluginApiTests.cs index 0a0eafe49..c9da259d6 100644 --- a/tests/AcDream.App.Tests/Plugins/RuntimeAutomationSurfacePluginApiTests.cs +++ b/tests/AcDream.App.Tests/Plugins/RuntimeAutomationSurfacePluginApiTests.cs @@ -410,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 f4082ccf4..9c2b6fac0 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -597,6 +597,43 @@ public void ClickingACoordinateAPluginWroteRaisesACoordinateLink() 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] diff --git a/tests/AcDream.Core.Tests/Chat/ChatTagMarkupTests.cs b/tests/AcDream.Core.Tests/Chat/ChatTagMarkupTests.cs index a9f36df57..40637c5d7 100644 --- a/tests/AcDream.Core.Tests/Chat/ChatTagMarkupTests.cs +++ b/tests/AcDream.Core.Tests/Chat/ChatTagMarkupTests.cs @@ -125,6 +125,29 @@ public void ParseLinksKeepsEveryOtherAngleBracketTextAsWritten() 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() { diff --git a/tests/AcDream.Runtime.Tests/Chat/RuntimeChatFeedTests.cs b/tests/AcDream.Runtime.Tests/Chat/RuntimeChatFeedTests.cs index feaae9d66..24828373b 100644 --- a/tests/AcDream.Runtime.Tests/Chat/RuntimeChatFeedTests.cs +++ b/tests/AcDream.Runtime.Tests/Chat/RuntimeChatFeedTests.cs @@ -233,6 +233,39 @@ public void APluginLineWithNoTagKeepsItsAngleBrackets() 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() { /// 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..d0f2d04c5 100644 --- a/src/AcDream.Core/Chat/ChatTagMarkup.cs +++ b/src/AcDream.Core/Chat/ChatTagMarkup.cs @@ -77,6 +77,30 @@ public readonly record struct ChatTextSpan( public static class ChatTagMarkup { + /// + /// Whether the text holds at least one start tag (<TYPE:FORMAT…>). + /// Text a plugin writes is read as markup only when it does, so a stray + /// < in an ordinary line is shown as it was written. + /// + public static bool ContainsTag(string? text) + { + if (string.IsNullOrEmpty(text)) + return false; + for (int open = text.IndexOf('<'); open >= 0; open = text.IndexOf('<', open + 1)) + { + int close = text.IndexOf('>', open + 1); + if (close < 0) + return false; + if (TryParseStartTag(text[(open + 1)..close]) is not null) + return true; + } + return false; + } + + /// The text with its markup removed: what shows. + public static string ToPlainText(string? text) => + string.Concat(Parse(text).Select(static span => span.Text)); + public static IReadOnlyList Parse(string? text) { if (string.IsNullOrEmpty(text)) diff --git a/src/AcDream.Plugin.Abstractions/Automation.cs b/src/AcDream.Plugin.Abstractions/Automation.cs index 6833d4d91..ccbaf3e03 100644 --- a/src/AcDream.Plugin.Abstractions/Automation.cs +++ b/src/AcDream.Plugin.Abstractions/Automation.cs @@ -927,9 +927,11 @@ 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 link in the chat window. A coordinate + /// pair written in any line but combat text ("meet at 42.1N 33.5E") is a + /// link, and so is a <COORD:World:42.1N, 33.5E>…<\COORD> + /// tag in a line a plugin posted. Clicking a player's name starts a tell + /// instead and raises nothing. /// event Action LinkClicked { @@ -937,7 +939,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 +1004,16 @@ 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 . /// 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 + /// . /// void PostMessage(string text, int logTextType) => PostSystemMessage(text); diff --git a/src/AcDream.Runtime/Chat/RuntimeChatFeed.cs b/src/AcDream.Runtime/Chat/RuntimeChatFeed.cs index eb62df50e..ae85bb400 100644 --- a/src/AcDream.Runtime/Chat/RuntimeChatFeed.cs +++ b/src/AcDream.Runtime/Chat/RuntimeChatFeed.cs @@ -17,8 +17,9 @@ 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, a coordinate, a plugin's +/// own markup), or null when it has none. The spans always rejoin to +/// . /// public readonly record struct RuntimeChatLine( long Sequence, @@ -118,11 +119,16 @@ public RuntimeChatLine BuildLine(ChatEntry entry) private RuntimeChatLine BuildLine(ChatEntry entry, bool timestamps) { - bool tagged = ShouldTagSender(entry); + bool tagged = ShouldTagSender(entry) || ChatLineWording.CarriesMarkup(entry); string markup = FormatTagged(entry); IReadOnlyList? spans = tagged ? ChatTagMarkup.Parse(markup) : null; + // A coordinate pair written anywhere in the line is a link too, except + // in combat text, which never carries one. + if (entry.Kind != ChatKind.Combat) + spans = ChatCoordinateLinks.Link(spans ?? new[] { new ChatTextSpan(markup, null) }) + ?? spans; string text = spans is null ? markup : string.Concat(spans.Select(span => span.Text)); diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs index 7865ca456..a0875d792 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs @@ -174,6 +174,16 @@ 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, whose <TYPE:FORMAT:data> + /// tags are read as links in the transcript. + /// + public void AddPluginText(string text, RetailLogTextType type) + => AddText(text, type, allowsMarkup: true); + + private void AddText(string text, RetailLogTextType type, bool allowsMarkup) { ArgumentNullException.ThrowIfNull(text); @@ -203,7 +213,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..0a0eafe49 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() { diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index b43931f27..c5ce100d7 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -574,6 +574,31 @@ public void TranscriptLayout_IsReusedUntilContentOrWidthChanges() Assert.Equal(4, ctrl.TranscriptLayoutBuildCount); } + [Fact] + public void ClickingACoordinateSomeoneTypedRaisesACoordinateLink() + { + 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); + 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); + } + [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..92ac9e775 --- /dev/null +++ b/tests/AcDream.Core.Tests/Chat/ChatCoordinateLinksTests.cs @@ -0,0 +1,107 @@ +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"))); + } + + [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")] + 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.Runtime.Tests/Chat/RuntimeChatFeedTests.cs b/tests/AcDream.Runtime.Tests/Chat/RuntimeChatFeedTests.cs index cd133d075..3e3a96176 100644 --- a/tests/AcDream.Runtime.Tests/Chat/RuntimeChatFeedTests.cs +++ b/tests/AcDream.Runtime.Tests/Chat/RuntimeChatFeedTests.cs @@ -74,6 +74,117 @@ public void AnotherPlayersNameIsMarkedUpAndTheVisibleTextIsUnchanged() .Select(span => span.Text))); } + // -- Coordinate links ------------------------------------------------- + + private static ChatTextSpan CoordinateSpan(RuntimeChatLine line) => + Assert.Single(line.Spans!, span => span.Tag is { Type: "COORD" }); + + [Fact] + public void ACoordinateAPlayerTypesIsALinkAndTheTextIsUnchanged() + { + 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); + + RuntimeChatLine line = Assert.Single(feed.Snapshot()); + + Assert.Equal("Bob says, \"meet at 42.1N 33.5E\"", line.Text); + Assert.Equal("Bob", line.Spans![0].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 ACoordinateInTheCharactersOwnLineAndInAServerLineIsALink() + { + 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.Equal("1N 2E", CoordinateSpan(lines[0]).Text); + Assert.Equal("42.1N, 33.5E", CoordinateSpan(lines[1]).Text); + Assert.Equal("Your corpse is located at (42.1N, 33.5E).", lines[1].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); + } + + [Fact] + public void ATimestampedLineKeepsItsStampAndLinksTheCoordinate() + { + var log = new ChatLog { DisplayTimestampsSource = static () => true }; + using var feed = new RuntimeChatFeed(log); + log.OnSystemMessage("at 5S 6W", chatType: 0u); + + 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))); + } + + // -- A plugin's markup ------------------------------------------------ + + [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); + } + // -- Pull ------------------------------------------------------------- [Fact] From bac30f9951bc555ccae8d51a79996de99f6f9953 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 19 Oct 2026 18:09:09 +0200 Subject: [PATCH 2/3] fix(chat): coordinate links only on plugin lines; no links from player text A line with a clickable sender was parsed as tag markup whole, the speaker's own words included. A player who typed 42.1N 33.5E<\COORD> got a green link showing one place that reported another to every plugin listening on LinkClicked. What players and the server write now looks as it did before this branch: the speaker's name is still the tell link, plain coordinates are not linked, and a COORD tag typed into a message keeps its caption as plain text in the line colour with nothing behind a click. Only a line a plugin posted carries coordinate links: its COORD tags and the pairs it writes in plain text. A plugin's line now reads only a well-formed closed by <\COORD>; any other angle-bracket text ("Press ") is shown as written instead of being swallowed as a tag. The chat links pairs with the public parser's own matcher instead of a copy of its pattern, and that pattern no longer matches inside words, so "10n 20east" is neither read nor linked. Co-Authored-By: Claude Opus 5.5 --- docs/plugin-api.md | 22 ++-- src/AcDream.Core/Chat/ChatCoordinateLinks.cs | 36 +++--- src/AcDream.Core/Chat/ChatTagMarkup.cs | 90 ++++++++++++++- .../AcDream.Plugin.Abstractions.csproj | 6 + src/AcDream.Plugin.Abstractions/Automation.cs | 24 ++-- .../PluginChatCoordinateWorkflow.cs | 19 +++- src/AcDream.Runtime/Chat/RuntimeChatFeed.cs | 33 ++++-- .../UI/Layout/ChatWindowControllerTests.cs | 32 +++++- .../Chat/ChatCoordinateLinksTests.cs | 11 ++ .../Chat/ChatTagMarkupTests.cs | 40 +++++++ .../PluginChatCoordinateWorkflowTests.cs | 16 +++ .../Chat/RuntimeChatFeedTests.cs | 104 +++++++++++++++--- 12 files changed, 363 insertions(+), 70 deletions(-) diff --git a/docs/plugin-api.md b/docs/plugin-api.md index c8d20ca55..f99909a1e 100644 --- a/docs/plugin-api.md +++ b/docs/plugin-api.md @@ -132,18 +132,24 @@ host.Automation.Chat.PostSystemMessage( @"Next waypoint: the Holtburg bridge<\COORD>"); ``` -A plugin's line is read as markup only when it holds a tag of the form -``, so an ordinary `<` in a line is shown as written. Server -text is never read as markup. +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. Every coordinate pair `PluginChatCoordinateParser` reads is a -link, in any line except combat text: what players say, tell and post on -channels, the server's own lines ("Your corpse is located at (42.1N, 33.5E).") -and plugins' lines. `PluginChatCoordinateLinkRouter` hands just the -coordinates to a callback: +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( diff --git a/src/AcDream.Core/Chat/ChatCoordinateLinks.cs b/src/AcDream.Core/Chat/ChatCoordinateLinks.cs index dfc4426b3..c24fa19cb 100644 --- a/src/AcDream.Core/Chat/ChatCoordinateLinks.cs +++ b/src/AcDream.Core/Chat/ChatCoordinateLinks.cs @@ -1,25 +1,22 @@ using System.Globalization; -using System.Text.RegularExpressions; using AcDream.Plugin.Abstractions; namespace AcDream.Core.Chat; /// -/// Turns the coordinate pairs written in a chat line ("meet at 42.1N 33.5E") -/// into the same COORD:World links a line's own markup makes, so a click -/// on one reaches plugins as . +/// 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 partial class ChatCoordinateLinks +public static class ChatCoordinateLinks { - // The pair pattern PluginChatCoordinateParser reads; each match is handed - // back to it, so a pair is linked exactly when the parser reads it. - [GeneratedRegex(@"(?\d+(?:\.\d+)?)\s*(?[NSEW])\s*(?:[,;/|]|\s+)\s*(?\d+(?:\.\d+)?)\s*(?[NSEW])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] - private static partial Regex PairPattern(); - /// /// 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. + /// 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. @@ -37,20 +34,23 @@ public static partial class ChatCoordinateLinks } int runStart = 0; - foreach (Match match in PairPattern().Matches(span.Text)) + foreach ((int index, int length, PluginChatCoordinate coordinate) + in PluginChatCoordinateParser.Find(span.Text)) { - if (!PluginChatCoordinateParser.TryParse(match.Value, out PluginChatCoordinate coordinate)) - continue; if (result is null) { result = new List(spans.Count + 2); for (int j = 0; j < i; j++) result.Add(spans[j]); } - if (match.Index > runStart) - result.Add(span with { Text = span.Text[runStart..match.Index] }); - result.Add(span with { Text = match.Value, Tag = TagFor(coordinate) }); - runStart = match.Index + match.Length; + 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) diff --git a/src/AcDream.Core/Chat/ChatTagMarkup.cs b/src/AcDream.Core/Chat/ChatTagMarkup.cs index d0f2d04c5..e9cdb27bf 100644 --- a/src/AcDream.Core/Chat/ChatTagMarkup.cs +++ b/src/AcDream.Core/Chat/ChatTagMarkup.cs @@ -78,9 +78,10 @@ public readonly record struct ChatTextSpan( public static class ChatTagMarkup { /// - /// Whether the text holds at least one start tag (<TYPE:FORMAT…>). - /// Text a plugin writes is read as markup only when it does, so a stray - /// < in an ordinary line is shown as it was written. + /// 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>", is not a tag. /// public static bool ContainsTag(string? text) { @@ -91,15 +92,92 @@ public static bool ContainsTag(string? text) int close = text.IndexOf('>', open + 1); if (close < 0) return false; - if (TryParseStartTag(text[(open + 1)..close]) is not null) + if (TryParseLinkTag(text[(open + 1)..close]) is not null) return true; } return false; } - /// The text with its markup removed: what shows. + /// + /// 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. + /// + public static IReadOnlyList ParseLinks(string? text) + { + if (string.IsNullOrEmpty(text)) + return Array.Empty(); + + var spans = new List(); + ChatTextTag? open = null; + int runStart = 0; + + for (int i = text.IndexOf('<'); i >= 0; i = text.IndexOf('<', i + 1)) + { + int close = text.IndexOf('>', i + 1); + if (close < 0) + break; + + string inner = text[(i + 1)..close]; + ChatTextTag? next; + if (TryParseLinkTag(inner) is { } start) + next = start; + else if (open is { } current && IsEndTag(inner, current)) + next = null; + else + continue; // not markup: shown as written + + if (i > runStart) + spans.Add(new ChatTextSpan(text[runStart..i], open)); + open = next; + runStart = close + 1; + i = close; + } + + if (runStart < text.Length) + spans.Add(new ChatTextSpan(text[runStart..], open)); + + return spans; + } + + /// A plugin's line with its link tags removed: what shows. public static string ToPlainText(string? text) => - string.Concat(Parse(text).Select(static span => span.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) { 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 ccbaf3e03..7eab6a369 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,11 +931,15 @@ IReadOnlyList CaptureMessages(ulong afterSequence) => Array.Empty(); /// - /// Raised when the player clicks a link in the chat window. A coordinate - /// pair written in any line but combat text ("meet at 42.1N 33.5E") is a - /// link, and so is a <COORD:World:42.1N, 33.5E>…<\COORD> - /// tag in a line a plugin posted. Clicking a player's name starts a tell - /// instead and raises nothing. + /// 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 { @@ -1006,7 +1014,9 @@ IDisposable RegisterInputInterceptor( /// Writes a line into the client's own text, visible only to this /// 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 . + /// 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. ///