## 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
406 lines
15 KiB
C#
406 lines
15 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Reflection;
|
|
using System.Threading;
|
|
using Server.Engines.Pathing.Cache;
|
|
using Server.Items;
|
|
using Xunit;
|
|
using static Server.Tests.Pathfinding.PathingTestSupport;
|
|
|
|
namespace Server.Tests.Pathfinding;
|
|
|
|
/// <summary>
|
|
/// How the cache decides what to build, what to serve, and what to throw away: the promotion gate,
|
|
/// the four fallthrough routes out of <see cref="StepCache.TryGetMask"/>, the strata and swim
|
|
/// layers, and LRU eviction.
|
|
/// </summary>
|
|
[Collection("Sequential Pathfinding Tests")]
|
|
public class StepCacheLifecycleTests : IDisposable
|
|
{
|
|
private readonly StepCacheStateScope _cacheState = new();
|
|
|
|
public void Dispose() => _cacheState.Dispose();
|
|
|
|
/// <summary>Resets to a known state and returns the singleton.</summary>
|
|
private static StepCache FreshCache(int promotionThreshold)
|
|
{
|
|
var cache = StepCache.Instance;
|
|
cache.Clear();
|
|
cache.MissPromotionThreshold = promotionThreshold;
|
|
return cache;
|
|
}
|
|
|
|
/// <summary>Builds the plain chunk and hands it back for a test to inject state into.</summary>
|
|
private static StepChunk BuiltPlainChunk(StepCache cache, Map map)
|
|
{
|
|
cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ);
|
|
|
|
var chunk = cache.GetResidentChunk(map.MapID, PlainX >> 4, PlainY >> 4);
|
|
Assert.NotNull(chunk);
|
|
return chunk;
|
|
}
|
|
|
|
// ---- promotion gate ----
|
|
|
|
/// <summary>
|
|
/// A chunk nothing has shown sustained interest in must not be built. The caller reads
|
|
/// IsHit=false as "use the slow path", which is the cheaper trade for a pet crossing a chunk
|
|
/// once: BuildChunk costs far more than the handful of slow-path steps it would save.
|
|
/// </summary>
|
|
[Fact]
|
|
public void FirstTouch_DefersBuild_AndFallsThrough()
|
|
{
|
|
var cache = FreshCache(promotionThreshold: 2);
|
|
var map = TestMap;
|
|
|
|
var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ);
|
|
|
|
Assert.False(lookup.IsHit);
|
|
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, lookup.HitKind);
|
|
|
|
var stats = cache.GetStats();
|
|
Assert.Equal(0, stats.ResidentChunks);
|
|
Assert.Equal(0L, stats.BuildsTotal);
|
|
Assert.Equal(0L, stats.MissesNotBuilt);
|
|
Assert.Equal(1L, stats.FallthroughNotBuilt);
|
|
}
|
|
|
|
[Fact]
|
|
public void SecondTouchInsideWindow_PromotesAndServes()
|
|
{
|
|
var cache = FreshCache(promotionThreshold: 2);
|
|
var map = TestMap;
|
|
|
|
Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ).IsHit);
|
|
|
|
var promoted = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ);
|
|
Assert.True(promoted.IsHit);
|
|
Assert.Equal(CacheHitKind.Miss_NotBuilt, promoted.HitKind);
|
|
Assert.Equal((byte)0xFF, promoted.WalkMask); // flat default ground: walkable in all eight directions
|
|
Assert.Equal(PlainZ, promoted.WalkZ_N);
|
|
|
|
var stats = cache.GetStats();
|
|
Assert.Equal(1, stats.ResidentChunks);
|
|
Assert.Equal(1L, stats.MissesNotBuilt);
|
|
Assert.Equal(1L, stats.BuildsTotal);
|
|
Assert.Equal(1L, stats.FallthroughNotBuilt);
|
|
|
|
// Now resident: a third query is a clean hit, not another miss.
|
|
var hit = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ);
|
|
Assert.Equal(CacheHitKind.Hit, hit.HitKind);
|
|
Assert.Equal((byte)0xFF, hit.WalkMask);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Two touches spread wider than the window are not interest, they're coincidence — a chunk
|
|
/// someone glanced through, then an unrelated creature wandering past minutes later. The count
|
|
/// restarts rather than accumulating toward a build.
|
|
/// </summary>
|
|
[Fact]
|
|
public void SecondTouchAfterWindow_RestartsTheCount()
|
|
{
|
|
var cache = FreshCache(promotionThreshold: 2);
|
|
cache.MissPromotionWindowMs = 1;
|
|
|
|
var map = TestMap;
|
|
|
|
Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ).IsHit);
|
|
Thread.Sleep(20); // outrun the window
|
|
|
|
var second = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ);
|
|
Assert.False(second.IsHit);
|
|
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, second.HitKind);
|
|
Assert.Equal(0, cache.GetStats().ResidentChunks);
|
|
Assert.Equal(2L, cache.GetStats().FallthroughNotBuilt);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The gate counts Finds, not probes. A single pathfind hits a chunk once per cell it expands
|
|
/// there, so counting probes would cross any threshold on the second cell and gate nothing at
|
|
/// all — the deferral would be dead code.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ManyProbesInOneFind_CountAsOneTouch()
|
|
{
|
|
var cache = FreshCache(promotionThreshold: 2);
|
|
var map = TestMap;
|
|
|
|
cache.BeginFindGeneration();
|
|
for (var i = 0; i < 8; i++)
|
|
{
|
|
// Eight different cells, all inside the same chunk.
|
|
var lookup = cache.TryGetMask(map, PlainX + i, PlainY, sourceZ: PlainZ);
|
|
Assert.False(lookup.IsHit);
|
|
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, lookup.HitKind);
|
|
}
|
|
|
|
Assert.Equal(0, cache.GetStats().ResidentChunks);
|
|
Assert.Equal(0L, cache.GetStats().BuildsTotal);
|
|
Assert.Equal(8L, cache.GetStats().FallthroughNotBuilt);
|
|
|
|
// A second Find is the second distinct touch, and crosses the threshold.
|
|
cache.BeginFindGeneration();
|
|
var promoted = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ);
|
|
|
|
Assert.Equal(CacheHitKind.Miss_NotBuilt, promoted.HitKind);
|
|
Assert.Equal(1, cache.GetStats().ResidentChunks);
|
|
Assert.Equal(1L, cache.GetStats().BuildsTotal);
|
|
}
|
|
|
|
/// <summary>Distinct Finds still don't promote if they straddle the window.</summary>
|
|
[Fact]
|
|
public void TwoFindsAcrossTheWindow_DoNotPromote()
|
|
{
|
|
var cache = FreshCache(promotionThreshold: 2);
|
|
cache.MissPromotionWindowMs = 1;
|
|
|
|
var map = TestMap;
|
|
|
|
cache.BeginFindGeneration();
|
|
Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ).IsHit);
|
|
|
|
Thread.Sleep(20);
|
|
|
|
cache.BeginFindGeneration();
|
|
var second = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ);
|
|
|
|
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, second.HitKind);
|
|
Assert.Equal(0, cache.GetStats().ResidentChunks);
|
|
}
|
|
|
|
[Fact]
|
|
public void EachChunkIsTrackedSeparately()
|
|
{
|
|
var cache = FreshCache(promotionThreshold: 2);
|
|
var map = TestMap;
|
|
|
|
// One touch each, in two different chunks: neither reaches the threshold on its own.
|
|
Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ).IsHit);
|
|
Assert.False(cache.TryGetMask(map, 1600, 1700, sourceZ: PlainZ).IsHit);
|
|
|
|
Assert.Equal(0, cache.GetStats().ResidentChunks);
|
|
Assert.Equal(2L, cache.GetStats().FallthroughNotBuilt);
|
|
}
|
|
|
|
// ---- fallthrough routes ----
|
|
|
|
[Fact]
|
|
public void OffMapCell_FallsThrough()
|
|
{
|
|
var lookup = FreshCache(2).TryGetMask(TestMap, -1, -1, sourceZ: 0);
|
|
|
|
Assert.False(lookup.IsHit);
|
|
Assert.Equal(CacheHitKind.Fallthrough_OffMap, lookup.HitKind);
|
|
Assert.Equal((byte)0, lookup.WalkMask);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A multi's cells fall through, and so does the 1-cell halo around it: a cell's mask encodes
|
|
/// the edges TO its neighbours, so a wall one cell over has to block them.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MultiCoveredCell_AndItsHalo_FallThrough()
|
|
{
|
|
var cache = FreshCache(promotionThreshold: 1);
|
|
var map = TestMap;
|
|
|
|
// A cell nowhere near a multi still serves from the static cache.
|
|
Assert.True(cache.TryGetMask(map, PlainX, PlainY, PlainZ).IsHit);
|
|
|
|
// Mark an isolated sector as multi-bearing. Sector.HasMultis only tests Count > 0 and the
|
|
// fallthrough never dereferences the multi, so a single null entry is enough — no real
|
|
// BaseMulti needed.
|
|
const int mx = 2000;
|
|
const int my = 2000;
|
|
var sx = mx >> 4;
|
|
var sy = my >> 4;
|
|
|
|
var sector = map.GetRealSector(sx, sy);
|
|
var multisField = typeof(Map.Sector).GetField("_multis", BindingFlags.NonPublic | BindingFlags.Instance);
|
|
Assert.NotNull(multisField);
|
|
|
|
var original = multisField.GetValue(sector);
|
|
try
|
|
{
|
|
multisField.SetValue(sector, new List<BaseMulti> { null });
|
|
|
|
// Inside the multi's sector.
|
|
Assert.Equal(CacheHitKind.Fallthrough_Multi, cache.TryGetMask(map, mx, my, 0).HitKind);
|
|
|
|
// Last cell of the neighbouring sector: its halo reaches across the boundary.
|
|
Assert.Equal(CacheHitKind.Fallthrough_Multi, cache.TryGetMask(map, sx * 16 - 1, my, 0).HitKind);
|
|
|
|
// One cell further out: halo no longer reaches, so the static cache handles it.
|
|
Assert.NotEqual(CacheHitKind.Fallthrough_Multi, cache.TryGetMask(map, sx * 16 - 2, my, 0).HitKind);
|
|
|
|
Assert.True(cache.GetStats().FallthroughMulti >= 2);
|
|
}
|
|
finally
|
|
{
|
|
multisField.SetValue(sector, original);
|
|
}
|
|
}
|
|
|
|
/// <summary>A query too far from the cell's baked Z gets no answer, rather than a wrong one.</summary>
|
|
[Fact]
|
|
public void SourceZFarFromBake_FallsThrough()
|
|
{
|
|
var cache = FreshCache(promotionThreshold: 1);
|
|
var map = TestMap;
|
|
|
|
cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ);
|
|
var before = cache.GetStats().FallthroughSourceZMismatch;
|
|
|
|
var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 100);
|
|
|
|
Assert.False(lookup.IsHit);
|
|
Assert.Equal(CacheHitKind.Fallthrough_SourceZMismatch, lookup.HitKind);
|
|
Assert.Equal(before + 1L, cache.GetStats().FallthroughSourceZMismatch);
|
|
}
|
|
|
|
// ---- strata ----
|
|
|
|
[Fact]
|
|
public void Stratum_MatchingQueryZ_IsServed()
|
|
{
|
|
var cache = FreshCache(promotionThreshold: 1);
|
|
var map = TestMap;
|
|
var chunk = BuiltPlainChunk(cache, map);
|
|
|
|
var offsets = NoStrataOffsets();
|
|
offsets[CellIndex(PlainX, PlainY)] = 0;
|
|
chunk.SetStrata(offsets, OneStratum(zCenter: 42, walkMask: 0b0000_0011, walkZs: [42, 42]));
|
|
|
|
var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 42);
|
|
|
|
Assert.True(lookup.IsHit);
|
|
Assert.Equal((byte)0b0000_0011, lookup.WalkMask);
|
|
Assert.Equal((sbyte)42, lookup.WalkZ_N);
|
|
Assert.Equal((sbyte)42, lookup.WalkZ_NE);
|
|
}
|
|
|
|
[Fact]
|
|
public void Stratum_QueryZOutOfReach_FallsThrough()
|
|
{
|
|
var cache = FreshCache(promotionThreshold: 1);
|
|
var map = TestMap;
|
|
var chunk = BuiltPlainChunk(cache, map);
|
|
|
|
var offsets = NoStrataOffsets();
|
|
offsets[CellIndex(PlainX, PlainY)] = 0;
|
|
chunk.SetStrata(offsets, OneStratum(zCenter: 42));
|
|
|
|
// The plain is more than StepHeight below the only stratum (Z 42), so nothing can answer.
|
|
var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ);
|
|
|
|
Assert.False(lookup.IsHit);
|
|
Assert.Equal(CacheHitKind.Fallthrough_MultiZ, lookup.HitKind);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A cell flagged multi-Z is served only from its strata. If it has none that match — here, a
|
|
/// zero-count record — it must fall through rather than quietly fall back to the main mask,
|
|
/// which was baked for a different surface.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MultiZCell_WithNoUsableStratum_FallsThrough()
|
|
{
|
|
var cache = FreshCache(promotionThreshold: 1);
|
|
var map = TestMap;
|
|
var chunk = BuiltPlainChunk(cache, map);
|
|
|
|
var before = cache.GetStats().FallthroughMultiZ;
|
|
|
|
var offsets = NoStrataOffsets();
|
|
offsets[CellIndex(PlainX, PlainY)] = 0;
|
|
chunk.SetStrata(offsets, [0]); // a record declaring zero strata
|
|
|
|
var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ);
|
|
|
|
Assert.False(lookup.IsHit);
|
|
Assert.Equal(CacheHitKind.Fallthrough_MultiZ, lookup.HitKind);
|
|
Assert.Equal(before + 1L, cache.GetStats().FallthroughMultiZ);
|
|
}
|
|
|
|
// ---- swim layer ----
|
|
|
|
[Fact]
|
|
public void SwimLayer_QueryAtWaterZ_IsServedFromTheLayer()
|
|
{
|
|
var cache = FreshCache(promotionThreshold: 1);
|
|
var map = TestMap;
|
|
var chunk = BuiltPlainChunk(cache, map);
|
|
|
|
var cell = CellIndex(PlainX, PlainY);
|
|
var bakedZ = chunk.SourceZ[cell];
|
|
|
|
// Place the water surface well clear of the walk surface, so the primary source-Z guard is
|
|
// guaranteed to reject the swim query and hand it to the layer.
|
|
var swimZ = (sbyte)(bakedZ - 20);
|
|
|
|
chunk.AllocateSwimLayer();
|
|
chunk.SwimSourceZ[cell] = swimZ;
|
|
chunk.SwimMask[cell] = 0b0000_0011;
|
|
chunk.SwimZN_Layer[cell] = swimZ;
|
|
chunk.SwimZNE_Layer[cell] = swimZ;
|
|
|
|
// At the walk surface, the layer is not consulted at all.
|
|
Assert.Equal(CacheHitKind.Hit, cache.TryGetMask(map, PlainX, PlainY, bakedZ).HitKind);
|
|
|
|
var swim = cache.TryGetMask(map, PlainX, PlainY, swimZ);
|
|
Assert.True(swim.IsHit);
|
|
Assert.Equal((byte)0, swim.WalkMask); // a swimmer can't walk
|
|
Assert.Equal((byte)0b0000_0011, swim.WetMask);
|
|
Assert.Equal(swimZ, swim.SwimZ_N);
|
|
Assert.Equal(swimZ, swim.SwimZ_NE);
|
|
}
|
|
|
|
/// <summary>
|
|
/// An inland cell in a chunk that has a swim layer carries the NoSwimLayerCell sentinel. That
|
|
/// sentinel is sbyte.MinValue, so a query at sbyte.MinValue would match it exactly on a naive
|
|
/// distance check — the guard has to reject the sentinel before measuring anything.
|
|
/// </summary>
|
|
[Fact]
|
|
public void SwimLayer_SentinelCell_IsNeverMatched()
|
|
{
|
|
var cache = FreshCache(promotionThreshold: 1);
|
|
var map = TestMap;
|
|
var chunk = BuiltPlainChunk(cache, map);
|
|
|
|
chunk.AllocateSwimLayer(); // allocated for some other cell; this one stays at the sentinel
|
|
|
|
var cell = CellIndex(PlainX, PlainY);
|
|
Assert.Equal(StepChunk.NoSwimLayerCell, chunk.SwimSourceZ[cell]);
|
|
|
|
var before = cache.GetStats().FallthroughSourceZMismatch;
|
|
var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: sbyte.MinValue);
|
|
|
|
Assert.False(lookup.IsHit);
|
|
Assert.Equal(CacheHitKind.Fallthrough_SourceZMismatch, lookup.HitKind);
|
|
Assert.Equal(before + 1L, cache.GetStats().FallthroughSourceZMismatch);
|
|
}
|
|
|
|
// ---- eviction ----
|
|
|
|
[Fact]
|
|
public void LruCap_EvictsDownToTheCap()
|
|
{
|
|
var cache = FreshCache(promotionThreshold: 1);
|
|
cache.MaxResidentChunks = 4;
|
|
|
|
var map = TestMap;
|
|
|
|
// Five chunks into a cache that holds four. Which one goes doesn't matter here, only that
|
|
// the count comes down and the key list stays in step, so equal touch ticks are fine.
|
|
for (var i = 0; i < 5; i++)
|
|
{
|
|
cache.TryGetMask(map, PlainX + i * 16, PlainY, sourceZ: PlainZ);
|
|
}
|
|
|
|
cache.EnforceLruCap();
|
|
|
|
Assert.Equal(4, cache.GetStats().ResidentChunks);
|
|
Assert.True(cache.GetStats().EvictionsByLruCap >= 1L);
|
|
Assert.True(cache.ResidentIndexInSync(), "eviction desynced the key list from the resident set");
|
|
}
|
|
}
|