ModernUO/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs
Kamron Batman 54c0040ac8
test: run tile-dependent tests on synthetic terrain instead of client files (#2725)
## Summary

About 114 tests skipped on CI because they needed the official client files (`tiledata.mul`, map and multi files). They gave CI no signal, and locally they ran against whichever client a developer had installed. The test hosts now build their own world instead, so every test runs on CI and runs the same on every machine. CI simulation: Server.Tests 837 passed, UOContent.Tests 1131 passed, **0 skipped**.

- **Generated tile data.** The fixtures write a pre-HS `tiledata.mul` from a small invented tile kit (`Server.Tests/Terrain/TestTiles`) and load it through the real `TileData.Load()`. The test hosts no longer read any client directory (`MODERNUO_CLIENT_PATH`, `MODERNUO_TEST_DATA_DIR` and `C:\Ultima Online Classic` are gone from test code). The file is written to `SyntheticData` in each test project's output, overwritten every run and removed by `dotnet clean`.
- **Injected terrain.** `TestTerrain` stamps land and statics into Felucca and Trammel, which share one terrain. Writes after startup must go in a `TestTerrain.Scope()`, which puts back every block it touched and keeps the step cache fresh.
- **Prefab scenes.** Seven named scenes (town, causeway, sewer walkway, coastline, deep sea, map-edge sea, an upstairs room with an L-shaped counter) replace the Britain coordinates the pathing, AI and fishing tests relied on. Each scene-based test asserts its scene is present first, so a missing scene fails instead of passing on flat ground. Stamping all seven takes 3-5 ms.
- **Multi footprints.** Contrived footprints for the three multi IDs content looks up (0x74, 0x7A, 0x0) are seeded through a new internal `MultiData.SetComponents`.
- **Fingerprint.** `StepCacheFile.HashTileDataFile` is extracted so a test can show that a one-flag change to the file changes the fingerprint, while runtime table mutation does not.

### Engine changes

- `TileMatrix.GetLandBlock`/`GetStaticBlock`: on a map without its files, return the block injected with `SetLandBlock`/`SetStaticBlock`, or the default when nothing was injected. Maps with files take the same path as before.
- `MultiData.SetComponents` (internal): registers a footprint without multi files.

### Test changes worth a look

- Values that were properties of real Britain terrain (probes at Z 10, a pinned plain walk mask of 0xC1) are re-pinned to the synthetic plain (Z 0, 0xFF).
- No tests were deleted. The multi walkability and boat-deck tests assert movement behaviour over a footprint, not what a real multi looks like, so they run on the contrived footprints.
- Maps 0 and 1 keep file indexes 0 and 1, and every stamp writes both, rather than sharing one file index. On a map without files the getters never consult the shared-file cache, and its patch bits would hide one facet's injection from the other.

## Test plan

- [x] `dotnet test -p:SolutionDir=... --filter "Category!=Manual"` with the client env vars pointed at an empty directory: 837 + 1131 passed, 0 skipped, 0 failed
- [x] Mutation check: removing the step-cache clear from `TestTerrain` makes the stale-cache tests fail
- [x] CI green on all platforms
2026-10-18 22:07:40 -07:00

440 lines
16 KiB
C#

using Server.Tests.Terrain;
using System;
using Server.Engines.Pathing.Cache;
using Server.Mobiles;
using Server.PathAlgorithms;
using Server.Systems.FeatureFlags;
using Xunit;
using Xunit.Abstractions;
namespace Server.Tests.Pathfinding;
/// <summary>
/// Smoke tests for <see cref="BitmapAStarAlgorithm"/>'s two branches: cache-direct fast
/// path (default walkers) and per-cell slow path (capability creatures and non-GM players).
/// Exercised end-to-end against the real Trammel TileMatrix to ensure neither regresses
/// to "no path found" on reachable goals.
/// </summary>
[Collection("Sequential Pathfinding Tests")]
public class BitmapAStarAlgorithmTests : IDisposable
{
private readonly ITestOutputHelper _output;
private readonly StepCacheStateScope _cacheState = new();
public BitmapAStarAlgorithmTests(ITestOutputHelper output)
{
_output = output;
}
public void Dispose() => _cacheState.Dispose();
[Theory]
// Pinned cell (1500, 1600, z=10): mask=0xC1 → N, W, NW walkable.
// Use start.Z for goal.Z so destNode lands in the same Z plane the algorithm reaches
// during expansion (GetAverageZ at the goal cell may differ from the engine's
// runtime-computed standing Z, which would break the destNode equality check).
[InlineData(1500, 1600, 1498, 1598)] // NW, 2 cells diagonal
[InlineData(1500, 1600, 1497, 1599)] // NW-ish, 3 W + 1 N
public void DefaultWalker_FindsPath_ViaCacheFastPath(int sx, int sy, int gx, int gy)
{
StepCache.Instance.Clear();
StepCache.Instance.MissPromotionThreshold = 1;
var map = Map.Maps[1];
Assert.NotNull(map);
var stub = new WalkerStub();
map.GetAverageZ(sx, sy, out _, out var startZ, out _);
var start = new Point3D(sx, sy, (sbyte)startZ);
var goal = new Point3D(gx, gy, (sbyte)startZ);
stub.MoveToWorld(start, map);
var statsBefore = StepCache.Instance.GetStats();
var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal);
var statsAfter = StepCache.Instance.GetStats();
stub.Delete();
Assert.NotNull(result);
Assert.NotEmpty(result);
Assert.True(statsAfter.BuildsTotal > statsBefore.BuildsTotal,
"a default walker should expand through the cache, not the slow path");
_output.WriteLine($"default-walker ({sx},{sy})->({gx},{gy}): {result.Length} steps");
}
[Theory]
[InlineData(1500, 1600, 1498, 1598)] // NW, 2 cells diagonal
[InlineData(1500, 1600, 1497, 1599)] // NW-ish, 3 W + 1 N
public void SwimCreature_FindsPath_ViaCacheCapabilityOverlay(int sx, int sy, int gx, int gy)
{
StepCache.Instance.Clear();
StepCache.Instance.MissPromotionThreshold = 1;
var map = Map.Maps[1];
Assert.NotNull(map);
var stub = new BareCreatureStub(World.NewMobile);
stub.DefaultMobileInit();
stub.CanSwim = true; // overlay route — cache + (walkMask | wetMask&canSwim)
map.GetAverageZ(sx, sy, out _, out var startZ, out _);
var start = new Point3D(sx, sy, (sbyte)startZ);
var goal = new Point3D(gx, gy, (sbyte)startZ);
stub.MoveToWorld(start, map);
var statsBefore = StepCache.Instance.GetStats();
var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal);
var statsAfter = StepCache.Instance.GetStats();
stub.Delete();
Assert.NotNull(result);
Assert.NotEmpty(result);
Assert.True(statsAfter.BuildsTotal > statsBefore.BuildsTotal,
"CanSwim creature should use the cache via capability overlay, not the slow path");
_output.WriteLine($"swimmer ({sx},{sy})->({gx},{gy}): {result.Length} steps");
}
[Fact]
public void DynamicObstaclePass_RejectsCellOccupiedByLivingMobile()
{
StepCache.Instance.Clear();
var map = Map.Maps[1];
Assert.NotNull(map);
// (1500, 1600) walks N/W/NW only (mask=0xC1). Path NW two cells; plant a blocker
// on the direct NW step so the algorithm must route via N→W or W→N around it.
var sx = 1500;
var sy = 1600;
var gx = 1498;
var gy = 1598;
var blockX = 1499;
var blockY = 1599;
map.GetAverageZ(sx, sy, out _, out var startZ, out _);
var start = new Point3D(sx, sy, (sbyte)startZ);
var goal = new Point3D(gx, gy, (sbyte)startZ);
var walker = new WalkerStub();
walker.MoveToWorld(start, map);
// Living blocker on the only direct-line cell. CanMoveOver returns false for an
// alive non-staff mobile, so the dynamic-obstacle pass must reject this cell.
map.GetAverageZ(blockX, blockY, out _, out var blockZ, out _);
var blocker = new WalkerStub();
blocker.MoveToWorld(new Point3D(blockX, blockY, (sbyte)blockZ), map);
var result = BitmapAStarAlgorithm.Instance.Find(walker, map, start, goal);
// Path may exist via an alternate route, but must NOT pass through the blocker.
Assert.NotNull(result);
var x = sx;
var y = sy;
foreach (var dir in result)
{
Movement.Movement.Offset(dir, ref x, ref y);
Assert.False(x == blockX && y == blockY,
$"path traversed blocker cell ({blockX},{blockY})");
}
walker.Delete();
blocker.Delete();
}
[Fact]
public void DynamicObstaclePass_RejectsCellOccupiedByImpassableItem()
{
StepCache.Instance.Clear();
var map = Map.Maps[1];
Assert.NotNull(map);
// Same NW-around-blocker scenario as the mobile-blocker test, but with an item.
var sx = 1500;
var sy = 1600;
var gx = 1498;
var gy = 1598;
var blockX = 1499;
var blockY = 1599;
const ushort blockerItemId = TestTiles.Counter;
map.GetAverageZ(sx, sy, out _, out var startZ, out _);
var start = new Point3D(sx, sy, (sbyte)startZ);
var goal = new Point3D(gx, gy, (sbyte)startZ);
var walker = new WalkerStub();
walker.MoveToWorld(start, map);
map.GetAverageZ(blockX, blockY, out _, out var blockZ, out _);
var blocker = new Item(World.NewItem)
{
ItemID = blockerItemId,
Map = map,
Location = new Point3D(blockX, blockY, (sbyte)blockZ)
};
var result = BitmapAStarAlgorithm.Instance.Find(walker, map, start, goal);
Assert.NotNull(result);
var x = sx;
var y = sy;
foreach (var dir in result)
{
Movement.Movement.Offset(dir, ref x, ref y);
Assert.False(x == blockX && y == blockY,
$"path traversed item-blocker cell ({blockX},{blockY})");
}
walker.Delete();
blocker.Delete();
}
[Fact]
public void FeatureFlagDisabled_RoutesToSlowPath_NoCacheUse()
{
StepCache.Instance.Clear();
var map = Map.Maps[1];
Assert.NotNull(map);
var stub = new WalkerStub();
map.GetAverageZ(1500, 1600, out _, out var startZ, out _);
var start = new Point3D(1500, 1600, (sbyte)startZ);
var goal = new Point3D(1498, 1598, (sbyte)startZ);
stub.MoveToWorld(start, map);
var statsBefore = StepCache.Instance.GetStats();
ContentFeatureFlags.BitmapPathfindingCache = false;
var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal);
var statsAfter = StepCache.Instance.GetStats();
stub.Delete();
Assert.NotNull(result);
Assert.NotEmpty(result);
Assert.Equal(statsBefore.BuildsTotal, statsAfter.BuildsTotal);
}
[Fact]
public void FlyCreature_RoutesToSlowPath_NoCacheUse()
{
StepCache.Instance.Clear();
var map = Map.Maps[1];
var stub = new FlyingStub(World.NewMobile);
stub.DefaultMobileInit();
map.GetAverageZ(1500, 1600, out _, out var startZ, out _);
var start = new Point3D(1500, 1600, (sbyte)startZ);
var goal = new Point3D(1498, 1598, (sbyte)startZ);
stub.MoveToWorld(start, map);
var statsBefore = StepCache.Instance.GetStats();
var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal);
var statsAfter = StepCache.Instance.GetStats();
stub.Delete();
Assert.NotNull(result);
Assert.NotEmpty(result);
Assert.Equal(statsBefore.BuildsTotal, statsAfter.BuildsTotal);
}
[Fact]
public void NonGmPlayer_UsesCache_WithStrictDiagonalRule()
{
StepCache.Instance.Clear();
StepCache.Instance.MissPromotionThreshold = 1;
var map = Map.Maps[1];
Assert.NotNull(map);
var stub = new PlayerStub();
map.GetAverageZ(1500, 1600, out _, out var startZ, out _);
var start = new Point3D(1500, 1600, (sbyte)startZ);
var goal = new Point3D(1498, 1598, (sbyte)startZ);
stub.MoveToWorld(start, map);
var statsBefore = StepCache.Instance.GetStats();
var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal);
var statsAfter = StepCache.Instance.GetStats();
stub.Delete();
Assert.NotNull(result);
Assert.NotEmpty(result);
// BuildsTotal increments on every chunk build, which happens only when the cache
// is queried. Slow path never touches the cache.
Assert.True(statsAfter.BuildsTotal > statsBefore.BuildsTotal,
"Non-GM player should use the cache, not the slow path");
}
[Fact]
public void DoorCreature_UsesCache_NotSlowPath()
{
StepCache.Instance.Clear();
StepCache.Instance.MissPromotionThreshold = 1;
var map = Map.Maps[1];
var stub = new DoorOpenerStub(World.NewMobile);
stub.DefaultMobileInit();
map.GetAverageZ(1500, 1600, out _, out var startZ, out _);
var start = new Point3D(1500, 1600, (sbyte)startZ);
var goal = new Point3D(1498, 1598, (sbyte)startZ);
stub.MoveToWorld(start, map);
var statsBefore = StepCache.Instance.GetStats();
var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal);
var statsAfter = StepCache.Instance.GetStats();
stub.Delete();
Assert.NotNull(result);
Assert.NotEmpty(result);
Assert.True(statsAfter.BuildsTotal > statsBefore.BuildsTotal,
"CanOpenDoors creature should use the cache (doors are dynamic items)");
}
[Fact]
public void ObstacleCreature_UsesCache_NotSlowPath()
{
StepCache.Instance.Clear();
StepCache.Instance.MissPromotionThreshold = 1;
var map = Map.Maps[1];
var stub = new ObstacleClimberStub(World.NewMobile);
stub.DefaultMobileInit();
map.GetAverageZ(1500, 1600, out _, out var startZ, out _);
var start = new Point3D(1500, 1600, (sbyte)startZ);
var goal = new Point3D(1498, 1598, (sbyte)startZ);
stub.MoveToWorld(start, map);
var statsBefore = StepCache.Instance.GetStats();
var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal);
var statsAfter = StepCache.Instance.GetStats();
stub.Delete();
Assert.NotNull(result);
Assert.NotEmpty(result);
Assert.True(statsAfter.BuildsTotal > statsBefore.BuildsTotal,
"CanMoveOverObstacles creature should use the cache (movables are dynamic items)");
}
// ---------------------------------------------------------------------------------
// Promotion-gate integration tests. These exercise BitmapAStarAlgorithm.Find()
// end-to-end against the live StepCache to prove the per-Find generation gate
// actually defers BuildChunk on a single pathfind. They duplicate behavior that
// unit tests cover at the cache layer; the value is end-to-end verification that
// the bench-relevant scenario (single Find on cleared cache) skips builds entirely.
// TODO: REMOVE these two tests once PR-6's gate is proven stable in production BDN.
// ---------------------------------------------------------------------------------
[Fact]
public void Find_SinglePathfindOnClearedCache_DoesNotBuildAnyChunk()
{
StepCache.Instance.Clear();
StepCache.Instance.MissPromotionThreshold = 2;
var map = Map.Maps[1];
var stub = new WalkerStub();
map.GetAverageZ(1500, 1600, out _, out var startZ, out _);
var start = new Point3D(1500, 1600, (sbyte)startZ);
var goal = new Point3D(1498, 1598, (sbyte)startZ);
stub.MoveToWorld(start, map);
var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal);
var stats = StepCache.Instance.GetStats();
stub.Delete();
Assert.NotNull(result);
Assert.Equal(0L, stats.BuildsTotal);
Assert.True(stats.FallthroughNotBuilt > 0L,
$"expected fallthrough on every chunk touched once; got 0 (residents={stats.ResidentChunks})");
_output.WriteLine(
$"single-Find gate: builds={stats.BuildsTotal} fallthrough_not_built={stats.FallthroughNotBuilt}"
);
}
[Fact]
public void Find_TwoPathfindsOverlappingChunks_PromoteToBuildOnSecondFind()
{
StepCache.Instance.Clear();
StepCache.Instance.MissPromotionThreshold = 2;
// Pinned: promotion needs both Finds inside the window, and Linux's coarse TickCount can
// step several ms between two back-to-back Finds.
StepCache.Instance.MissPromotionWindowMs = 30_000;
var map = Map.Maps[1];
var stub = new WalkerStub();
map.GetAverageZ(1500, 1600, out _, out var startZ, out _);
var start = new Point3D(1500, 1600, (sbyte)startZ);
var goal = new Point3D(1498, 1598, (sbyte)startZ);
stub.MoveToWorld(start, map);
// Find #1: first time anyone touches these chunks. Gate defers; no builds.
BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal);
var afterFirst = StepCache.Instance.GetStats();
Assert.Equal(0L, afterFirst.BuildsTotal);
// Find #2: same path; chunks now hit their second distinct Find inside the window.
// Gate promotes — at least one BuildChunk fires.
BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal);
var afterSecond = StepCache.Instance.GetStats();
stub.Delete();
Assert.True(afterSecond.BuildsTotal > 0L,
$"second Find through overlapping chunks must promote (got {afterSecond.BuildsTotal} builds)");
_output.WriteLine(
$"two-Find gate: first builds={afterFirst.BuildsTotal} second builds={afterSecond.BuildsTotal}"
);
}
/// <summary>
/// Mobile with Player=true and default AccessLevel (Player). Triggers the strict
/// AND-rule for diagonal corner-cut while still using the cache.
/// </summary>
private sealed class PlayerStub : Mobile
{
public PlayerStub()
{
Body = 0xC9;
Player = true;
}
}
/// <summary>
/// BaseCreature with CanFly=true — RequiresSlowPath returns true (Z-jumping is beyond
/// the cache's static-only scope), so GetSuccessors short-circuits to the slow path.
/// </summary>
private sealed class FlyingStub : BaseCreature
{
public FlyingStub(Serial serial) : base(serial)
{
Body = 0xC9;
}
public override bool CanFly => true;
}
private sealed class DoorOpenerStub : BaseCreature
{
public DoorOpenerStub(Serial serial) : base(serial)
{
Body = 0xC9;
}
public override bool CanOpenDoors => true;
}
private sealed class ObstacleClimberStub : BaseCreature
{
public ObstacleClimberStub(Serial serial) : base(serial)
{
Body = 0xC9;
}
public override bool CanMoveOverObstacles => true;
}
}