diff --git a/docs/navigation.md b/docs/navigation.md
index ff6b3c37..43e1b7a5 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 e363fd9e..909fcd1d 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 3a896f2e..13a64780 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 7081801c..b40c19bd 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 272f222d..e9c800b0 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 00000000..c1050edc
--- /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 00000000..7bf61a42
--- /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 edb724e5..f4ec9e4e 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 93e3d4b2..8dcb49e6 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()
{