ModernUO/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.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

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");
}
}