Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/plugin-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <COORD:World:42.1N, 33.5E>the Holtburg bridge<\COORD>");
```

The only tag read is a well-formed `<COORD:World:pair>`, closed by
`<\COORD>`. Any other angle-bracket text ("Press <Ctrl:F>") 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/AcDream.App/UI/Layout/ChatWindowController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 3 additions & 1 deletion src/AcDream.App/UI/RetailUiRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ public sealed record ChatRuntimeBindings(
Func<ICommandBus> CommandBus,
ChatWindowState Windows,
AcDream.Runtime.Chat.RuntimeChatEntryOwner Entry,
SettingsStore? Store = null);
SettingsStore? Store = null,
Action<PluginChatLinkClicked>? LinkClicked = null);

public sealed record RadarRuntimeBindings(
Func<UiRadarSnapshot> Snapshot,
Expand Down Expand Up @@ -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,
Expand Down
81 changes: 81 additions & 0 deletions src/AcDream.Core/Chat/ChatCoordinateLinks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using System.Globalization;
using AcDream.Plugin.Abstractions;

namespace AcDream.Core.Chat;

/// <summary>
/// Turns the coordinate pairs written in a plugin's chat line ("meet at
/// 42.1N 33.5E") into the same <c>COORD:World</c> links the line's own markup
/// makes, so a click on one reaches plugins as
/// <see cref="PluginChatLinkKind.Coordinate"/>. Only a plugin's own line is
/// linked; what players and the server write is shown as it arrived.
/// </summary>
public static class ChatCoordinateLinks
{
/// <summary>
/// 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
/// <see cref="PluginChatCoordinateParser.ParseAll"/> reads it.
/// </summary>
/// <returns>The new spans, which rejoin to the same text; null when the
/// line holds no pair.</returns>
public static IReadOnlyList<ChatTextSpan>? Link(IReadOnlyList<ChatTextSpan> spans)
{
ArgumentNullException.ThrowIfNull(spans);
List<ChatTextSpan>? 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<ChatTextSpan>(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;
}

/// <summary>The link a coordinate is carried by, in the chat markup's own form.</summary>
public static ChatTextTag TagFor(PluginChatCoordinate coordinate) =>
new(
"COORD",
"World",
$"{Axis(coordinate.NorthSouth, 'N', 'S')}, {Axis(coordinate.EastWest, 'E', 'W')}");

/// <summary>
/// How a clicked coordinate is named to plugins: "42.1N, 33.5W", to at most
/// three decimals, written the same in every locale.
/// </summary>
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);
}
16 changes: 11 additions & 5 deletions src/AcDream.Core/Chat/ChatLineWording.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,28 @@ namespace AcDream.Core.Chat;
/// </summary>
public static class ChatLineWording
{
/// <summary>The line as plain text, with no clickable sender marked up.</summary>
/// <summary>
/// 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.
/// </summary>
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);

/// <summary>
/// The line with another player's name wrapped so a front end that supports
/// it can make the name clickable. Identical to <see cref="Format(ChatEntry)"/>
/// 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
/// <see cref="Format(ChatEntry)"/> for every other line.
/// </summary>
public static string FormatTagged(ChatEntry entry)
=> ShouldTagSender(entry)
? Format(
entry,
sender =>
$"<Tell:IIDString:{entry.SenderGuid}:{sender}>{sender}<\\Tell>")
: Format(entry);
: Format(entry, static sender => sender);

/// <summary>Whether this line names another player we could click to tell.</summary>
// A channel line names its speaker without an object id (allegiance,
Expand Down
15 changes: 15 additions & 0 deletions src/AcDream.Core/Chat/ChatLog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@ public void OnTellReceived(
}

public void OnSystemMessage(string text, uint chatType)
=> OnSystemMessage(text, chatType, allowsMarkup: false);

/// <param name="allowsMarkup">
/// True for a line the client itself wrote on a plugin's behalf. Only its
/// <c>&lt;COORD:World:pair&gt;</c> tags are read, as links; any other
/// angle-bracket text is shown as written. Server text is never read.
/// </param>
public void OnSystemMessage(string text, uint chatType, bool allowsMarkup)
{
// Identical text can describe distinct events and must retain each occurrence.
Append(new ChatEntry(
Expand All @@ -168,6 +176,7 @@ public void OnSystemMessage(string text, uint chatType)
ChannelId: chatType)
{
LogTextType = chatType,
AllowsMarkup = allowsMarkup,
});
}

Expand Down Expand Up @@ -315,6 +324,12 @@ public readonly record struct ChatEntry(

public uint LogTextType { get; init; } = 0x00u;

/// <summary>
/// Whether <see cref="Text"/> may carry chat markup (a link such as
/// <c>&lt;COORD:World:42.1N, 33.5E&gt;…</c>). Only a line a plugin wrote does.
/// </summary>
public bool AllowsMarkup { get; init; }

/// <summary>
/// Who a received tell was addressed to; 0 on every other line, and when
/// the listener is not known.
Expand Down
114 changes: 114 additions & 0 deletions src/AcDream.Core/Chat/ChatTagMarkup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,120 @@ public readonly record struct ChatTextSpan(

public static class ChatTagMarkup
{
/// <summary>
/// Whether a plugin's line holds at least one link tag
/// <see cref="ParseLinks"/> reads: a well-formed
/// <c>&lt;COORD:World:42.1N, 33.5E&gt;</c>. Any other angle-bracket text,
/// such as "Press &lt;Ctrl:F&gt;", and an escaped tag are not tags.
/// </summary>
public static bool ContainsTag(string? text) =>
!string.IsNullOrEmpty(text)
&& text.IndexOf('<') >= 0
&& ParseLinks(text).Any(static span => span.Tag is not null);

/// <summary>
/// Reads the link tags in a line a plugin posted. Only a well-formed
/// <c>&lt;COORD:World:pair&gt;</c> opens a link, and only its own
/// <c>&lt;\COORD&gt;</c> 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 <c>&lt;</c> is dropped and that <c>&lt;</c> is shown as
/// written, never read as a tag, which is how
/// <see cref="AcDream.Plugin.Abstractions.PluginChatMarkup.Escape"/> makes
/// text show literally.
/// </summary>
public static IReadOnlyList<ChatTextSpan> ParseLinks(string? text)
{
if (string.IsNullOrEmpty(text))
return Array.Empty<ChatTextSpan>();

var spans = new List<ChatTextSpan>();
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;
}

/// <summary>A plugin's line with its link tags removed: what <see cref="ParseLinks"/> shows.</summary>
public static string ToPlainText(string? text) =>
string.Concat(ParseLinks(text).Select(static span => span.Text));

/// <summary>
/// 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.
/// </summary>
public static IReadOnlyList<ChatTextSpan> WithoutCoordinateTags(IReadOnlyList<ChatTextSpan> 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<ChatTextSpan> Parse(string? text)
{
if (string.IsNullOrEmpty(text))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,10 @@
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>

<!-- The client's chat links coordinates with the same matcher the public parser uses, so a
pair is a link exactly when a plugin would read it. -->
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Core" />
</ItemGroup>
</Project>
Loading