From 767b5a94d8d58c790f19e7bb49a61f0f7185c80d Mon Sep 17 00:00:00 2001 From: David Smith Date: Mon, 12 Oct 2026 17:06:36 +0200 Subject: [PATCH 1/3] 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 bcb4ba84c2fae700397fa1a159731be26b690224 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 19 Oct 2026 18:14:05 +0200 Subject: [PATCH 2/3] fix(navigation): a dungeon place on an object top stays on it Standing a place in a cell on the floor under it only looked at the cell polygons, while the dungeon route grid also stands a body on the tops of the objects the landblock holds. A place deliberately on a bridge, a platform or a crate (a route waypoint recorded where the character stood) was moved down under it, so a walk that used to arrive on the object now ended on the floor below or had no route, breaking the promise that a place on a rock top is arrived at on the rock. A place now stays where it is when something the route grid stands on, a cell floor or an object's top (as the grid captures and sorts them, through the new NavGeometry.HighestFloorUnder and the floor and cylinder top rules shared with NavGrid), lies within the router's goal height tolerance under it or up to 0.5 m over it; it is lifted onto that surface when it is just under it. Only otherwise does it drop to the floor as before. The earlier tests went through a test double that called the floor lookup itself, so removing the call from the client's goal source failed nothing. A test now goes through RuntimeNavigationGoalSource.TryLocatePlace and a preview with it. Both new tests were shown to fail with their part of the fix removed. The docs now say what happens to a place with a NaN height in a dungeon (it is given the ground's height first and then dropped to a floor under that), and that the fallback searches every cell of the landblock, so a place over a pit drops to its bottom. Co-Authored-By: Claude Opus 5.5 --- docs/navigation.md | 4 +- src/AcDream.Core/Navigation/NavGeometry.cs | 78 +++++++++++++++++++ src/AcDream.Core/Navigation/NavGrid.cs | 37 ++++++--- src/AcDream.Core/Physics/CellSurface.cs | 2 +- .../NavigationAutomation.cs | 13 +++- .../RuntimeNavigationWalkSources.cs | 49 +++++++++--- .../Navigation/DungeonPlaceFloorTests.cs | 78 +++++++++++++++++++ 7 files changed, 234 insertions(+), 27 deletions(-) diff --git a/docs/navigation.md b/docs/navigation.md index 43e1b7a55..e33c1ba37 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. 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. | +| `PreviewPathAsync(position, arrivalMeters)` | Plans to a place on its specified floor, including inside a dungeon. A place in a dungeon or building cell is stood on the floor under it (see [Arriving](#arriving)), so a dungeon map cell's `Center` can be given as it is. Does not start or replace a walk. | | `StandOn(objectId, arrivalMeters)` | Walks onto an object and ends on its top, jumping up where the character can. | | `Follow(playerId, bufferMeters)` | Follows a player until stopped: see [Following](#following). Only players can be followed. | | `StopGoTo()` | Ends the walk under way; `Rejected` when there is none. | @@ -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 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 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 that has something a walk stands on within 1.2 m under it (or up to 0.5 m over it), a floor or the top of an object such as a bridge, a platform or a crate, stays where it is (lifted onto that surface if it is under it), so a route waypoint recorded on a platform is arrived at on the platform. Any other place in such a cell is first dropped to the floor under it: the cell's own floor nearest at or below 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 highest floor under it of any cell in the landblock. That search takes every cell in the landblock, so a place over a pit or a shaft drops to its bottom. A place in such a cell given with an elevation of NaN is first given the height of the ground there (or the character's, where no ground is loaded), and is then dropped the same way, so in a dungeon it ends on a floor under that height. 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/Navigation/NavGeometry.cs b/src/AcDream.Core/Navigation/NavGeometry.cs index 7dfe9db6e..aa8df76da 100644 --- a/src/AcDream.Core/Navigation/NavGeometry.cs +++ b/src/AcDream.Core/Navigation/NavGeometry.cs @@ -242,6 +242,84 @@ private static NavGeometry Capture( }; } + /// + /// The highest floor under a point, from to , + /// that a grid captured over the landblock holds: a face of one + /// of its cells, or of an object the landblock holds or names, no + /// steeper than a floor, or the top of such an object's cylinder or dome where a body stands + /// on it. Each is taken and sorted into floor and wall as the grid takes and sorts it, so a + /// height this finds is one a route can arrive on. Null where there is none. Call it on the + /// thread that owns the physics world. + /// + public static float? HighestFloorUnder( + PhysicsEngine engine, + uint landblockId, + float x, + float y, + float lowest, + float highest, + Func? standsOn = null) + { + ArgumentNullException.ThrowIfNull(engine); + uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; + if (!engine.TryGetLandblockCollision(canonical, out _, out IReadOnlyList cells, out _)) + return null; + + float? best = null; + void Consider(float z) + { + if (z >= lowest && z <= highest && (best is null || z > best)) + best = z; + } + + foreach (CellSurface cell in cells) + { + if (cell.SampleLevelZAtOrBelow(x, y, highest) is { } z) + Consider(z); + } + + var point = new Vector2(x, y); + var entries = new List(); + engine.ShadowObjects.CaptureEntries(entries); + HashSet? owners = null; + var triangles = new List(); + var cylinders = new List(); + foreach (ShadowEntry entry in entries) + { + if (!Meets(entry)) + continue; + NavAvoidance footprint = FootprintOf(entry, engine.DataCache); + if (Vector2.Distance(Flat(footprint.Centre), point) > footprint.Radius) + continue; + owners ??= [.. engine.ShadowObjects.CaptureStaticOwnersForLandblock(canonical)]; + if (!owners.Contains(entry.EntityId) && standsOn?.Invoke(entry.EntityId) != true) + continue; + AddCollision(engine.DataCache, entry, cylinders, triangles); + } + + foreach ((Vector3 a, Vector3 b, Vector3 c) in triangles) + { + Vector3 normal = Vector3.Cross(b - a, c - a); + float length = normal.Length(); + if (length > 1e-6f + && NavGrid.IsFloor(normal, length) + && CellSurface.PointInTriangleXY(x, y, a, b, c, out float z)) + { + Consider(z); + } + } + foreach (NavCylinder cylinder in cylinders) + { + float across = Vector2.DistanceSquared(Flat(cylinder.Base), point); + if (across > cylinder.Radius * cylinder.Radius) + continue; + float top = cylinder.Base.Z + NavGrid.TopOver(cylinder, across, out bool stands); + if (stands) + Consider(top); + } + return best; + } + /// /// The collision of the object the physics world knows as , every /// part of it that a body meets, or null when it has none. diff --git a/src/AcDream.Core/Navigation/NavGrid.cs b/src/AcDream.Core/Navigation/NavGrid.cs index 0f483d465..649f69687 100644 --- a/src/AcDream.Core/Navigation/NavGrid.cs +++ b/src/AcDream.Core/Navigation/NavGrid.cs @@ -79,6 +79,30 @@ public sealed class NavGrid private static readonly float DomeStandShare = MathF.Sqrt(MathF.Max(0f, 1f - (PhysicsGlobals.FloorZ * PhysicsGlobals.FloorZ))); + /// + /// Whether a face with this normal (of this length) is floor rather than wall: no steeper + /// than a floor, whichever way it faces. + /// + internal static bool IsFloor(Vector3 normal, float length) => + MathF.Abs(normal.Z) / length >= PhysicsGlobals.FloorZ; + + /// + /// How high over its base a cylinder's or a dome's top stands at a point + /// (the square of the horizontal distance) from its + /// middle, and whether a body stands there: anywhere on a cylinder's flat top, and only + /// on the part of a dome's top that is no steeper than a floor. + /// + internal static float TopOver(NavCylinder cylinder, float acrossSquared, out bool stands) + { + stands = true; + if (!cylinder.Dome) + return cylinder.Height; + float inside = (cylinder.Radius * cylinder.Radius) - acrossSquared; + float standWithin = cylinder.Radius * DomeStandShare; + stands = acrossSquared <= standWithin * standWithin; + return cylinder.Radius + (inside > 0f ? MathF.Sqrt(inside) : 0f); + } + // East, north, west and south, then the diagonals. private static readonly int[] StepX = [1, 0, -1, 0, 1, -1, -1, 1]; private static readonly int[] StepY = [0, 1, 0, -1, 1, 1, -1, -1]; @@ -987,7 +1011,7 @@ private static void RasterizeTriangle( float length = normal.Length(); if (!(length > 1e-6f)) return; - bool walkable = MathF.Abs(normal.Z) / length >= PhysicsGlobals.FloorZ; + bool walkable = IsFloor(normal, length); int x0 = Math.Max(0, (int)MathF.Floor(MathF.Min(a.X, MathF.Min(b.X, c.X)) / cellSize)); int x1 = Math.Min(side - 1, (int)MathF.Floor(MathF.Max(a.X, MathF.Max(b.X, c.X)) / cellSize)); @@ -1057,7 +1081,6 @@ private static void RasterizeCylinder( int y0 = Math.Max(0, (int)MathF.Floor((centre.Y - reach) / cellSize)); int y1 = Math.Min(side - 1, (int)MathF.Floor((centre.Y + reach) / cellSize)); Span square = stackalloc Vector3[4]; - float standWithin = cylinder.Radius * DomeStandShare; for (int y = y0; y <= y1; y++) { for (int x = x0; x <= x1; x++) @@ -1067,15 +1090,7 @@ private static void RasterizeCylinder( float across = (dx * dx) + (dy * dy); if (across > reach * reach) continue; - float top = centre.Z + cylinder.Height; - bool stands = true; - if (cylinder.Dome) - { - float middle = centre.Z + cylinder.Radius; - float inside = (cylinder.Radius * cylinder.Radius) - across; - top = middle + (inside > 0f ? MathF.Sqrt(inside) : 0f); - stands = across <= standWithin * standWithin; - } + float top = centre.Z + TopOver(cylinder, across, out bool stands); spans.Add(x, y, centre.Z, top, stands, source); float left = MathF.Max(x * cellSize, centre.X - cylinder.Radius); diff --git a/src/AcDream.Core/Physics/CellSurface.cs b/src/AcDream.Core/Physics/CellSurface.cs index 909fcd1db..6ccd64a68 100644 --- a/src/AcDream.Core/Physics/CellSurface.cs +++ b/src/AcDream.Core/Physics/CellSurface.cs @@ -123,7 +123,7 @@ public CellSurface( } - private static bool PointInTriangleXY( + internal static bool PointInTriangleXY( float px, float py, Vector3 a, Vector3 b, Vector3 c, out float z) diff --git a/src/AcDream.Plugin.Abstractions/NavigationAutomation.cs b/src/AcDream.Plugin.Abstractions/NavigationAutomation.cs index b40c19bd4..c6dfed146 100644 --- a/src/AcDream.Plugin.Abstractions/NavigationAutomation.cs +++ b/src/AcDream.Plugin.Abstractions/NavigationAutomation.cs @@ -476,8 +476,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. A position in a dungeon or - /// building cell stands on the floor under it, so a - /// may be given as it is. + /// building cell is placed as places + /// it, standing 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")); @@ -659,8 +660,12 @@ PluginNavigationCommandStatus GoTo(uint objectId, float arrivalMeters) => /// 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. 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 + /// a dungeon or building cell with nothing a walk stands on within 1.2 m under it is + /// dropped to 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; one on or just over a floor or the top of an + /// object such as a platform stays there. In such a cell a position with an elevation of + /// NaN is given the ground's height first and then dropped the same way, so in a dungeon it + /// ends on a floor under that height. 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 e9c800b09..1fe42ecb8 100644 --- a/src/AcDream.Runtime/Navigation/RuntimeNavigationWalkSources.cs +++ b/src/AcDream.Runtime/Navigation/RuntimeNavigationWalkSources.cs @@ -395,7 +395,10 @@ internal static bool StandsInTheWay(ClientObject item) /// /// A place in a cell's landblock frame, set relative to where the character stands in the - /// physics world. A place with no height stands on the terrain there. + /// physics world. A place with no height is first given the terrain's height there (or the + /// character's, where no terrain is loaded); a place in an interior cell is then stood on + /// a floor as says, so in a dungeon a place with no height + /// ends on a floor under that height too. /// public bool TryLocatePlace(uint cellId, Vector3 local, out Vector3 position) { @@ -411,7 +414,8 @@ public bool TryLocatePlace(uint cellId, Vector3 local, out Vector3 position) OnTheGround( _physics, controller.Position + PlaceOffset(cellId, local, here.ObjCellId, here.Frame.Origin), - controller.Position.Z)); + controller.Position.Z), + StandsStill); return true; } @@ -422,14 +426,30 @@ public bool TryLocatePlace(uint cellId, Vector3 local, out Vector3 position) 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. + /// A place in an interior cell stood where a walk can arrive at it. 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 arrives at. + /// + /// A place with something a route stands on within + /// under it (or up to + /// over it) is left where it is, lifted onto that + /// surface if it is under it. That surface is a cell's floor or the top of an object the + /// route grid holds, such as a bridge, a platform or a crate, so a place recorded on top + /// of one stays on it. + /// Otherwise it drops to its cell's own floor nearest at or below 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, to the highest floor under it of any cell in its landblock. That search takes + /// every cell in the landblock, so a place over a pit or a shaft drops to its bottom. + /// An outdoor place, or one with no floor under it at all, is left as it is. + /// + /// A place given with no height reaches this with the height gave + /// it, so in a dungeon it lands on a floor under that height, not on the terrain. /// - internal static Vector3 OnTheCellFloor(PhysicsEngine physics, uint cellId, Vector3 point) + internal static Vector3 OnTheCellFloor( + PhysicsEngine physics, + uint cellId, + Vector3 point, + Func? standsOn = null) { ArgumentNullException.ThrowIfNull(physics); if ((cellId & 0xFFFFu) < 0x0100u || (cellId & 0xFFFFu) == 0xFFFFu || float.IsNaN(point.Z) @@ -438,6 +458,17 @@ internal static Vector3 OnTheCellFloor(PhysicsEngine physics, uint cellId, Vecto return point; } float atMost = point.Z + PlaceAboveFloorMeters; + if (NavGeometry.HighestFloorUnder( + physics, + cellId, + point.X, + point.Y, + point.Z - NavRouter.GoalHeightTolerance, + atMost, + standsOn) is { } standing) + { + return standing > point.Z ? point with { Z = standing } : point; + } float? own = null; float? any = null; foreach (CellSurface cell in cells) diff --git a/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorTests.cs b/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorTests.cs index 7bf61a429..204f6942f 100644 --- a/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorTests.cs +++ b/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorTests.cs @@ -1,6 +1,7 @@ using System.Numerics; using AcDream.Core.Physics; using AcDream.Plugin.Abstractions; +using AcDream.Runtime.Gameplay; using AcDream.Runtime.Navigation; using static AcDream.App.Tests.Navigation.NavigationWalkControllerTests; @@ -120,6 +121,83 @@ public void AWalkToAPlaceAtTheMiddleOfATallRoomArrives() Assert.InRange(Vector2.Distance(new Vector2(body.Position.X, body.Position.Y), new Vector2(30f, 10f)), 0f, 2.6f); } + /// + /// The client's own goal source stands a place on the floor, not only the test double: + /// a preview through it to the middle of a tall room routes to that room's floor. + /// + [Fact] + public async Task TheClientsGoalSourceStandsAPlaceAtTheMiddleOfATallRoomOnItsFloor() + { + PhysicsEngine world = DungeonWithTallRooms(); + RuntimeNavigationGoalSource goals = ClientGoals(world); + + Assert.True(goals.TryLocatePlace(TallRoom, TallMiddle, out Vector3 placed)); + var body = new SimulatedBody(new Vector3(10f, 10f, Floor)) { CellId = LowRoom }; + var walk = new NavigationWalkController(world, body, goals, isSealedDungeon: _ => true); + PluginNavigationPlan plan = await walk.PreviewPathAsync(TallRoom, TallMiddle, 2.5f); + + Assert.Equal(new Vector3(30f, 10f, Floor), placed); + Assert.Equal(PluginNavigationPlanStatus.Routed, plan.Status); + Assert.InRange(plan.Path[^1].Elevation * 240d, Floor - 0.1d, Floor + 0.1d); + } + + /// + /// A place on top of an object in a cell, such as a route waypoint recorded where the + /// character stood on a platform, stays on it: the route grid stands a body on the + /// object's top, so a walk arrives there, or not at all. Only a place with nothing to + /// stand on near under it drops to the floor. + /// + [Fact] + public void APlaceOnAPlatformInACellStaysOnThePlatform() + { + PhysicsEngine world = DungeonWithAPlatform(); + RuntimeNavigationGoalSource goals = ClientGoals(world); + var onTop = new Vector3(30f, 10f, PlatformTop); + + Assert.NotEmpty(NavigationGeometry(world).Cylinders); + Assert.Equal(onTop, RuntimeNavigationGoalSource.OnTheCellFloor(world, TallRoom, onTop)); + Assert.Equal(onTop, RuntimeNavigationGoalSource.OnTheCellFloor(world, TallRoom, onTop with { Z = PlatformTop - 0.2f })); + Assert.Equal(onTop with { Z = PlatformTop + 1f }, RuntimeNavigationGoalSource.OnTheCellFloor(world, TallRoom, onTop with { Z = PlatformTop + 1f })); + Assert.True(goals.TryLocatePlace(TallRoom, onTop, out Vector3 placed)); + Assert.Equal(onTop, placed); + Assert.Equal(new Vector3(36f, 10f, Floor), RuntimeNavigationGoalSource.OnTheCellFloor(world, TallRoom, new Vector3(36f, 10f, PlatformTop))); + } + + private const float PlatformTop = Floor + 2f; + + /// The 5 m room with a platform 2 m high and 3 m across the middle in it, which the landblock holds. + private static PhysicsEngine DungeonWithAPlatform() + { + PhysicsEngine physics = DungeonWithTallRooms(); + physics.ShadowObjects.Register( + 0x7A9B_4001u, + gfxObjId: 0u, + new Vector3(30f, 10f, Floor), + Quaternion.Identity, + 3f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: 0xA9B4FFFFu, + collisionType: ShadowCollisionType.Cylinder, + cylHeight: 2f, + seedCellId: TallRoom, + isStatic: true); + return physics; + } + + private static AcDream.Core.Navigation.NavGeometry NavigationGeometry(PhysicsEngine world) => + AcDream.Core.Navigation.NavGeometry.CaptureDungeon(world, 0xA9B4FFFFu, 0f, 0f, 64f)!; + + /// The client's own goal source, the character standing in the low room. + private static RuntimeNavigationGoalSource ClientGoals(PhysicsEngine world) + { + var start = new Vector3(10f, 10f, Floor); + var controller = new PlayerMovementController(world); + controller.PreparePositionForCommit(start, LowRoom, start); + var movement = new RuntimeLocalPlayerMovementState { Controller = controller }; + return new RuntimeNavigationGoalSource(world, GameRuntimeTestFactory.Create(), movement); + } + private static Task Preview(Goals goals, uint cellId, Vector3 local) { var body = new SimulatedBody(new Vector3(10f, 10f, Floor)) { CellId = LowRoom }; From 24ddf4263c08fb50342672de2b6481aeaf4809ec Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 19 Oct 2026 18:24:59 +0200 Subject: [PATCH 3/3] fix(navigation): keep a place on a surface only at foot height The first fix kept a place where it was whenever something walkable lay up to 1.2 m under it. A cell's middle, 1.5 m up in a 3 m room, then stayed in mid-air over a table, chest or altar top: no route, or one onto the furniture. A recorded waypoint sits at foot height, so a place now stays only when a surface lies no more than 0.5 m under it, and is lifted when it lies up to 0.5 m under one; anything else drops to the floor as before. Only faces that face up count, both in that check and in the floor lookup the drop uses, so the underside of a ceiling, an arch or something hanging never captures or lifts a place. The installed dungeons confirm their floors face up (694 of 712 and 325 of 327 cell middles have one under them). A cylinder top counts as far out as the route grid holds it, half a grid column beyond its radius, so a waypoint recorded at a platform's edge stays on the platform; a cylinder with no radius or height is ignored, as the grid ignores it. The grid's reach and extent rules are now shared helpers. New tests, each shown to fail with its part of the fix removed: a table in a low room (the middle drops to the floor and routes), a place just under a ceiling, a place at a platform's edge, and a platform the server placed, which stays a floor only because the client's goal source passes the walk's standable objects on. The docs no longer claim a height found is one a route can arrive on (no headroom is checked), and say which place or surface each sentence means. Co-Authored-By: Claude Opus 5.5 --- docs/navigation.md | 2 +- src/AcDream.Core/Navigation/NavGeometry.cs | 50 +++-- src/AcDream.Core/Navigation/NavGrid.cs | 13 +- src/AcDream.Core/Physics/CellSurface.cs | 6 +- .../NavigationAutomation.cs | 13 +- .../RuntimeNavigationWalkSources.cs | 33 ++-- .../DungeonPlaceFloorInstalledDatTests.cs | 4 + .../Navigation/DungeonPlaceFloorTests.cs | 183 ++++++++++++++++-- .../Physics/CellSurfaceTests.cs | 3 +- 9 files changed, 244 insertions(+), 63 deletions(-) diff --git a/docs/navigation.md b/docs/navigation.md index e33c1ba37..15abd58b7 100644 --- a/docs/navigation.md +++ b/docs/navigation.md @@ -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 in a dungeon or building cell that has something a walk stands on within 1.2 m under it (or up to 0.5 m over it), a floor or the top of an object such as a bridge, a platform or a crate, stays where it is (lifted onto that surface if it is under it), so a route waypoint recorded on a platform is arrived at on the platform. Any other place in such a cell is first dropped to the floor under it: the cell's own floor nearest at or below 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 highest floor under it of any cell in the landblock. That search takes every cell in the landblock, so a place over a pit or a shaft drops to its bottom. A place in such a cell given with an elevation of NaN is first given the height of the ground there (or the character's, where no ground is loaded), and is then dropped the same way, so in a dungeon it ends on a floor under that height. Preview and walk place it the same way. | +| A place (`go to` a cell or map point) | On the floor the place stands on, within the arrival distance. A place on a rock top is arrived at on the rock, or not at all: a route that only reaches the ground beside it ends `NoRoute`. A place joined to the ground by a ramp or stairs is reached up them. A place in a dungeon or building cell is first checked against the face-up surfaces the route grid holds there: a floor, or the top of an object such as a bridge, a platform or a crate. If one lies no more than 0.5 m under the place, the place stays where it is; if one lies no more than 0.5 m over the place, the place is lifted onto that surface. So a route waypoint recorded on a platform is arrived at on the platform, while a room's middle, well over a table there, is not stopped on the table. Any other place in such a cell drops to the floor under it: its own cell's floor nearest at or below the place, or where that cell has no floor there, as in the upper half of a tall room authored as a cell on top of a cell, the highest floor under the place of any cell in the landblock. That search takes every cell in the landblock, so a place over a pit or a shaft drops to the pit's bottom. A place in such a cell given with an elevation of NaN first takes the height of the ground there (or the character's height, where no ground is loaded) and then drops the same way, so in a dungeon such a place ends on a floor under that height. Preview and walk do all this the same way. | | A player (`follow`) | Never ends by itself: see [Following](#following). | | An object's top (`stand on`) | On the highest floor over the object's own collision that a body stands on, within the arrival distance of that top's middle. A ramp or stair up to a deck is not the deck. An object with nothing on top to stand on, such as a creature or a thin wall, or no way up, ends `NoRoute`. | diff --git a/src/AcDream.Core/Navigation/NavGeometry.cs b/src/AcDream.Core/Navigation/NavGeometry.cs index aa8df76da..5878fcdf4 100644 --- a/src/AcDream.Core/Navigation/NavGeometry.cs +++ b/src/AcDream.Core/Navigation/NavGeometry.cs @@ -244,12 +244,14 @@ private static NavGeometry Capture( /// /// The highest floor under a point, from to , - /// that a grid captured over the landblock holds: a face of one - /// of its cells, or of an object the landblock holds or names, no - /// steeper than a floor, or the top of such an object's cylinder or dome where a body stands - /// on it. Each is taken and sorted into floor and wall as the grid takes and sorts it, so a - /// height this finds is one a route can arrive on. Null where there is none. Call it on the - /// thread that owns the physics world. + /// among the surfaces a grid captured over the landblock holds: + /// a face of one of its cells, or of an object the landblock holds or + /// names, that faces up and is no steeper than a floor, or the top + /// of such an object's cylinder or dome where a body stands on it. The objects, the floor + /// slope and a cylinder's reach are the grid's own. Only a face that faces up counts, so the + /// underside of an arch or of something hanging is never one. Whether a body has room to + /// stand there is not checked. Null where there is none. Call it on the thread that owns + /// the physics world. /// public static float? HighestFloorUnder( PhysicsEngine engine, @@ -272,13 +274,27 @@ void Consider(float z) best = z; } - foreach (CellSurface cell in cells) + void ConsiderFace(Vector3 a, Vector3 b, Vector3 c) { - if (cell.SampleLevelZAtOrBelow(x, y, highest) is { } z) + Vector3 normal = Vector3.Cross(b - a, c - a); + float length = normal.Length(); + if (length > 1e-6f + && normal.Z > 0f + && NavGrid.IsFloor(normal, length) + && CellSurface.PointInTriangleXY(x, y, a, b, c, out float z)) + { Consider(z); + } + } + + foreach (CellSurface cell in cells) + { + foreach ((Vector3 a, Vector3 b, Vector3 c) in cell.Triangles) + ConsiderFace(a, b, c); } var point = new Vector2(x, y); + float slack = NavGrid.DefaultCellSize * 0.5f; var entries = new List(); engine.ShadowObjects.CaptureEntries(entries); HashSet? owners = null; @@ -289,7 +305,7 @@ void Consider(float z) if (!Meets(entry)) continue; NavAvoidance footprint = FootprintOf(entry, engine.DataCache); - if (Vector2.Distance(Flat(footprint.Centre), point) > footprint.Radius) + if (Vector2.Distance(Flat(footprint.Centre), point) > footprint.Radius + slack) continue; owners ??= [.. engine.ShadowObjects.CaptureStaticOwnersForLandblock(canonical)]; if (!owners.Contains(entry.EntityId) && standsOn?.Invoke(entry.EntityId) != true) @@ -298,20 +314,14 @@ void Consider(float z) } foreach ((Vector3 a, Vector3 b, Vector3 c) in triangles) - { - Vector3 normal = Vector3.Cross(b - a, c - a); - float length = normal.Length(); - if (length > 1e-6f - && NavGrid.IsFloor(normal, length) - && CellSurface.PointInTriangleXY(x, y, a, b, c, out float z)) - { - Consider(z); - } - } + ConsiderFace(a, b, c); foreach (NavCylinder cylinder in cylinders) { + if (!NavGrid.HasExtent(cylinder)) + continue; float across = Vector2.DistanceSquared(Flat(cylinder.Base), point); - if (across > cylinder.Radius * cylinder.Radius) + float reach = NavGrid.ReachOf(cylinder, NavGrid.DefaultCellSize); + if (across > reach * reach) continue; float top = cylinder.Base.Z + NavGrid.TopOver(cylinder, across, out bool stands); if (stands) diff --git a/src/AcDream.Core/Navigation/NavGrid.cs b/src/AcDream.Core/Navigation/NavGrid.cs index 649f69687..e4ce9d382 100644 --- a/src/AcDream.Core/Navigation/NavGrid.cs +++ b/src/AcDream.Core/Navigation/NavGrid.cs @@ -86,6 +86,15 @@ public sealed class NavGrid internal static bool IsFloor(Vector3 normal, float length) => MathF.Abs(normal.Z) / length >= PhysicsGlobals.FloorZ; + /// Whether a cylinder or dome fills any room at all; one with no radius or height is left out of a grid. + internal static bool HasExtent(NavCylinder cylinder) => cylinder.Radius > 0f && cylinder.Height > 0f; + + /// + /// How far from its middle a cylinder or dome fills a grid's columns: half a column beyond + /// its radius, so a column whose middle lies just outside its edge still holds its top. + /// + internal static float ReachOf(NavCylinder cylinder, float cellSize) => cylinder.Radius + (cellSize * 0.5f); + /// /// How high over its base a cylinder's or a dome's top stands at a point /// (the square of the horizontal distance) from its @@ -1072,10 +1081,10 @@ private static void RasterizeCylinder( int side, byte source) { - if (!(cylinder.Radius > 0f) || !(cylinder.Height > 0f)) + if (!HasExtent(cylinder)) return; Vector3 centre = cylinder.Base - origin; - float reach = cylinder.Radius + (cellSize * 0.5f); + float reach = ReachOf(cylinder, cellSize); int x0 = Math.Max(0, (int)MathF.Floor((centre.X - reach) / cellSize)); int x1 = Math.Min(side - 1, (int)MathF.Floor((centre.X + reach) / cellSize)); int y0 = Math.Max(0, (int)MathF.Floor((centre.Y - reach) / cellSize)); diff --git a/src/AcDream.Core/Physics/CellSurface.cs b/src/AcDream.Core/Physics/CellSurface.cs index 6ccd64a68..1373c2a0e 100644 --- a/src/AcDream.Core/Physics/CellSurface.cs +++ b/src/AcDream.Core/Physics/CellSurface.cs @@ -99,9 +99,9 @@ public CellSurface( } /// - /// The height of the highest level face under a point that is no higher than + /// The height of the highest level, face-up 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. + /// in the cell stands on, never a ceiling, which faces down. /// public float? SampleLevelZAtOrBelow(float worldX, float worldY, float atMostZ) { @@ -110,7 +110,7 @@ public CellSurface( { Vector3 normal = Vector3.Cross(b - a, c - a); float length = normal.Length(); - if (length <= 1e-6f || MathF.Abs(normal.Z) / length < PhysicsGlobals.FloorZ) + if (length <= 1e-6f || normal.Z / length < PhysicsGlobals.FloorZ) continue; if (PointInTriangleXY(worldX, worldY, a, b, c, out float z) && z <= atMostZ diff --git a/src/AcDream.Plugin.Abstractions/NavigationAutomation.cs b/src/AcDream.Plugin.Abstractions/NavigationAutomation.cs index c6dfed146..e9148d174 100644 --- a/src/AcDream.Plugin.Abstractions/NavigationAutomation.cs +++ b/src/AcDream.Plugin.Abstractions/NavigationAutomation.cs @@ -660,12 +660,13 @@ PluginNavigationCommandStatus GoTo(uint objectId, float arrivalMeters) => /// 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. A position in - /// a dungeon or building cell with nothing a walk stands on within 1.2 m under it is - /// dropped to 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; one on or just over a floor or the top of an - /// object such as a platform stays there. In such a cell a position with an elevation of - /// NaN is given the ground's height first and then dropped the same way, so in a dungeon it - /// ends on a floor under that height. It does not turn the character to face + /// a dungeon or building cell that is within 0.5 m of a floor or of the top of an object + /// such as a platform, over or under that surface, is placed on that surface. Any other + /// position in such a cell is dropped to the floor under the position, or to the floor of + /// the cell below where the position's own cell has none there, as in the upper half of a + /// tall room. In such a cell a position with an elevation of NaN first takes the ground's + /// height and is then dropped the same way, so in a dungeon that position ends on a floor + /// under that height. The walk 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 1fe42ecb8..71a26474b 100644 --- a/src/AcDream.Runtime/Navigation/RuntimeNavigationWalkSources.cs +++ b/src/AcDream.Runtime/Navigation/RuntimeNavigationWalkSources.cs @@ -420,8 +420,9 @@ public bool TryLocatePlace(uint cellId, Vector3 local, out Vector3 position) } /// - /// 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. + /// How far a place may be from a floor, over it or under it, and still count as given on + /// that floor: a route waypoint recorded where the character stood is at foot height, a + /// little out at most, so a place this near a floor is not moved to another one. /// internal const float PlaceAboveFloorMeters = 0.5f; @@ -430,20 +431,22 @@ public bool TryLocatePlace(uint cellId, Vector3 local, out Vector3 position) /// often given by the middle of the cell's bounds, which in a tall room is metres above the /// floor, higher than a walk arrives at. /// - /// A place with something a route stands on within - /// under it (or up to - /// over it) is left where it is, lifted onto that - /// surface if it is under it. That surface is a cell's floor or the top of an object the - /// route grid holds, such as a bridge, a platform or a crate, so a place recorded on top - /// of one stays on it. - /// Otherwise it drops to its cell's own floor nearest at or below 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, to the highest floor under it of any cell in its landblock. That search takes - /// every cell in the landblock, so a place over a pit or a shaft drops to its bottom. + /// When a face-up surface the route grid holds, a cell's floor or the top of an + /// object such as a bridge, a platform or a crate, lies no more than + /// under the place, the place is left where it is. + /// When such a surface lies no more than that over the place, the place is lifted onto + /// the surface. So a waypoint recorded on top of a platform stays on the platform, while + /// the middle of a room, well over a table in it, does not stop on the table. + /// Otherwise the place drops to its cell's own floor nearest at or below it, or where + /// that cell has no floor there, as in the upper half of a tall room authored as a cell on + /// top of a cell, to the highest floor under it of any cell in the landblock. That search + /// takes every cell in the landblock, so a place over a pit or a shaft drops to the pit's + /// bottom. /// An outdoor place, or one with no floor under it at all, is left as it is. /// - /// A place given with no height reaches this with the height gave - /// it, so in a dungeon it lands on a floor under that height, not on the terrain. + /// A place given with no height arrives here already holding the height + /// gave it, the terrain's or the character's, so in a dungeon such + /// a place ends on a floor under that height, not on the terrain. /// internal static Vector3 OnTheCellFloor( PhysicsEngine physics, @@ -463,7 +466,7 @@ internal static Vector3 OnTheCellFloor( cellId, point.X, point.Y, - point.Z - NavRouter.GoalHeightTolerance, + point.Z - PlaceAboveFloorMeters, atMost, standsOn) is { } standing) { diff --git a/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorInstalledDatTests.cs b/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorInstalledDatTests.cs index c1050edce..2b78a1328 100644 --- a/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorInstalledDatTests.cs +++ b/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorInstalledDatTests.cs @@ -46,6 +46,7 @@ public void TheMiddleOfEveryCellRoutesOnceItStandsOnTheFloor(uint dungeon, uint int onTheFloor = 0; int tall = 0; int floorless = 0; + int faceUpFloor = 0; var reasons = new Dictionary(); var flooredReasons = new Dictionary(); foreach (CellSurface cell in cells) @@ -63,6 +64,8 @@ public void TheMiddleOfEveryCellRoutesOnceItStandsOnTheFloor(uint dungeon, uint Vector3 placed = RuntimeNavigationGoalSource.OnTheCellFloor(world.Engine, cell.CellId, middle); if (middle.Z - placed.Z > NavRouter.GoalHeightTolerance) tall++; + if (NavGeometry.HighestFloorUnder(world.Engine, dungeon, middle.X, middle.Y, float.NegativeInfinity, middle.Z) is not null) + faceUpFloor++; if (cell.SampleLevelZAtOrBelow(middle.X, middle.Y, middle.Z) is null) floorless++; NavRoute raw = NavRouter.Find(grid, start, middle, arrival, arriveOnGoalFloor: true); @@ -79,6 +82,7 @@ public void TheMiddleOfEveryCellRoutesOnceItStandsOnTheFloor(uint dungeon, uint _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}"); + _output.WriteLine($" {faceUpFloor} with a face-up floor somewhere under their middle"); 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)) diff --git a/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorTests.cs b/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorTests.cs index 204f6942f..9cdb59e0e 100644 --- a/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorTests.cs +++ b/tests/AcDream.App.Tests/Navigation/DungeonPlaceFloorTests.cs @@ -1,6 +1,11 @@ using System.Numerics; using AcDream.Core.Physics; using AcDream.Plugin.Abstractions; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Runtime; +using AcDream.Runtime.Entities; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Navigation; using static AcDream.App.Tests.Navigation.NavigationWalkControllerTests; @@ -143,9 +148,9 @@ public async Task TheClientsGoalSourceStandsAPlaceAtTheMiddleOfATallRoomOnItsFlo /// /// A place on top of an object in a cell, such as a route waypoint recorded where the - /// character stood on a platform, stays on it: the route grid stands a body on the - /// object's top, so a walk arrives there, or not at all. Only a place with nothing to - /// stand on near under it drops to the floor. + /// character stood on a platform, stays on the platform: the route grid stands a body on + /// the object's top, so a walk arrives there, or not at all. A place a little under the + /// top is lifted onto it. /// [Fact] public void APlaceOnAPlatformInACellStaysOnThePlatform() @@ -153,49 +158,197 @@ public void APlaceOnAPlatformInACellStaysOnThePlatform() PhysicsEngine world = DungeonWithAPlatform(); RuntimeNavigationGoalSource goals = ClientGoals(world); var onTop = new Vector3(30f, 10f, PlatformTop); + var justOver = onTop with { Z = PlatformTop + 0.4f }; Assert.NotEmpty(NavigationGeometry(world).Cylinders); Assert.Equal(onTop, RuntimeNavigationGoalSource.OnTheCellFloor(world, TallRoom, onTop)); Assert.Equal(onTop, RuntimeNavigationGoalSource.OnTheCellFloor(world, TallRoom, onTop with { Z = PlatformTop - 0.2f })); - Assert.Equal(onTop with { Z = PlatformTop + 1f }, RuntimeNavigationGoalSource.OnTheCellFloor(world, TallRoom, onTop with { Z = PlatformTop + 1f })); + Assert.Equal(justOver, RuntimeNavigationGoalSource.OnTheCellFloor(world, TallRoom, justOver)); Assert.True(goals.TryLocatePlace(TallRoom, onTop, out Vector3 placed)); Assert.Equal(onTop, placed); Assert.Equal(new Vector3(36f, 10f, Floor), RuntimeNavigationGoalSource.OnTheCellFloor(world, TallRoom, new Vector3(36f, 10f, PlatformTop))); } + /// + /// A waypoint recorded at a platform's edge stays on the platform, as far out as the route + /// grid still holds the platform's top: half a grid column beyond its radius. + /// + [Fact] + public void APlaceAtAPlatformsEdgeStaysOnThePlatform() + { + PhysicsEngine world = DungeonWithAPlatform(); + float edge = 30f + PlatformRadius; + float slack = AcDream.Core.Navigation.NavGrid.DefaultCellSize * 0.5f; + var atTheEdge = new Vector3(edge + (slack * 0.8f), 10f, PlatformTop); + var pastTheEdge = new Vector3(edge + (slack * 1.5f), 10f, PlatformTop); + + Assert.Equal(atTheEdge, RuntimeNavigationGoalSource.OnTheCellFloor(world, TallRoom, atTheEdge)); + Assert.Equal(pastTheEdge with { Z = Floor }, RuntimeNavigationGoalSource.OnTheCellFloor(world, TallRoom, pastTheEdge)); + } + + /// + /// The middle of a 3 m room is 1.5 m over its floor and 0.6 m over the top of a table + /// standing there. A table top is no floor a waypoint was recorded on, so the place drops + /// to the room's floor and a route reaches it. + /// + [Fact] + public async Task TheMiddleOfALowRoomOverATableDropsToTheFloorAndRoutes() + { + PhysicsEngine world = DungeonWithTallRooms(); + RegisterCylinder(world, 0x7A9B_4002u, new Vector3(15f, 12f, Floor), radius: 0.6f, height: 0.9f, LowRoom, isStatic: true); + RuntimeNavigationGoalSource goals = ClientGoals(world); + var middle = new Vector3(15f, 12f, -28.5f); + + Assert.True(goals.TryLocatePlace(LowRoom, middle, out Vector3 placed)); + var body = new SimulatedBody(new Vector3(10f, 10f, Floor)) { CellId = LowRoom }; + var walk = new NavigationWalkController(world, body, goals, isSealedDungeon: _ => true); + PluginNavigationPlan plan = await walk.PreviewPathAsync(LowRoom, middle, 2.5f); + + Assert.Equal(middle with { Z = Floor }, placed); + Assert.Equal(PluginNavigationPlanStatus.Routed, plan.Status); + Assert.InRange(plan.Path[^1].Elevation * 240d, Floor - 0.1d, Floor + 0.1d); + } + + /// + /// The underside of a ceiling, an arch or anything hanging faces down, and nothing stands + /// on it: a place just under the 5 m room's ceiling drops to the floor, never up onto + /// the ceiling. + /// + [Fact] + public void APlaceJustUnderACeilingDropsToTheFloorNotOntoTheCeiling() + { + PhysicsEngine world = DungeonWithTallRooms(); + var underTheCeiling = new Vector3(30f, 10f, -25.3f); + + Assert.Equal( + underTheCeiling with { Z = Floor }, + RuntimeNavigationGoalSource.OnTheCellFloor(world, TallRoom, underTheCeiling)); + } + + /// + /// An object the server placed is no part of the landblock, so a place is kept on its top + /// only because the walk names it as something a body stands on, as it does when it + /// builds its grid. The client's goal source passes that on. + /// + [Fact] + public void APlaceOnAPlatformTheServerPlacedStaysOnIt() + { + PhysicsEngine world = DungeonWithTallRooms(); + using GameRuntime runtime = GameRuntimeTestFactory.Create(); + RuntimeEntityRecord record = runtime.EntityObjects.RegisterEntity(Spawn(0x7000_0001u)).Canonical!; + runtime.EntityObjects.ApplyAcceptedSpawn(record, record.CreateIntegrationVersion, record.Snapshot, replaceGeneration: false); + runtime.InventoryOwner.Objects.AddOrUpdate(new ClientObject { ObjectId = 0x7000_0001u, Name = "Platform" }); + uint local = runtime.EntityObjects.Entities.ClaimLocalId(record); + RegisterCylinder(world, local, new Vector3(30f, 10f, Floor), PlatformRadius, 2f, TallRoom, isStatic: false); + RuntimeNavigationGoalSource goals = ClientGoals(world, runtime); + var onTop = new Vector3(30f, 10f, PlatformTop); + + Assert.True(goals.StandsStill(local)); + Assert.True(goals.TryLocatePlace(TallRoom, onTop, out Vector3 placed)); + Assert.Equal(onTop, placed); + Assert.Equal(onTop with { Z = Floor }, RuntimeNavigationGoalSource.OnTheCellFloor(world, TallRoom, onTop)); + } + private const float PlatformTop = Floor + 2f; + private const float PlatformRadius = 3f; - /// The 5 m room with a platform 2 m high and 3 m across the middle in it, which the landblock holds. + /// The 5 m room with a platform 2 m high and 6 m across in its middle, which the landblock holds. private static PhysicsEngine DungeonWithAPlatform() { PhysicsEngine physics = DungeonWithTallRooms(); + RegisterCylinder(physics, 0x7A9B_4001u, new Vector3(30f, 10f, Floor), PlatformRadius, 2f, TallRoom, isStatic: true); + return physics; + } + + private static void RegisterCylinder( + PhysicsEngine physics, + uint entityId, + Vector3 at, + float radius, + float height, + uint cellId, + bool isStatic) => physics.ShadowObjects.Register( - 0x7A9B_4001u, + entityId, gfxObjId: 0u, - new Vector3(30f, 10f, Floor), + at, Quaternion.Identity, - 3f, + radius, worldOffsetX: 0f, worldOffsetY: 0f, landblockId: 0xA9B4FFFFu, collisionType: ShadowCollisionType.Cylinder, - cylHeight: 2f, - seedCellId: TallRoom, - isStatic: true); - return physics; - } + cylHeight: height, + seedCellId: cellId, + isStatic: isStatic); private static AcDream.Core.Navigation.NavGeometry NavigationGeometry(PhysicsEngine world) => AcDream.Core.Navigation.NavGeometry.CaptureDungeon(world, 0xA9B4FFFFu, 0f, 0f, 64f)!; /// The client's own goal source, the character standing in the low room. - private static RuntimeNavigationGoalSource ClientGoals(PhysicsEngine world) + private static RuntimeNavigationGoalSource ClientGoals(PhysicsEngine world, GameRuntime? runtime = null) { var start = new Vector3(10f, 10f, Floor); var controller = new PlayerMovementController(world); controller.PreparePositionForCommit(start, LowRoom, start); var movement = new RuntimeLocalPlayerMovementState { Controller = controller }; - return new RuntimeNavigationGoalSource(world, GameRuntimeTestFactory.Create(), movement); + return new RuntimeNavigationGoalSource(world, runtime ?? GameRuntimeTestFactory.Create(), movement); + } + + /// A server object as the client is told of it: no player, nothing ethereal, no creature. + private static WorldSession.EntitySpawn Spawn(uint guid) + { + var position = new CreateObject.ServerPosition(TallRoom, 30f, 10f, Floor, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: 1); + var physics = new PhysicsSpawnData( + RawState: 0u, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x0200_0001u, + MotionTableId: null, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + 0x0200_0001u, + [], + [], + [], + null, + null, + guid.ToString("X8"), + null, + null, + null, + PhysicsState: physics.RawState, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); } private static Task Preview(Goals goals, uint cellId, Vector3 local) diff --git a/tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs b/tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs index 8dcb49e6d..d736b0b79 100644 --- a/tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs @@ -56,7 +56,8 @@ public void SampleLevelZAtOrBelow_TakesTheFloorUnderThePointNotTheCeilingOverIt( [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)); + // The ceiling faces down, so even a point over it stands on the floor, not on the ceiling. + Assert.Equal(0f, surface.SampleLevelZAtOrBelow(5f, 5f, 6f)); Assert.Null(surface.SampleLevelZAtOrBelow(5f, 5f, -1f)); Assert.Null(surface.SampleLevelZAtOrBelow(20f, 5f, 2.5f)); }