From 087f3466b8d5c6692a2f3972b1fe4fe1a021bcdf Mon Sep 17 00:00:00 2001 From: David Smith Date: Sat, 10 Oct 2026 23:10:41 +0200 Subject: [PATCH 01/14] fix(vendor): a shop listing with zero stock is unlimited ACE sends a shop create-list row's authored stack size unchanged, and world content authors many of those rows as 0 rather than -1. ACE never limits a create-list item, but the vendor window only treated a negative stock as unlimited, so it hid every zero-stock row and the vendor looked empty. Component auto-fill also capped those rows at nothing. The ApproachVendor wiring now records any non-positive stock as -1. Claude-Session: https://claude.ai/code/session_01T9gYq4uZVPNw3DPW9cnpE9 --- src/AcDream.Core.Net/GameEventWiring.cs | 6 ++- .../Gameplay/RuntimeVendorLifecycleTests.cs | 40 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 339af09a7..f4f47db1b 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -697,9 +697,13 @@ void NotifyOfEnchantmentRemoval(uint spellId) for (int i = 0; i < shopItems.Length; i++) { VendorApproach.ItemProfile item = p.Value.Items[i]; + // ACE sends a shop create-list row's authored stack size + // as-is, and world content authors many of them as 0 rather + // than -1. ACE never limits a create-list item, so any + // non-positive count means the vendor never runs out. shopItems[i] = new VendorShopItem( item.ItemGuid, - item.StackSize, + item.StackSize > 0 ? item.StackSize : VendorComponentFill.UnlimitedStock, item.Desc.WeenieClassId, item.Desc.Name, item.Desc.ItemType, diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorLifecycleTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorLifecycleTests.cs index 1c10f11ec..0a1d2c225 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorLifecycleTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorLifecycleTests.cs @@ -65,6 +65,46 @@ public void ApproachVendorEvent_PopulatesVendorStateWithProfileAndItems() Assert.Equal(250, item.Value); } + [Fact] + public void ApproachVendorEvent_ZeroStockListing_IsUnlimited() + { + using GameRuntime runtime = Create(); + VendorState vendor = runtime.InventoryOwner.Vendor; + using IDisposable wiring = Wire(vendor); + + // ACE sends a shop create-list row's authored stack size as-is, and + // world content authors many of them as 0. ACE never limits a + // create-list item, so 0 carries the same meaning as -1. + Dispatch(BuildApproachVendorPayload( + vendorGuid: 0x40001000u, + categories: 0x42u, + minValue: 0u, + maxValue: 500u, + dealsMagic: false, + buyPrice: 0.8f, + sellPrice: 1.3f, + currencyWcid: 0u, + currencyAmount: 0u, + currencyName: "", + items: + [ + new VendorItemFixture( + ItemGuid: 0x50002000u, StackSize: 0, Name: "Iron Sword", + WeenieClassId: 42u, RawIconId: 0x1234u, + ItemType: (uint)ItemType.Weapon, Value: 250), + new VendorItemFixture( + ItemGuid: 0x50002001u, StackSize: -1, Name: "Bread", + WeenieClassId: 43u, RawIconId: 0x1235u, + ItemType: (uint)ItemType.Food, Value: 5), + new VendorItemFixture( + ItemGuid: 0x50002002u, StackSize: 3, Name: "Leather Cap", + WeenieClassId: 44u, RawIconId: 0x1236u, + ItemType: (uint)ItemType.Armor, Value: 40), + ])); + + Assert.Equal([-1, -1, 3], vendor.Items.Select(i => i.StackSize)); + } + [Fact] public void SecondApproachVendorEventFromDifferentVendor_ReplacesTheSession() { From 767b5a94d8d58c790f19e7bb49a61f0f7185c80d Mon Sep 17 00:00:00 2001 From: David Smith Date: Mon, 12 Oct 2026 17:06:36 +0200 Subject: [PATCH 02/14] fix(navigation): a place in a dungeon cell stands on the floor under it A place walk or preview inside a dungeon kept the height it was given. A dungeon map gives a cell by the middle of its bounds, which in a tall room is metres above the floor, and the router only arrives within 1.2 m of a goal's height, so such a place came back NoRoute. In 0x5B48 no cell routed at all, and in 0x5948 only the low ones did. RuntimeNavigationGoalSource.TryLocatePlace now stands a place in an interior cell on the cell's own floor nearest at or below it (up to 0.5 m above), or, where the cell has no floor there, as in the upper half of a tall room authored as a cell on top of a cell, on the nearest floor of any cell below it. Preview and walk both locate places through it, so they agree. Outdoor places, places with no height, and object goals are unchanged. Against the installed dat, from one start: 0x5948 routes 313 of 327 cell middles (39 before); 0x5B48 routes 265 of 712 (9 before), the rest lying in wings its cells alone do not join. Claude-Session: https://claude.ai/code/session_01Qxrqqq5UccBfoNgzr2T84o --- docs/navigation.md | 4 +- src/AcDream.Core/Physics/CellSurface.cs | 24 +++ .../DungeonMapAutomation.cs | 5 +- .../NavigationAutomation.cs | 8 +- .../RuntimeNavigationWalkSources.cs | 47 ++++- .../DungeonPlaceFloorInstalledDatTests.cs | 89 +++++++++ .../Navigation/DungeonPlaceFloorTests.cs | 175 ++++++++++++++++++ .../NavigationWalkControllerTests.cs | 7 +- .../Physics/CellSurfaceTests.cs | 18 ++ 9 files changed, 367 insertions(+), 10 deletions(-) create mode 100644 tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorInstalledDatTests.cs create mode 100644 tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorTests.cs diff --git a/docs/navigation.md b/docs/navigation.md index ff6b3c375..43e1b7a55 100644 --- a/docs/navigation.md +++ b/docs/navigation.md @@ -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 stands on the floor under it, 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. | @@ -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 stood on the floor under it: the cell's own floor nearest at or below it (or up to 0.5 m above it), or where the cell has none there, as in the upper half of a tall room authored as a cell on top of a cell, the nearest floor of any cell below it. Preview and walk place it 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`. | diff --git a/src/AcDream.Core/Physics/CellSurface.cs b/src/AcDream.Core/Physics/CellSurface.cs index e363fd9ea..909fcd1db 100644 --- a/src/AcDream.Core/Physics/CellSurface.cs +++ b/src/AcDream.Core/Physics/CellSurface.cs @@ -98,6 +98,30 @@ public CellSurface( return null; } + /// + /// The height of the highest level face under a point that is no higher than + /// , or null where the cell has none: the floor a point + /// in the cell stands on, not the ceiling over it. + /// + public float? SampleLevelZAtOrBelow(float worldX, float worldY, float atMostZ) + { + float? highest = null; + foreach (var (a, b, c) in _triangles) + { + Vector3 normal = Vector3.Cross(b - a, c - a); + float length = normal.Length(); + if (length <= 1e-6f || MathF.Abs(normal.Z) / length < PhysicsGlobals.FloorZ) + continue; + if (PointInTriangleXY(worldX, worldY, a, b, c, out float z) + && z <= atMostZ + && (highest is null || z > highest)) + { + highest = z; + } + } + return highest; + } + private static bool PointInTriangleXY( float px, float py, diff --git a/src/AcDream.Plugin.Abstractions/DungeonMapAutomation.cs b/src/AcDream.Plugin.Abstractions/DungeonMapAutomation.cs index 3a896f2e2..13a647801 100644 --- a/src/AcDream.Plugin.Abstractions/DungeonMapAutomation.cs +++ b/src/AcDream.Plugin.Abstractions/DungeonMapAutomation.cs @@ -14,7 +14,10 @@ namespace AcDream.Plugin.Abstractions; /// One cell of a floorplan: where it is and which layer it was drawn in. /// /// The full cell id, landblock in the high half. -/// The middle of the cell's geometry, in landblock-local metres. +/// +/// The middle of the cell's geometry, in landblock-local metres. In a tall room it is well +/// above the floor; a walk or preview to it stands it on the floor under it. +/// /// The of the layer the cell is drawn in. public readonly record struct PluginDungeonCell(uint CellId, Vector3 Center, float LayerZ); diff --git a/src/AcDream.Plugin.Abstractions/NavigationAutomation.cs b/src/AcDream.Plugin.Abstractions/NavigationAutomation.cs index 7081801cc..b40c19bd4 100644 --- a/src/AcDream.Plugin.Abstractions/NavigationAutomation.cs +++ b/src/AcDream.Plugin.Abstractions/NavigationAutomation.cs @@ -475,7 +475,9 @@ Task PreviewPathAsync(uint objectId, float arrivalMeters = /// /// Plans a path to a cell-aware position without moving or taking ownership of /// the character. Call from the thread that raises . - /// An elevation of NaN selects ground at the destination. + /// An elevation of NaN selects ground at the destination. A position in a dungeon or + /// building cell stands on the floor under it, so a + /// may be given as it is. /// Task PreviewPathAsync(PluginNavigationPosition position, float arrivalMeters = 2.5f) => Task.FromResult(new PluginNavigationPlan(PluginNavigationPlanStatus.Unavailable, [], "navigation is unavailable")); @@ -656,7 +658,9 @@ PluginNavigationCommandStatus GoTo(uint objectId, float arrivalMeters) => /// of on the floor that /// position stands on, and waiting while something else needs the character. A position /// on a rock top is arrived at on the rock, or not at all. An elevation of NaN stands the - /// position on the ground there, for map coordinates given without a height. It does not turn the character to face + /// position on the ground there, for map coordinates given without a height. A position in + /// a dungeon or building cell stands on the floor under it, the floor of the cell below + /// where its own cell has none there, as in the upper half of a tall room. It does not turn the character to face /// anything on arrival. A position with no cell, such as one read from a VTank route /// file, is placed by its map coordinates alone. reports the /// walk with no object id. diff --git a/src/AcDream.Runtime/Navigation/RuntimeNavigationWalkSources.cs b/src/AcDream.Runtime/Navigation/RuntimeNavigationWalkSources.cs index 272f222df..e9c800b09 100644 --- a/src/AcDream.Runtime/Navigation/RuntimeNavigationWalkSources.cs +++ b/src/AcDream.Runtime/Navigation/RuntimeNavigationWalkSources.cs @@ -405,13 +405,54 @@ public bool TryLocatePlace(uint cellId, Vector3 local, out Vector3 position) return false; } AcDream.Core.Physics.Position here = controller.CellPosition; - position = OnTheGround( + position = OnTheCellFloor( _physics, - controller.Position + PlaceOffset(cellId, local, here.ObjCellId, here.Frame.Origin), - controller.Position.Z); + cellId, + OnTheGround( + _physics, + controller.Position + PlaceOffset(cellId, local, here.ObjCellId, here.Frame.Origin), + controller.Position.Z)); return true; } + /// + /// How far above a floor a place may be given and still stand on it, so a place given at + /// its floor's height, a little out, is not dropped to a floor below. + /// + internal const float PlaceAboveFloorMeters = 0.5f; + + /// + /// A place in an interior cell stood on the floor under it: the cell's own floor nearest at + /// or below the place, or where the cell has none there, as in the upper half of a tall room + /// authored as a cell on top of a cell, the nearest floor of any cell in its landblock. A + /// place in a cell is often given by the middle of the cell's bounds, which in a tall room + /// is metres above the floor, higher than a walk can arrive at. An outdoor place, or one with + /// no floor under it, is left as it is. + /// + internal static Vector3 OnTheCellFloor(PhysicsEngine physics, uint cellId, Vector3 point) + { + ArgumentNullException.ThrowIfNull(physics); + if ((cellId & 0xFFFFu) < 0x0100u || (cellId & 0xFFFFu) == 0xFFFFu || float.IsNaN(point.Z) + || !physics.TryGetLandblockCollision((cellId & 0xFFFF0000u) | 0xFFFFu, out _, out IReadOnlyList cells, out _)) + { + return point; + } + float atMost = point.Z + PlaceAboveFloorMeters; + float? own = null; + float? any = null; + foreach (CellSurface cell in cells) + { + if (cell.SampleLevelZAtOrBelow(point.X, point.Y, atMost) is not { } z) + continue; + if (cell.CellId == cellId) + own = own is null ? z : MathF.Max(own.Value, z); + any = any is null ? z : MathF.Max(any.Value, z); + } + return own is { } floor ? point with { Z = floor } + : any is { } below ? point with { Z = below } + : point; + } + /// /// A point whose height is not known stood on the terrain under it, or at /// where no terrain under it is loaded yet, as for a far goal diff --git a/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorInstalledDatTests.cs b/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorInstalledDatTests.cs new file mode 100644 index 000000000..c1050edce --- /dev/null +++ b/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorInstalledDatTests.cs @@ -0,0 +1,89 @@ +using System.Numerics; +using AcDream.Core.Navigation; +using AcDream.Core.Physics; +using AcDream.Runtime.Navigation; +using Xunit.Abstractions; + +namespace AcDream.App.Tests.Navigation; + +/// +/// Every cell of an installed dungeon routed to by the middle of its bounds, the place a +/// dungeon map gives for it, as it is given and as it stands on the floor under it. +/// +[Trait("Lane", "InstalledDat")] +[Trait("Purpose", "Diagnostic")] +public sealed class DungeonPlaceFloorInstalledDatTests +{ + private readonly ITestOutputHelper _output; + + public DungeonPlaceFloorInstalledDatTests(ITestOutputHelper output) => _output = output; + + /// + /// 0x5B48's wings are joined by more than its cells, so from its largest wing about 265 of + /// its cells route on the floor and few as given; 0x5948 routes nearly every cell. + /// + [Theory] + [InlineData(0x5B48FFFFu, 0x5B48013Cu)] + [InlineData(0x5948FFFFu, 0x59480100u)] + public void TheMiddleOfEveryCellRoutesOnceItStandsOnTheFloor(uint dungeon, uint startCell) + { + string? directory = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + Assert.False(string.IsNullOrWhiteSpace(directory), "Set ACDREAM_DAT_DIR to the installed game files."); + uint[] cellIds = [.. Enumerable.Range(0x100, 0x400).Select(offset => (dungeon & 0xFFFF0000u) | (uint)offset)]; + PublishedLandblock world = PublishedLandblock.Load(directory!, dungeon, cellIds); + Assert.True(world.Engine.TryGetLandblockCollision(dungeon, out _, out IReadOnlyList cells, out _)); + Assert.True(NavGeometry.TryMeasureCells(world.Engine, dungeon, out Vector2 minimum, out Vector2 maximum)); + float size = MathF.Ceiling((MathF.Max(maximum.X - minimum.X, maximum.Y - minimum.Y) + 48f) / 16f) * 16f; + float originX = MathF.Floor((((minimum.X + maximum.X) * 0.5f) - (size * 0.5f)) / NavGrid.DefaultCellSize) * NavGrid.DefaultCellSize; + float originY = MathF.Floor((((minimum.Y + maximum.Y) * 0.5f) - (size * 0.5f)) / NavGrid.DefaultCellSize) * NavGrid.DefaultCellSize; + NavGrid grid = NavGrid.Build(NavGeometry.CaptureDungeon(world.Engine, dungeon, originX, originY, size)!, world.Body); + + int startNode = grid.FindWalkableNode(world.CellOrigins[startCell], 1f, 1.5f); + Assert.True(startNode >= 0); + Vector3 start = grid.Position(startNode); + float arrival = NavigationWalkController.PlanningRadius(2.5f); + int asGiven = 0; + int onTheFloor = 0; + int tall = 0; + int floorless = 0; + var reasons = new Dictionary(); + var flooredReasons = new Dictionary(); + foreach (CellSurface cell in cells) + { + var low = new Vector3(float.PositiveInfinity); + var high = new Vector3(float.NegativeInfinity); + foreach ((Vector3 a, Vector3 b, Vector3 c) in cell.Triangles) + { + low = Vector3.Min(low, Vector3.Min(a, Vector3.Min(b, c))); + high = Vector3.Max(high, Vector3.Max(a, Vector3.Max(b, c))); + } + if (float.IsInfinity(low.X)) + continue; + Vector3 middle = (low + high) * 0.5f; + Vector3 placed = RuntimeNavigationGoalSource.OnTheCellFloor(world.Engine, cell.CellId, middle); + if (middle.Z - placed.Z > NavRouter.GoalHeightTolerance) + tall++; + if (cell.SampleLevelZAtOrBelow(middle.X, middle.Y, middle.Z) is null) + floorless++; + NavRoute raw = NavRouter.Find(grid, start, middle, arrival, arriveOnGoalFloor: true); + if (raw.Outcome == NavRouteOutcome.Routed) + asGiven++; + else + reasons[raw.Reason ?? "(none)"] = reasons.GetValueOrDefault(raw.Reason ?? "(none)") + 1; + NavRoute floored = NavRouter.Find(grid, start, placed, arrival, arriveOnGoalFloor: true); + if (floored.Outcome == NavRouteOutcome.Routed) + onTheFloor++; + else + flooredReasons[floored.Reason ?? "(none)"] = flooredReasons.GetValueOrDefault(floored.Reason ?? "(none)") + 1; + } + + _output.WriteLine($"0x{dungeon >> 16:X4}: {cells.Count} cells, {tall} with their middle higher over the floor than a walk arrives, {floorless} with no floor of their own under it"); + _output.WriteLine($" routed as given: {asGiven}; routed on the floor: {onTheFloor}"); + foreach ((string reason, int count) in reasons.OrderByDescending(pair => pair.Value).Take(5)) + _output.WriteLine($" {count} as given: {reason}"); + foreach ((string reason, int count) in flooredReasons.OrderByDescending(pair => pair.Value).Take(5)) + _output.WriteLine($" {count} on the floor: {reason}"); + Assert.True(onTheFloor > asGiven); + } + +} diff --git a/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorTests.cs b/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorTests.cs new file mode 100644 index 000000000..7bf61a429 --- /dev/null +++ b/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorTests.cs @@ -0,0 +1,175 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Plugin.Abstractions; +using AcDream.Runtime.Navigation; +using static AcDream.App.Tests.Navigation.NavigationWalkControllerTests; + +namespace AcDream.App.Tests.Navigation; + +/// +/// A place inside a dungeon cell is often given by the middle of the cell's bounds, which in a +/// tall room is metres above its floor. Such a place stands on the floor under it. +/// +public sealed class DungeonPlaceFloorTests +{ + private const uint LowRoom = 0xA9B40100u; + private const uint TallRoom = 0xA9B40101u; + private const uint StackedLower = 0xA9B40102u; + private const uint StackedUpper = 0xA9B40103u; + private const float Floor = -30f; + + /// The middle of the 5 m room's bounds, 2.5 m over its floor, as a dungeon map gives it. + private static readonly Vector3 TallMiddle = new(30f, 10f, -27.5f); + + /// The middle of the floorless upper cell's bounds, 7.5 m over the floor of the cell below. + private static readonly Vector3 UpperMiddle = new(50f, 10f, -22.5f); + + /// + /// Why the fix is needed: a goal left in mid-air, higher over the floor than a walk can arrive + /// at, has no route at all, though the room is open to walk into. + /// + [Fact] + public async Task APlaceLeftInMidAirOverATallRoomsFloorHasNoRoute() + { + PluginNavigationPlan plan = await Preview(new Goals(), TallRoom, TallMiddle); + + Assert.Equal(PluginNavigationPlanStatus.NoRoute, plan.Status); + } + + [Fact] + public async Task APlaceAtTheMiddleOfATallRoomRoutesToItsFloor() + { + PluginNavigationPlan plan = await Preview(new Goals { Floors = DungeonWithTallRooms() }, TallRoom, TallMiddle); + + Assert.Equal(PluginNavigationPlanStatus.Routed, plan.Status); + Assert.InRange(plan.Path[^1].Elevation * 240d, Floor - 0.1d, Floor + 0.1d); + } + + [Fact] + public async Task APlaceInAFloorlessUpperCellRoutesToTheFloorOfTheCellBelow() + { + PhysicsEngine world = DungeonWithTallRooms(); + + Vector3 placed = RuntimeNavigationGoalSource.OnTheCellFloor(world, StackedUpper, UpperMiddle); + PluginNavigationPlan plan = await Preview(new Goals { Floors = world }, StackedUpper, UpperMiddle); + + Assert.Equal(new Vector3(50f, 10f, Floor), placed); + Assert.Equal(PluginNavigationPlanStatus.Routed, plan.Status); + Assert.Equal(StackedLower, plan.Path[^1].CellId); + } + + [Fact] + public async Task APlaceInALowRoomIsUnchanged() + { + PhysicsEngine world = DungeonWithTallRooms(); + var onTheFloor = new Vector3(15f, 12f, Floor); + var middle = new Vector3(15f, 12f, -28.5f); + + PluginNavigationPlan plan = await Preview(new Goals { Floors = world }, LowRoom, middle); + + Assert.Equal(onTheFloor, RuntimeNavigationGoalSource.OnTheCellFloor(world, LowRoom, onTheFloor)); + Assert.Equal(onTheFloor, RuntimeNavigationGoalSource.OnTheCellFloor(world, LowRoom, middle)); + Assert.Equal(PluginNavigationPlanStatus.Routed, plan.Status); + } + + [Fact] + public void APlaceGivenALittleUnderItsFloorStaysOnThatFloor() + { + PhysicsEngine world = DungeonWithTallRooms(); + + Vector3 placed = RuntimeNavigationGoalSource.OnTheCellFloor(world, TallRoom, new Vector3(30f, 10f, Floor - 0.2f)); + + Assert.Equal(Floor, placed.Z); + } + + [Theory] + [InlineData(0xA9B40001u)] + [InlineData(0xA9B4FFFFu)] + [InlineData(0u)] + public void AnOutdoorPlaceIsUnchanged(uint cellId) + { + PhysicsEngine world = DungeonWithTallRooms(); + var place = new Vector3(30f, 10f, -27.5f); + + Assert.Equal(place, RuntimeNavigationGoalSource.OnTheCellFloor(world, cellId, place)); + } + + [Fact] + public void APlaceWithNoFloorUnderItIsUnchanged() + { + PhysicsEngine world = DungeonWithTallRooms(); + var outside = new Vector3(100f, 100f, -27.5f); + var belowEveryFloor = new Vector3(30f, 10f, -40f); + + Assert.Equal(outside, RuntimeNavigationGoalSource.OnTheCellFloor(world, TallRoom, outside)); + Assert.Equal(belowEveryFloor, RuntimeNavigationGoalSource.OnTheCellFloor(world, TallRoom, belowEveryFloor)); + } + + /// The walk to a place stands its goal where the preview does. + [Fact] + public void AWalkToAPlaceAtTheMiddleOfATallRoomArrives() + { + PhysicsEngine world = DungeonWithTallRooms(); + var body = new SimulatedBody(new Vector3(10f, 10f, Floor)) { CellId = LowRoom }; + var walk = new NavigationWalkController(world, body, new Goals { Floors = world }, isSealedDungeon: _ => true); + + walk.WalkToPlace(TallRoom, TallMiddle, 2.5f); + NavigationWalkReport report = RunUntilSettled(walk, body); + + Assert.Equal(NavigationWalkState.Arrived, report.State); + Assert.InRange(Vector2.Distance(new Vector2(body.Position.X, body.Position.Y), new Vector2(30f, 10f)), 0f, 2.6f); + } + + private static Task Preview(Goals goals, uint cellId, Vector3 local) + { + var body = new SimulatedBody(new Vector3(10f, 10f, Floor)) { CellId = LowRoom }; + var walk = new NavigationWalkController(goals.Floors ?? DungeonWithTallRooms(), body, goals, isSealedDungeon: _ => true); + return walk.PreviewPathAsync(cellId, local, 2.5f); + } + + /// + /// Three rooms in a row 30 m down: a low room 3 m high, a room 5 m high, and a room 10 m + /// high authored as a cell on top of a cell, the upper one with walls and a ceiling but + /// no floor. + /// + internal static PhysicsEngine DungeonWithTallRooms() + { + static CellSurface Cell(uint cellId, float x0, float x1, float? floor, float low, float top, bool roofed = true) + { + var vertices = new Dictionary(); + var polygons = new List>(); + void Add(params Vector3[] corners) + { + var ids = new List(); + foreach (Vector3 corner in corners) + { + ids.Add((short)vertices.Count); + vertices[(ushort)vertices.Count] = corner; + } + polygons.Add(ids); + } + if (floor is { } z) + Add(new(x0, 0f, z), new(x1, 0f, z), new(x1, 20f, z), new(x0, 20f, z)); + if (roofed) + Add(new(x0, 0f, top), new(x0, 20f, top), new(x1, 20f, top), new(x1, 0f, top)); + Add(new(x0, 0f, low), new(x1, 0f, low), new(x1, 0f, top), new(x0, 0f, top)); + Add(new(x0, 20f, low), new(x1, 20f, low), new(x1, 20f, top), new(x0, 20f, top)); + return new CellSurface(cellId, vertices, polygons); + } + + var physics = new PhysicsEngine(); + physics.AddLandblock( + 0xA9B4FFFFu, + new TerrainSurface(new byte[81], new float[256]), + [ + Cell(LowRoom, 0f, 20f, Floor, Floor, -27f), + Cell(TallRoom, 20f, 40f, Floor, Floor, -25f), + Cell(StackedLower, 40f, 60f, Floor, Floor, -25f, roofed: false), + Cell(StackedUpper, 40f, 60f, null, -25f, -20f), + ], + [], + 0f, + 0f); + return physics; + } +} diff --git a/tests/AcDream.App.Tests/Navigation/NavigationWalkControllerTests.cs b/tests/AcDream.App.Tests/Navigation/NavigationWalkControllerTests.cs index edb724e59..f4ec9e4e1 100644 --- a/tests/AcDream.App.Tests/Navigation/NavigationWalkControllerTests.cs +++ b/tests/AcDream.App.Tests/Navigation/NavigationWalkControllerTests.cs @@ -2222,7 +2222,7 @@ private static PhysicsEngine DungeonWorld(uint firstCell, float corridorEnd = 23 return physics; } - private static NavigationWalkReport RunUntilSettled(NavigationWalkController walk, SimulatedBody body, float seconds = 120f) => + internal static NavigationWalkReport RunUntilSettled(NavigationWalkController walk, SimulatedBody body, float seconds = 120f) => RunUntil( walk, body, @@ -2343,10 +2343,13 @@ public IReadOnlyList FindPortals(Vector3 around, float radius, uin [.. Portals.Select(RuntimeNavigationGoalSource.PortalFootprint)], goal); + /// When set, places in a cell stand on its floor as the client's goal source stands them. + public PhysicsEngine? Floors { get; init; } + /// Places stand where their landblock-local point says, the test world having one landblock at its origin. public bool TryLocatePlace(uint cellId, Vector3 local, out Vector3 position) { - position = local; + position = Floors is { } floors ? RuntimeNavigationGoalSource.OnTheCellFloor(floors, cellId, local) : local; return true; } diff --git a/tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs b/tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs index 93e3d4b2c..8dcb49e6d 100644 --- a/tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs @@ -43,6 +43,24 @@ public void SampleFloorZ_InsideFlat_ReturnsZ() Assert.Equal(10f, z!.Value, precision: 2); } + [Fact] + public void SampleLevelZAtOrBelow_TakesTheFloorUnderThePointNotTheCeilingOverIt() + { + var vertices = new Dictionary + { + [0] = new(0f, 0f, 0f), [1] = new(10f, 0f, 0f), [2] = new(10f, 10f, 0f), [3] = new(0f, 10f, 0f), + [4] = new(0f, 0f, 5f), [5] = new(0f, 10f, 5f), [6] = new(10f, 10f, 5f), [7] = new(10f, 0f, 5f), + [8] = new(0f, 0f, 0f), [9] = new(10f, 0f, 0f), [10] = new(10f, 0f, 5f), [11] = new(0f, 0f, 5f), + }; + var surface = new CellSurface(0x0100, vertices, + [new() { 0, 1, 2, 3 }, new() { 4, 5, 6, 7 }, new() { 8, 9, 10, 11 }]); + + Assert.Equal(0f, surface.SampleLevelZAtOrBelow(5f, 5f, 2.5f)); + Assert.Equal(5f, surface.SampleLevelZAtOrBelow(5f, 5f, 6f)); + Assert.Null(surface.SampleLevelZAtOrBelow(5f, 5f, -1f)); + Assert.Null(surface.SampleLevelZAtOrBelow(20f, 5f, 2.5f)); + } + [Fact] public void SampleFloorZ_OutsideFloor_ReturnsNull() { From 46f309337afbfcbf86640e0664abc56d9e02e861 Mon Sep 17 00:00:00 2001 From: David Smith Date: Wed, 14 Oct 2026 12:01:13 +0200 Subject: [PATCH 03/14] feat(markup): a list column can draw a button in each row A `` draws each row's caption on a button with the face and border of a markup ` + private void DrawButtonCell(UiRenderContext context, int column, int index, float cellX, float cellW, float y) + { + if (!HasButtonCaption(column, index, out string caption)) + return; + (float x, float top, float w, float h) = ButtonRect(_columns![column], cellX, cellW, y); + UiControlState state = ButtonState(index, column); + Vector4 face = state switch + { + UiControlState.Hovered => Vector4.Lerp(ButtonColor, ButtonBorderColor, 0.3f) with { W = ButtonColor.W }, + UiControlState.Pressed => Vector4.Lerp(ButtonColor, Vector4.Zero, 0.25f) with { W = ButtonColor.W }, + _ => ButtonColor, + }; + context.DrawFill(x, top, w, h, face); + context.DrawRectOutline(x, top, w, h, ButtonBorderColor, 1f); + Vector4 textColor = state == UiControlState.Disabled ? TextColor with { W = TextColor.W * 0.45f } : TextColor; + + if (DatFont is { } font) + { + context.DrawStringDat(font, caption, + x + (w - font.MeasureWidth(caption)) * 0.5f, + top + (h - font.LineHeight) * 0.5f, + textColor, ThemePalette is null); + } + else if (context.DefaultFont is { } fallback) + { + context.DrawString(caption, + x + (w - fallback.MeasureWidth(caption)) * 0.5f, + top + (h - fallback.LineHeight) * 0.5f, + textColor); + } + } + + /// + /// Where a button sits in its cell: its column's size, centred, never past + /// the cell's one-pixel inset; a size of 0 fills the inset cell. + /// + private (float x, float y, float w, float h) ButtonRect( + UiMarkupListColumn column, float cellX, float cellW, float rowY) + { + float maxW = MathF.Max(0f, cellW - 2f), maxH = MathF.Max(0f, RowHeight - 2f); + float w = column.ButtonWidth > 0f ? MathF.Min(column.ButtonWidth, maxW) : maxW; + float h = column.ButtonHeight > 0f ? MathF.Min(column.ButtonHeight, maxH) : maxH; + return (MathF.Floor(cellX + (cellW - w) * 0.5f), MathF.Floor(rowY + (RowHeight - h) * 0.5f), w, h); + } + + private bool HasButtonCaption(int column, int index, out string caption) + { + caption = string.Empty; + if (_columns is not { } columns || column < 0 || column >= columns.Count + || columns[column].Kind != UiMarkupListColumnKind.Button) + return false; + if (_cachedTextRows[column] is not { } captions || index < 0 || index >= captions.Count) + return false; + caption = captions[index] ?? string.Empty; + return caption.Length > 0; + } + + /// + /// The look of one button cell: pressed only while the pointer is still + /// over the button it went down on, and no other button hovers while one + /// is held. + /// + private UiControlState ButtonState(int index, int column) + { + if (!Enabled) + return UiControlState.Disabled; + bool over = _hoverRow == index && _hoverColumn == column; + if (_pressRow >= 0) + return _pressRow == index && _pressColumn == column && over + ? UiControlState.Pressed + : UiControlState.Normal; + return over ? UiControlState.Hovered : UiControlState.Normal; + } + + internal UiControlState ButtonStateForTest(int index, int column) => + HasButtonCaption(column, index, out _) ? ButtonState(index, column) : UiControlState.Normal; + + /// The button cell at a point inside the list, or (-1, -1) where there is none. + private (int index, int column) ButtonCellAt(float localX, float localY, float contentWidth, int visibleRows) + { + if (_columns is not { } columns || localX < 0f || localX >= contentWidth) + return (-1, -1); + int row = (int)MathF.Floor(localY / MathF.Max(1f, RowHeight)); + int index = _topRow + row; + if (row < 0 || row >= visibleRows) + return (-1, -1); + for (int c = 0; c < columns.Count; c++) + { + (float cellX, float cellW) = _cachedLayout[c]; + if (localX < cellX || localX >= cellX + cellW) + continue; + if (!HasButtonCaption(c, index, out _)) + return (-1, -1); + (float bx, float by, float bw, float bh) = ButtonRect(columns[c], cellX, cellW, row * RowHeight); + bool onButton = localX >= bx && localX < bx + bw && localY >= by && localY < by + bh; + return onButton ? (index, c) : (-1, -1); + } + return (-1, -1); + } + + private void ClearButtonPointer() + { + _hoverRow = _hoverColumn = -1; + _pressRow = _pressColumn = -1; + } + + /// + /// Follows the pointer over button cells and fires a button when the left + /// button comes up over the one it went down on. Only a release that fires + /// a button is handled here; everything else carries on to the list. + /// + private bool TrackButtonPointer( + in UiEvent e, IReadOnlyList columns, float contentWidth, int visibleRows) + { + switch (e.Type) + { + case UiEventType.HoverEnter: + case UiEventType.MouseMove: + case UiEventType.MouseDown: + (_hoverRow, _hoverColumn) = _draggingThumb + ? (-1, -1) + : ButtonCellAt(e.Data1, e.Data2, contentWidth, visibleRows); + return false; + case UiEventType.HoverLeave: + _hoverRow = _hoverColumn = -1; + return false; + case UiEventType.CaptureChanged: + _pressRow = _pressColumn = -1; + return false; + case UiEventType.MouseUp: + { + (int index, int column) = (_pressRow, _pressColumn); + _pressRow = _pressColumn = -1; + (_hoverRow, _hoverColumn) = ButtonCellAt(e.Data1, e.Data2, contentWidth, visibleRows); + if (index < 0 || !Enabled || (_hoverRow, _hoverColumn) != (index, column)) + return false; + columns[column].ButtonClicked?.Invoke(index); + return true; + } + default: + return false; + } + } + private bool OnEventColumns(in UiEvent e, IReadOnlyList columns) { int rowCount = _cachedRowCount; @@ -383,6 +553,8 @@ private bool OnEventColumns(in UiEvent e, IReadOnlyList colu float contentWidth = rowCount > visibleRows ? MathF.Max(0f, Width - ScrollbarWidth) : Width; + if (_hasButtons && TrackButtonPointer(e, columns, contentWidth, visibleRows)) + return true; if (TryHandleScrollbarEvent(e, contentWidth, rowCount, visibleRows)) return true; @@ -427,6 +599,11 @@ private bool OnEventColumns(in UiEvent e, IReadOnlyList colu if (index < (_cachedIconRows[c]?.Count ?? 0)) columns[c].IconClicked?.Invoke(index); break; + case UiMarkupListColumnKind.Button: + // Fires on release over the same button; see TrackButtonPointer. + if ((_hoverRow, _hoverColumn) == (index, c)) + (_pressRow, _pressColumn) = (index, c); + break; } break; } diff --git a/src/AcDream.App/UI/UiMarkupListColumn.cs b/src/AcDream.App/UI/UiMarkupListColumn.cs index 3fedea06c..aac48351a 100644 --- a/src/AcDream.App/UI/UiMarkupListColumn.cs +++ b/src/AcDream.App/UI/UiMarkupListColumn.cs @@ -5,6 +5,7 @@ public enum UiMarkupListColumnKind Text, Check, Icon, + Button, } public sealed class UiMarkupListColumn @@ -24,6 +25,12 @@ public sealed class UiMarkupListColumn public Func? IconResolve { get; internal init; } public Action? IconClicked { get; internal init; } + public Action? ButtonClicked { get; internal init; } + + /// A button's size inside its cell; 0 fills the cell. Never larger than the cell. + public float ButtonWidth { get; internal init; } + public float ButtonHeight { get; internal init; } + public static UiMarkupListColumn Text( float width, Func> textSource, @@ -67,9 +74,33 @@ public static UiMarkupListColumn Icon( IconClicked = onClick, }; + /// + /// A column of buttons: each row's caption is drawn on a button face, an + /// empty caption draws no button, and a click on one fires + /// with the row index without selecting the row. + /// A button by + /// is centred in its cell; 0 fills the cell that way. + /// + public static UiMarkupListColumn Button( + float width, + Func> captionSource, + Action onClick, + bool isAutoWidth = false, + float buttonWidth = 0f, + float buttonHeight = 0f) => new() + { + Kind = UiMarkupListColumnKind.Button, + Width = width, + IsAutoWidth = isAutoWidth, + TextSource = captionSource, + ButtonClicked = onClick, + ButtonWidth = buttonWidth, + ButtonHeight = buttonHeight, + }; + public int RowCount() => Kind switch { - UiMarkupListColumnKind.Text => TextSource?.Invoke().Count ?? 0, + UiMarkupListColumnKind.Text or UiMarkupListColumnKind.Button => TextSource?.Invoke().Count ?? 0, UiMarkupListColumnKind.Check => CheckSource?.Invoke().Count ?? 0, UiMarkupListColumnKind.Icon => IconValuesSource?.Invoke().Count ?? 0, _ => 0, diff --git a/src/AcDream.App/UI/UiPointerState.cs b/src/AcDream.App/UI/UiPointerState.cs new file mode 100644 index 000000000..07b75dffd --- /dev/null +++ b/src/AcDream.App/UI/UiPointerState.cs @@ -0,0 +1,4 @@ +namespace AcDream.App.UI; + +/// The look a control takes from the pointer and its own enabled flag. +public enum UiControlState { Normal, Hovered, Pressed, Disabled } diff --git a/tests/AcDream.App.Tests/UI/ListButtonColumnTests.cs b/tests/AcDream.App.Tests/UI/ListButtonColumnTests.cs new file mode 100644 index 000000000..78f147728 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/ListButtonColumnTests.cs @@ -0,0 +1,337 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public sealed class ListButtonColumnTests +{ + private static (uint, int, int) Sprite(uint id) => (1u, 32, 32); + + private sealed class FoundBinding + { + public IReadOnlyList Names { get; } = new[] { "Drudge", "Rat", "Mosswart" }; + public IReadOnlyList Captions { get; } = new[] { "Select", "", "Select" }; + public int Selected { get; set; } = -1; + public List Acted { get; } = new(); + public Action Act => row => Acted.Add(row); + } + + private static string FoundXml(string buttonColumn) => + "" + + "" + + " " + + buttonColumn + + ""; + + [Fact] + public void ButtonColumn_BindsItsCaptionsAndOnclick() + { + var binding = new FoundBinding(); + var panel = MarkupDocument.Build( + FoundXml(""), + binding, Sprite); + var list = Assert.IsType(panel.Children[0]); + + var column = list.Columns![1]; + Assert.Equal(UiMarkupListColumnKind.Button, column.Kind); + Assert.Equal(binding.Captions, column.TextSource!()); + Assert.Equal(3, column.RowCount()); + column.ButtonClicked!(2); + Assert.Equal(new[] { 2 }, binding.Acted); + } + + [Fact] + public void ButtonColumn_MissingOnclick_ThrowsAtBuild() + { + var ex = Assert.Throws(() => MarkupDocument.Build( + FoundXml(""), + new FoundBinding(), Sprite)); + Assert.Contains("column[1] type=\"button\" onclick", ex.Message); + } + + [Fact] + public void ButtonColumn_MissingItems_ThrowsAtBuild() + { + var ex = Assert.Throws(() => MarkupDocument.Build( + FoundXml(""), + new FoundBinding(), Sprite)); + Assert.Contains("column[1] type=\"button\" items", ex.Message); + } + + [Fact] + public void UnknownColumnType_NamesButtonAmongTheExpectedTypes() + { + var ex = Assert.Throws(() => MarkupDocument.Build( + FoundXml(""), + new FoundBinding(), Sprite)); + Assert.Contains("button", ex.Message); + } + + private static UiMarkupList MakeList( + List acted, List selections, string[]? captions = null) + { + captions ??= new[] { "Go", "", "Go" }; + var list = new UiMarkupList + { + Width = 100f, Height = 60f, RowHeight = 20f, + SelectedIndexSource = () => -1, + SelectionChanged = row => selections.Add(row), + BackgroundColor = default, BorderColor = default, + Columns = new[] + { + UiMarkupListColumn.Text(50f, () => new[] { "a", "b", "c" }, null), + UiMarkupListColumn.Button(50f, () => captions, row => acted.Add(row)), + }, + }; + var (_, ctx) = MakeContext(200f, 200f); + list.DrawSelfAndChildren(ctx); + return list; + } + + [Fact] + public void PressAndReleaseOnAButton_FiresOnclickWithTheRow_WithoutSelecting() + { + var acted = new List(); + var selections = new List(); + var list = MakeList(acted, selections); + + // Row 2 (y = 40..60), button column (x = 50..100). + list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 70, Data2 = 45 }); + Assert.Empty(acted); + list.OnEvent(new UiEvent { Type = UiEventType.MouseUp, Data1 = 72, Data2 = 47 }); + + Assert.Equal(new[] { 2 }, acted); + Assert.Empty(selections); + } + + [Fact] + public void ReleaseOffThePressedButton_DoesNotFire() + { + var acted = new List(); + var list = MakeList(acted, new List()); + + list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 70, Data2 = 5 }); + list.OnEvent(new UiEvent { Type = UiEventType.MouseUp, Data1 = 70, Data2 = 45 }); + list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 70, Data2 = 5 }); + list.OnEvent(new UiEvent { Type = UiEventType.MouseUp, Data1 = 10, Data2 = 5 }); + + Assert.Empty(acted); + } + + [Fact] + public void EmptyCaption_IsNoButton_AndAClickOnItDoesNothing() + { + var acted = new List(); + var selections = new List(); + var list = MakeList(acted, selections); + + list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 70, Data2 = 25 }); + list.OnEvent(new UiEvent { Type = UiEventType.MouseUp, Data1 = 70, Data2 = 25 }); + + Assert.Empty(acted); + Assert.Empty(selections); + Assert.Equal(UiControlState.Normal, list.ButtonStateForTest(1, 1)); + } + + [Fact] + public void ButtonCell_ShowsHoverAndPressOnlyOnTheCellUnderThePointer() + { + var list = MakeList(new List(), new List()); + Assert.True(list.ReceivesHoverMouseMove); + + list.OnEvent(new UiEvent { Type = UiEventType.HoverEnter, Data1 = 70, Data2 = 5 }); + Assert.Equal(UiControlState.Hovered, list.ButtonStateForTest(0, 1)); + Assert.Equal(UiControlState.Normal, list.ButtonStateForTest(2, 1)); + + list.OnEvent(new UiEvent { Type = UiEventType.MouseMove, Data1 = 70, Data2 = 45 }); + Assert.Equal(UiControlState.Normal, list.ButtonStateForTest(0, 1)); + Assert.Equal(UiControlState.Hovered, list.ButtonStateForTest(2, 1)); + + list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 70, Data2 = 45 }); + Assert.Equal(UiControlState.Pressed, list.ButtonStateForTest(2, 1)); + + // Dragged off the pressed button: it lets go of its pressed look. + list.OnEvent(new UiEvent { Type = UiEventType.MouseMove, Data1 = 70, Data2 = 5 }); + Assert.Equal(UiControlState.Normal, list.ButtonStateForTest(2, 1)); + Assert.Equal(UiControlState.Normal, list.ButtonStateForTest(0, 1)); + + list.OnEvent(new UiEvent { Type = UiEventType.MouseUp, Data1 = 70, Data2 = 5 }); + Assert.Equal(UiControlState.Hovered, list.ButtonStateForTest(0, 1)); + + list.OnEvent(new UiEvent { Type = UiEventType.HoverLeave }); + Assert.Equal(UiControlState.Normal, list.ButtonStateForTest(0, 1)); + } + + [Fact] + public void ListWithoutButtonColumns_TakesNoHoverMoves() + { + var list = new UiMarkupList + { + Columns = new[] { UiMarkupListColumn.Text(50f, () => new[] { "a" }, null) }, + }; + Assert.False(list.ReceivesHoverMouseMove); + } + + [Fact] + public void DisabledList_ShowsItsButtonsDisabled() + { + var list = MakeList(new List(), new List()); + list.Enabled = false; + Assert.Equal(UiControlState.Disabled, list.ButtonStateForTest(0, 1)); + } + + [Fact] + public void ClassicButtonCell_DrawsAFaceInsideItsCell_OnlyWhereThereIsACaption() + { + var face = new Vector4(0.2f, 0.4f, 0.6f, 1f); + var list = new UiMarkupList + { + Width = 100f, Height = 60f, RowHeight = 20f, + SelectedIndexSource = () => -1, + BackgroundColor = default, BorderColor = default, + ButtonColor = face, + Columns = new[] + { + UiMarkupListColumn.Text(50f, () => new[] { "a", "b", "c" }, null), + UiMarkupListColumn.Button(50f, () => new[] { "Go", "", "Go" }, _ => { }), + }, + }; + var (renderer, ctx) = MakeContext(200f, 200f); + + list.DrawSelfAndChildren(ctx); + + var faceVerts = renderer.DebugSpriteSegmentVerts + .Where(s => s.Texture == 0u) + .SelectMany(s => Chunk(s.Verts)) + .Where(v => ColorMatches(v, face)) + .ToList(); + Assert.NotEmpty(faceVerts); + Assert.All(faceVerts, v => Assert.InRange(v[0], 50f, 100f)); + // Rows 0 and 2 only: nothing in row 1 (y = 20..40). + Assert.Contains(faceVerts, v => v[1] < 20f); + Assert.Contains(faceVerts, v => v[1] > 40f); + Assert.DoesNotContain(faceVerts, v => v[1] > 21f && v[1] < 39f); + } + + [Fact] + public void ButtonColumn_BindsItsButtonSize() + { + var panel = MarkupDocument.Build( + FoundXml(""), + new FoundBinding(), Sprite); + var column = Assert.IsType(panel.Children[0]).Columns![1]; + Assert.Equal(40f, column.ButtonWidth); + Assert.Equal(12f, column.ButtonHeight); + } + + [Fact] + public void ButtonColumn_NegativeButtonSize_ThrowsAtBuild() + { + var ex = Assert.Throws(() => MarkupDocument.Build( + FoundXml(""), + new FoundBinding(), Sprite)); + Assert.Contains("column[1] type=\"button\" buttonh", ex.Message); + } + + [Fact] + public void SizedButton_IsCentredInItsCell_AndOnlyTheButtonTakesClicks() + { + var face = new Vector4(0.2f, 0.4f, 0.6f, 1f); + var acted = new List(); + var list = new UiMarkupList + { + Width = 100f, Height = 60f, RowHeight = 20f, + SelectedIndexSource = () => -1, + BackgroundColor = default, BorderColor = default, + ButtonColor = face, + Columns = new[] + { + UiMarkupListColumn.Text(50f, () => new[] { "a" }, null), + // Cell x = 50..100, y = 0..20; a 30x10 button sits at x = 60..90, y = 5..15. + UiMarkupListColumn.Button(50f, () => new[] { "Go" }, row => acted.Add(row), + buttonWidth: 30f, buttonHeight: 10f), + }, + }; + var (renderer, ctx) = MakeContext(200f, 200f); + list.DrawSelfAndChildren(ctx); + + var faceVerts = renderer.DebugSpriteSegmentVerts + .Where(s => s.Texture == 0u) + .SelectMany(s => Chunk(s.Verts)) + .Where(v => ColorMatches(v, face)) + .ToList(); + Assert.NotEmpty(faceVerts); + Assert.All(faceVerts, v => Assert.InRange(v[0], 60f, 90f)); + Assert.All(faceVerts, v => Assert.InRange(v[1], 5f, 15f)); + + // Inside the cell but beside the button: nothing. + list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 55, Data2 = 10 }); + list.OnEvent(new UiEvent { Type = UiEventType.MouseUp, Data1 = 55, Data2 = 10 }); + list.OnEvent(new UiEvent { Type = UiEventType.MouseMove, Data1 = 75, Data2 = 2 }); + Assert.Equal(UiControlState.Normal, list.ButtonStateForTest(0, 1)); + Assert.Empty(acted); + + list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 75, Data2 = 10 }); + list.OnEvent(new UiEvent { Type = UiEventType.MouseUp, Data1 = 75, Data2 = 10 }); + Assert.Equal(new[] { 0 }, acted); + } + + [Fact] + public void SizedButton_NeverGrowsPastItsCell() + { + var face = new Vector4(0.2f, 0.4f, 0.6f, 1f); + var list = new UiMarkupList + { + Width = 100f, Height = 60f, RowHeight = 20f, + SelectedIndexSource = () => -1, + BackgroundColor = default, BorderColor = default, + ButtonColor = face, + Columns = new[] + { + UiMarkupListColumn.Text(50f, () => new[] { "a" }, null), + UiMarkupListColumn.Button(50f, () => new[] { "Go" }, _ => { }, + buttonWidth: 500f, buttonHeight: 500f), + }, + }; + var (renderer, ctx) = MakeContext(200f, 200f); + list.DrawSelfAndChildren(ctx); + + var faceVerts = renderer.DebugSpriteSegmentVerts + .Where(s => s.Texture == 0u) + .SelectMany(s => Chunk(s.Verts)) + .Where(v => ColorMatches(v, face)) + .ToList(); + Assert.All(faceVerts, v => Assert.InRange(v[0], 51f, 99f)); + Assert.All(faceVerts, v => Assert.InRange(v[1], 1f, 19f)); + } + + private static IEnumerable Chunk(IReadOnlyList verts) + { + for (int i = 0; i + 8 <= verts.Count; i += 8) + yield return verts.Skip(i).Take(8).ToArray(); + } + + private static bool ColorMatches(float[] vertex, Vector4 color) => + MathF.Abs(vertex[4] - color.X) < 0.001f + && MathF.Abs(vertex[5] - color.Y) < 0.001f + && MathF.Abs(vertex[6] - color.Z) < 0.001f + && MathF.Abs(vertex[7] - color.W) < 0.001f; + + private static (TextRenderer renderer, UiRenderContext ctx) MakeContext(float w, float h) + { + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(w, h)); + var ctx = new UiRenderContext(renderer, new Vector2(w, h)); + return (renderer, ctx); + } + + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource + { + public IGpuFrame? CurrentFrame => null; + } +} From dcdb31fcfee91e88e736b6b0637972a019156315 Mon Sep 17 00:00:00 2001 From: David Smith Date: Wed, 14 Oct 2026 15:00:37 +0200 Subject: [PATCH 04/14] 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 ea56eee5a7bd4d267b9548f7765b03bf31db655c Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 19 Oct 2026 18:09:23 +0200 Subject: [PATCH 09/14] refactor(markup): give UiControlState its own file and make it internal The enum sat alone in a file named for something else. Only the list's button cells and their tests use it, and nothing public returns it, so it does not need to be part of the App's public surface. Co-Authored-By: Claude Opus 5.5 --- src/AcDream.App/UI/{UiPointerState.cs => UiControlState.cs} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/AcDream.App/UI/{UiPointerState.cs => UiControlState.cs} (63%) diff --git a/src/AcDream.App/UI/UiPointerState.cs b/src/AcDream.App/UI/UiControlState.cs similarity index 63% rename from src/AcDream.App/UI/UiPointerState.cs rename to src/AcDream.App/UI/UiControlState.cs index 07b75dffd..7993df7d1 100644 --- a/src/AcDream.App/UI/UiPointerState.cs +++ b/src/AcDream.App/UI/UiControlState.cs @@ -1,4 +1,4 @@ namespace AcDream.App.UI; /// The look a control takes from the pointer and its own enabled flag. -public enum UiControlState { Normal, Hovered, Pressed, Disabled } +internal enum UiControlState { Normal, Hovered, Pressed, Disabled } From d35393a3e6c50759e3270120a1ef067577276817 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 19 Oct 2026 18:09:23 +0200 Subject: [PATCH 10/14] fix(markup): list buttons fire only for the row pressed, and look disabled A button column's onclick gets a row index, so a release could fire for a different item than the one pressed. The press is now dropped if, before the release, the list scrolls, its row count changes, or the pressed row's text, caption or icon changes. The rows are re-read at the release, so a change with no frame drawn in between still counts. Rows re-read with the same content (bindings hand over a fresh array each read) still fire. Disabling the list while pressed drops the press too. A wheel scroll with no pointer move left the hover on a button that had scrolled away; the hover is now found again from the last pointer position after a scroll and on every draw. A disabled list drew an enabled-looking face; face, border and caption now fade by the alpha a disabled toggle's caption uses (named for both). A caption wider than its button is cut at the button's edges instead of running across the cell. The docs now say what the button looks like on and off a theme, that its hover and pressed looks are its own, and when a press is cancelled. New tests go through UiRoot dispatch (press, release, capture taken away) and cover each fix; the fix tests fail on the previous commit. Co-Authored-By: Claude Opus 5.5 --- docs/plugin-ui-markup.md | 21 +- src/AcDream.App/UI/UiMarkupList.cs | 209 +++++++++++--- src/AcDream.App/UI/UiMarkupToggle.cs | 5 +- .../UI/ListButtonColumnTests.cs | 254 ++++++++++++++++++ 4 files changed, 440 insertions(+), 49 deletions(-) diff --git a/docs/plugin-ui-markup.md b/docs/plugin-ui-markup.md index b21a9a858..95513d0e8 100644 --- a/docs/plugin-ui-markup.md +++ b/docs/plugin-ui-markup.md @@ -348,12 +348,21 @@ 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. It has the -face and border of a markup ` /// 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 b5f7ea29f3470634ce792de685d935a94485a4d8 Mon Sep 17 00:00:00 2001 From: David Smith Date: Fri, 16 Oct 2026 09:02:45 +0200 Subject: [PATCH 05/14] fix(items): a carried spell focus no longer blocks inventory completeness HasCompleteInventory wanted a contents listing for every container-slot item, but ACE never sends one for a focus (container type 2), since it holds nothing. Anyone carrying a focus never had a complete inventory, so plugin Sort waited 60 s and gave up. Skip foci when walking packs. Claude-Session: https://claude.ai/code/session_01Lm9T9oP2Hi5FEjwsLmXeyN --- src/AcDream.Core/Items/ClientObjectTable.cs | 7 +++++-- .../Items/InventoryCompletenessTests.cs | 13 +++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index d8ed56bab..aba45de56 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -1206,7 +1206,7 @@ public IReadOnlyList GetEquippedBy(uint wielderId) => .ToArray() : Array.Empty(); - /// Whether server listings cover the owner and every nested pack. + /// Whether server listings cover the owner and every nested pack (a focus needs none). public bool HasCompleteInventory(uint ownerId) { if (ownerId == 0 || _pendingMoves.Count != 0) @@ -1225,7 +1225,10 @@ public bool HasCompleteInventory(uint ownerId) { if (!_objects.TryGetValue(id, out ClientObject? item)) return false; - if (IsContainerListMember(item)) + // A focus takes a container slot but holds nothing, and the + // server never sends a listing for one. + if (IsContainerListMember(item) + && !InventoryContainerPlacementPolicy.IsFocus(item)) pending.Push(id); } } diff --git a/tests/AcDream.Core.Tests/Items/InventoryCompletenessTests.cs b/tests/AcDream.Core.Tests/Items/InventoryCompletenessTests.cs index 00656b517..60cd62bb4 100644 --- a/tests/AcDream.Core.Tests/Items/InventoryCompletenessTests.cs +++ b/tests/AcDream.Core.Tests/Items/InventoryCompletenessTests.cs @@ -42,4 +42,17 @@ public void EmptyAuthoritativeInventoryIsComplete() table.InitializeInventoryManifest(1, Array.Empty()); Assert.True(table.HasCompleteInventory(1)); } + + [Fact] + public void SpellFocusNeverListedDoesNotBlockCompleteness() + { + // ACE lists a focus (container type 2) in the container slots but + // never sends its contents, since a focus holds nothing. + var table = new ClientObjectTable(); + table.InitializeInventoryManifest(1, + [new ContainerContentEntry(2, 1), new ContainerContentEntry(3, 2)]); + Assert.False(table.HasCompleteInventory(1)); + table.ReplaceContents(2, Array.Empty()); + Assert.True(table.HasCompleteInventory(1)); + } } From 1c08ff7abc709fe6727a57c75e240a2b3fc2542a Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 19 Oct 2026 18:01:30 +0200 Subject: [PATCH 06/14] Revert "fix(vendor): a shop listing with zero stock is unlimited" This reverts commit 087f3466b8d5c6692a2f3972b1fe4fe1a021bcdf. A listed amount of 0 is a real amount, not "unlimited": only -1 means a vendor never runs out, and component auto-fill caps a row at its listed amount, so rewriting 0 to -1 at the wire changes what auto-fill stages. The empty-vendor symptom comes from the vendor window hiding rows whose amount is 0 at open; the next commit fixes that in the window instead. Co-Authored-By: Claude Opus 5.5 --- src/AcDream.Core.Net/GameEventWiring.cs | 6 +-- .../Gameplay/RuntimeVendorLifecycleTests.cs | 40 ------------------- 2 files changed, 1 insertion(+), 45 deletions(-) diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index f4f47db1b..339af09a7 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -697,13 +697,9 @@ void NotifyOfEnchantmentRemoval(uint spellId) for (int i = 0; i < shopItems.Length; i++) { VendorApproach.ItemProfile item = p.Value.Items[i]; - // ACE sends a shop create-list row's authored stack size - // as-is, and world content authors many of them as 0 rather - // than -1. ACE never limits a create-list item, so any - // non-positive count means the vendor never runs out. shopItems[i] = new VendorShopItem( item.ItemGuid, - item.StackSize > 0 ? item.StackSize : VendorComponentFill.UnlimitedStock, + item.StackSize, item.Desc.WeenieClassId, item.Desc.Name, item.Desc.ItemType, diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorLifecycleTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorLifecycleTests.cs index 0a1d2c225..1c10f11ec 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorLifecycleTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorLifecycleTests.cs @@ -65,46 +65,6 @@ public void ApproachVendorEvent_PopulatesVendorStateWithProfileAndItems() Assert.Equal(250, item.Value); } - [Fact] - public void ApproachVendorEvent_ZeroStockListing_IsUnlimited() - { - using GameRuntime runtime = Create(); - VendorState vendor = runtime.InventoryOwner.Vendor; - using IDisposable wiring = Wire(vendor); - - // ACE sends a shop create-list row's authored stack size as-is, and - // world content authors many of them as 0. ACE never limits a - // create-list item, so 0 carries the same meaning as -1. - Dispatch(BuildApproachVendorPayload( - vendorGuid: 0x40001000u, - categories: 0x42u, - minValue: 0u, - maxValue: 500u, - dealsMagic: false, - buyPrice: 0.8f, - sellPrice: 1.3f, - currencyWcid: 0u, - currencyAmount: 0u, - currencyName: "", - items: - [ - new VendorItemFixture( - ItemGuid: 0x50002000u, StackSize: 0, Name: "Iron Sword", - WeenieClassId: 42u, RawIconId: 0x1234u, - ItemType: (uint)ItemType.Weapon, Value: 250), - new VendorItemFixture( - ItemGuid: 0x50002001u, StackSize: -1, Name: "Bread", - WeenieClassId: 43u, RawIconId: 0x1235u, - ItemType: (uint)ItemType.Food, Value: 5), - new VendorItemFixture( - ItemGuid: 0x50002002u, StackSize: 3, Name: "Leather Cap", - WeenieClassId: 44u, RawIconId: 0x1236u, - ItemType: (uint)ItemType.Armor, Value: 40), - ])); - - Assert.Equal([-1, -1, 3], vendor.Items.Select(i => i.StackSize)); - } - [Fact] public void SecondApproachVendorEventFromDifferentVendor_ReplacesTheSession() { From 38ef9601f3fc9645181eed293568de5ba118837f Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 19 Oct 2026 18:05:23 +0200 Subject: [PATCH 07/14] fix(vendor): list every shop row at open, whatever its amount Servers list many vendor rows with an amount of 0. The vendor window hid any row whose listed amount minus what was staged was 0 or less, and it applied that test before anything was staged, so a vendor whose rows all carry 0 opened empty. A row now leaves the browse list only when staging has used it up: it is staged, its amount is limited (anything but -1), and the listed amount minus the total staged is 0 or less. A row that is not staged is always listed. A row listed with 0 can still be staged; the first unit staged takes it out of the list and clears the selection, the same as exhausting any limited row, and clearing the buying list puts it back. The listed amount stays as the server sent it, so -1 alone means unlimited and component auto-fill keeps capping a row at its listed amount. Co-Authored-By: Claude Opus 5.5 --- .../UI/Layout/VendorUiController.cs | 23 +++++++--- .../UI/Layout/VendorUiControllerTests.cs | 42 +++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/AcDream.App/UI/Layout/VendorUiController.cs b/src/AcDream.App/UI/Layout/VendorUiController.cs index 58f905379..c2a7e6e08 100644 --- a/src/AcDream.App/UI/Layout/VendorUiController.cs +++ b/src/AcDream.App/UI/Layout/VendorUiController.cs @@ -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; @@ -670,12 +670,23 @@ private void RebuildItemList(bool reselectFirst) } } - private int AvailableShopQuantity(VendorShopItem item) + /// + /// 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 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. + /// + 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); diff --git a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs index acfce6d7b..b38e7938d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs @@ -1333,6 +1333,48 @@ public void UnlimitedSupplyShopItem_IsNeverHiddenNoMatterHowMuchIsStaged() Assert.Equal(1, h.ItemList.GetNumUIItems()); } + [Fact] + public void ZeroStockShopItem_IsListedWhenTheVendorOpens() + { + // Servers list many rows with an amount of 0. Every listed row is + // shown at open; only staging can take a row out of the list. + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, 0, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 100), + new VendorShopItem(AnotherArmorItemGuid, -1, 4u, "Buckler", (uint)ItemType.Armor, 900u, 40), + }); + + Assert.Equal(2, h.ItemList.GetNumUIItems()); + Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId); + Assert.Equal(ArmorItemGuid, h.Selection.SelectedObjectId); + } + + [Fact] + public void StagingAZeroStockShopItem_StagesItAndTakesTheRowOutOfTheList() + { + // A listed amount of 0 is a limited amount: staging is not refused, + // and the first unit staged leaves 0 - 1 remaining, which removes the + // row and clears the selection, exactly as exhausting any limited row. + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, 0, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 100), + }); + + h.AddButton.OnClick!.Invoke(); + + Assert.Equal(1, h.BuyingList.GetNumUIItems()); + Assert.Equal(ArmorItemGuid, h.BuyingList.GetItem(0)!.ItemId); + Assert.Equal(0, h.ItemList.GetNumUIItems()); + Assert.Null(h.Selection.SelectedObjectId); + + h.BuyClearListButton.OnClick!.Invoke(); + + Assert.Equal(1, h.ItemList.GetNumUIItems()); + Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId); + } + [Fact] public void BuyAllButton_SendsOneBatchedBuyForEveryStagedEntryAndClearsStagingOnSuccess() { From bac30f9951bc555ccae8d51a79996de99f6f9953 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 19 Oct 2026 18:09:09 +0200 Subject: [PATCH 08/14] 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. ///