Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
087f346
fix(vendor): a shop listing with zero stock is unlimited
davidc-smith Oct 10, 2026
767b5a9
fix(navigation): a place in a dungeon cell stands on the floor under it
davidc-smith Oct 12, 2026
46f3093
feat(markup): a list column can draw a button in each row
davidc-smith Oct 14, 2026
dcdb31f
feat(chat): coordinates in chat are links that reach plugins
davidc-smith Oct 14, 2026
b5f7ea2
fix(items): a carried spell focus no longer blocks inventory complete…
davidc-smith Oct 16, 2026
1c08ff7
Revert "fix(vendor): a shop listing with zero stock is unlimited"
eriknihlen Oct 19, 2026
38ef960
fix(vendor): list every shop row at open, whatever its amount
eriknihlen Oct 19, 2026
bac30f9
fix(chat): coordinate links only on plugin lines; no links from playe…
eriknihlen Oct 19, 2026
ea56eee
refactor(markup): give UiControlState its own file and make it internal
eriknihlen Oct 19, 2026
d35393a
fix(markup): list buttons fire only for the row pressed, and look dis…
eriknihlen Oct 19, 2026
e58b78f
fix(markup): a list button's press survives changes in other columns
eriknihlen Oct 19, 2026
bcb4ba8
fix(navigation): a dungeon place on an object top stays on it
eriknihlen Oct 19, 2026
ca0cc66
fix(chat): escape helper for relayed text, invariant link names, stat…
eriknihlen Oct 19, 2026
24ddf42
fix(navigation): keep a place on a surface only at foot height
eriknihlen Oct 19, 2026
0b3b018
Merge PR #218: a shop row is listed whatever its amount
eriknihlen Oct 19, 2026
e522442
Merge PR #222: a place in a dungeon cell stands on the floor under it
eriknihlen Oct 19, 2026
c92c14b
Merge PR #223: a markup list column can draw a button in each row
eriknihlen Oct 19, 2026
035c3a1
Merge PR #224: coordinate links on plugin chat lines reach plugins
eriknihlen Oct 19, 2026
1c06ad7
Merge PR #226: a carried spell focus no longer blocks inventory compl…
eriknihlen Oct 19, 2026
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
4 changes: 2 additions & 2 deletions docs/navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ Everything is on `host.Automation.Navigation`. A host without navigation answers
| `GoTo(objectId, arrivalMeters)` | Walks to an object and ends beside it, facing it, within `arrivalMeters` (above 0, at most 50) at a spot with a line of sight to it. |
| `GoTo(position, arrivalMeters)` | Walks to a place and ends on the floor that place stands on. Faces nothing on arrival. A position with no cell, such as one read from a route file, is placed by its map coordinates alone, and an elevation of NaN stands it on the ground there. |
| `PreviewPathAsync(objectId, arrivalMeters)` | Plans a path to an object and returns ordered cell-aware positions with elevation. Does not start or replace a walk. |
| `PreviewPathAsync(position, arrivalMeters)` | Plans to a place on its specified floor, including inside a dungeon. Does not start or replace a walk. |
| `PreviewPathAsync(position, arrivalMeters)` | Plans to a place on its specified floor, including inside a dungeon. A place in a dungeon or building cell is stood on the floor under it (see [Arriving](#arriving)), so a dungeon map cell's `Center` can be given as it is. Does not start or replace a walk. |
| `StandOn(objectId, arrivalMeters)` | Walks onto an object and ends on its top, jumping up where the character can. |
| `Follow(playerId, bufferMeters)` | Follows a player until stopped: see [Following](#following). Only players can be followed. |
| `StopGoTo()` | Ends the walk under way; `Rejected` when there is none. |
Expand Down Expand Up @@ -320,7 +320,7 @@ Reports are snapshots, safe to read on any plugin tick. Check that `Sequence` is
| Goal | Where the walk ends |
|---|---|
| An object (`go to`) | Beside it, within the arrival distance of its side, at a spot that can see that side, facing it. An object whose collision is in the grid, such as a life stone or a sign, hides its own middle, so its side is what counts. Failing that, the nearest spot that can see it, up to 10 m away, as `Arrived` with a reason. Failing that, the nearest spot a walk reaches, as `ArrivedWithoutSight`. |
| A place (`go to` a cell or map point) | On the floor the place stands on, within the arrival distance. A place on a rock top is arrived at on the rock, or not at all: a route that only reaches the ground beside it ends `NoRoute`. A place joined to the ground by a ramp or stairs is reached up them. |
| A place (`go to` a cell or map point) | On the floor the place stands on, within the arrival distance. A place on a rock top is arrived at on the rock, or not at all: a route that only reaches the ground beside it ends `NoRoute`. A place joined to the ground by a ramp or stairs is reached up them. A place in a dungeon or building cell is first checked against the face-up surfaces the route grid holds there: a floor, or the top of an object such as a bridge, a platform or a crate. If one lies no more than 0.5 m under the place, the place stays where it is; if one lies no more than 0.5 m over the place, the place is lifted onto that surface. So a route waypoint recorded on a platform is arrived at on the platform, while a room's middle, well over a table there, is not stopped on the table. Any other place in such a cell drops to the floor under it: its own cell's floor nearest at or below the place, or where that cell has no floor there, as in the upper half of a tall room authored as a cell on top of a cell, the highest floor under the place of any cell in the landblock. That search takes every cell in the landblock, so a place over a pit or a shaft drops to the pit's bottom. A place in such a cell given with an elevation of NaN first takes the height of the ground there (or the character's height, where no ground is loaded) and then drops the same way, so in a dungeon such a place ends on a floor under that height. Preview and walk do all this the same way. |
| A player (`follow`) | Never ends by itself: see [Following](#following). |
| An object's top (`stand on`) | On the highest floor over the object's own collision that a body stands on, within the arrival distance of that top's middle. A ramp or stair up to a deck is not the deck. An object with nothing on top to stand on, such as a creature or a thin wall, or no way up, ends `NoRoute`. |

Expand Down
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
34 changes: 30 additions & 4 deletions docs/plugin-ui-markup.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,13 +313,14 @@ The two forms cannot be mixed on one list.
selected="{SelectedMonster}" onchange="{SelectMonster}">
<column type="check" width="20" values="{MonsterFlags}" onchange="{ToggleFlag}"/>
<column type="text" width="127" items="{MonsterNames}" onclick="{PingMonster}"/>
<column type="icon" width="*" iconkind="did" values="{MonsterIcons}" onclick="{MoveUp}"/>
<column type="icon" width="20" iconkind="did" values="{MonsterIcons}" onclick="{MoveUp}"/>
<column type="button" width="*" items="{MonsterActions}" onclick="{ActOnMonster}"/>
</list>
```

| Attribute | Column type | Required | Binding |
|---|---|---|---|
| `type` | all | yes | `text`, `check`, or `icon` |
| `type` | all | yes | `text`, `check`, `icon`, or `button` |
| `width` | all | see below | pixels, or `*` for auto |
| `items` | `text` | yes | `IReadOnlyList<string>` |
| `colors` | `text` | no | `IReadOnlyList<uint>` or `<int>`, `0xRRGGBB` per row |
Expand All @@ -329,19 +330,44 @@ The two forms cannot be mixed on one list.
| `values` | `icon` | yes | `IReadOnlyList<uint>` or `<int>` |
| `iconkind` | `icon` | no | `did` (default), `spell`, or `item` |
| `onclick` | `icon` | yes | `Action<int>` with the row index |
| `items` | `button` | yes | `IReadOnlyList<string>`, each row's caption; an empty caption draws no button |
| `onclick` | `button` | yes | `Action<int>` with the row index; the click does not select the row |
| `buttonw`, `buttonh` | `button` | no | pixels; the button's size, centred in its cell (default: the whole cell) |

Widths: `*` shares the remaining width equally with every other `*` column.
The last column is always automatic; it takes the whole remainder unless
another column is also `*`. Every other column needs a positive `width` or
`*`. Columns that would overflow the list are clamped and later ones draw
nothing. Remainder pixels go to the last sharing column.

Rows: the row count is the longest bound column. A `text` or `icon` cell past
its own column's data draws nothing; a `check` cell draws unchecked. A click
Rows: the row count is the longest bound column. A `text`, `icon` or `button`
cell past its own column's data draws nothing; a `check` cell draws unchecked. A click
in a `text` column without `onclick` selects the row and fires the list's
`onchange`; any other click fires the column's own callback and leaves the
selection alone. A click past a column's own data does nothing.

A `button` cell is drawn as a button one pixel inside its cell, or at
`buttonw` by `buttonh` centred in the cell (never larger than the cell; set
either alone to size only that side), with its caption centred in the list's
text colour and cut off at the button's edges. Without a theme its face and
border are those of an unthemed `<button>`. On a themed window the face is the
theme's background colour (the list itself is drawn in the field colour that
a themed `<button>` uses) and the border is the theme's border colour. Unlike
a `<button>`, it lightens while hovered and darkens while pressed. A disabled
list draws its buttons' face, border and caption faded and fires none of them.

It fires like a `<button>`, when the left button comes up over the button it
went down on, so a press dragged off the button cancels it; a click beside a
smaller button does nothing. Because `onclick` gets a row index, a press is
also cancelled if, before the release, the list scrolls, its row count changes,
or the pressed button's own caption changes or becomes empty. Changes in the
row's other columns, such as a distance that updates every frame, do not
cancel it, so a plugin that reorders rows with the same count should change
the caption too, or the click fires for whatever item is at the pressed row.
A cell with an empty caption is not a button and ignores clicks.
On a host older than button columns, a `type="button"` column fails the build
of its window as an unknown type.

There is no header row; place `<label>` elements above the list. A `check`
cell draws the same lamp glyph as `<toggle>`. When rows overflow the list's
height it reserves a 16-pixel scrollbar at its right edge, drawn with the
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
23 changes: 17 additions & 6 deletions src/AcDream.App/UI/Layout/VendorUiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -619,7 +619,7 @@ private void RebuildItemList(bool reselectFirst)
foreach (VendorShopItem item in items)
{
if (((item.ItemType ?? 0u) & maskValue) == 0u) continue;
if (AvailableShopQuantity(item) <= 0) continue;
if (IsShopSupplyConsumedByStaging(item)) continue;

firstItem ??= item;
if (item.ItemGuid == selectedGuid) selectedStillVisible = true;
Expand Down Expand Up @@ -670,12 +670,23 @@ private void RebuildItemList(bool reselectFirst)
}
}

private int AvailableShopQuantity(VendorShopItem item)
/// <summary>
/// True when staging has used up a limited shop row, which takes it out of
/// the browse list. A row that is not staged is always listed, whatever
/// amount the vendor sent, 0 included. A staged row with a limited amount
/// is removed once the listed amount minus the total staged is 0 or less,
/// so a row listed with 0 leaves the list as soon as anything is staged.
/// Only <see cref="VendorComponentFill.UnlimitedStock"/> means the vendor
/// never runs out. The remainder is recomputed from the untouched listing
/// on every call, so un-staging puts the row back without a restore path.
/// </summary>
private bool IsShopSupplyConsumedByStaging(VendorShopItem item)
{
if (item.StackSize < 0)
return int.MaxValue;
int staged = _buyStaging.TryGet(item.ItemGuid, out VendorStagingEntry entry) ? entry.Quantity : 0;
return item.StackSize - staged;
if (item.StackSize == VendorComponentFill.UnlimitedStock)
return false;
if (!_buyStaging.TryGet(item.ItemGuid, out VendorStagingEntry entry))
return false;
return item.StackSize - entry.Quantity <= 0;
}

private void RefreshItemsTabAvailability() => RebuildItemList(reselectFirst: false);
Expand Down
19 changes: 18 additions & 1 deletion src/AcDream.App/UI/MarkupDocument.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1009,12 +1009,29 @@ private static UiMarkupListColumn BuildListColumn(
: null;
return UiMarkupListColumn.Icon(width, valuesSource, resolve, onClick, isAutoWidth);
}
case "button":
{
var captionSource = BindStringList(
(string?)columnEl.Attribute("items"), binding, ColumnContext(index, "button", "items"));
var onClick = BindRequiredIntAction(
(string?)columnEl.Attribute("onclick"), binding, ColumnContext(index, "button", "onclick"));
return UiMarkupListColumn.Button(width, captionSource, onClick, isAutoWidth,
ButtonSize(columnEl, "buttonw", index), ButtonSize(columnEl, "buttonh", index));
}
default:
throw new FormatException(
$"column[{index}] has unknown type=\"{type}\" (expected text, check, or icon)");
$"column[{index}] has unknown type=\"{type}\" (expected text, check, icon, or button)");
}
}

private static float ButtonSize(XElement columnEl, string attribute, int index)
{
float size = FOr(columnEl, attribute, 0f);
if (size < 0f)
throw new FormatException($"{ColumnContext(index, "button", attribute)} must not be negative");
return size;
}

private static string ColumnContext(int index, string type, string attribute) =>
$"column[{index}] type=\"{type}\" {attribute}";

Expand Down
3 changes: 3 additions & 0 deletions src/AcDream.App/UI/PluginMarkupTheme.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,13 @@ void Bind(Func<UiDatFont?> get, Action<UiDatFont?> set)
case UiMarkupList list:
var lb = list.BackgroundColor; var le = list.BorderColor; var lt = list.TextColor;
var ls = list.SelectedColor; var band = list.SelectionBandEnabled;
var lbf = list.ButtonColor; var lbe = list.ButtonBorderColor;
panel.AddThemeAction(p => {
list.ThemePalette = p; list.BackgroundColor = p?.Field ?? lb;
list.BorderColor = p?.Border ?? le; list.TextColor = p?.Text ?? lt;
list.SelectedColor = p?.Selected ?? ls; list.SelectionBandEnabled = p is not null || band;
// The list's own ground is the field colour, so its buttons take the window's.
list.ButtonColor = p?.Background ?? lbf; list.ButtonBorderColor = p?.Border ?? lbe;
});
break;
case UiScrollbar scroll:
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
4 changes: 4 additions & 0 deletions src/AcDream.App/UI/UiControlState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace AcDream.App.UI;

/// <summary>The look a control takes from the pointer and its own enabled flag.</summary>
internal enum UiControlState { Normal, Hovered, Pressed, Disabled }
Loading
Loading