feat(pathfinding): non-eager TryGetMask + second-touch promotion (#2451)
## Summary Closes the Cold-cache regression flagged in PR #2450. `StepCache.TryGetMask` no longer eagerly runs `BuildChunk` on the first miss for a chunk that isn't in a `.swb` lazy reader. Instead it returns `Fallthrough_NotBuilt` and the caller (`BitmapAStarAlgorithm`) takes the per-cell slow path. The chunk is only promoted to the bitmap fast path after the **second** miss within a 30-second window, filtering single-touch pass-throughs. This makes BitmapAStar's worst-case (cold cache + short hops) collapse from **12–47× slower** than FastAStar to **roughly the same**, which is the floor the slow path can deliver. Steady-state warm performance (the actual deliverable) is unchanged from PR-5 — it was always the cache fast path. ## The pet-follow scenario this fixes A mounted player at ~4 tiles/sec with a pet/hireable following will trigger an NPC pathfind every 100–300 ms. Each pathfind is 1–6 tiles. As the player crosses chunk boundaries (~4 sec/chunk), the pet's first pathfind in the new chunk under the previous behavior triggered a full ~700 µs `BuildChunk` for a chunk the player would leave shortly after. At 50–100 mobiles per shard, this exceeded the 8 ms tick budget. PR-5 BDN data showed scenarios 6–9 (2–8 tile NPC perception) at 2,300–3,700 µs Cold vs FastAStar's 80–200 µs. Under the new gate: - First miss → `Fallthrough_NotBuilt` → caller uses slow path (~30–50 µs short path). No `BuildChunk`. No allocation. - Player keeps moving → chunk never gets a second touch within window → never promoted, no rot. - NPC patrolling a fixed territory → repeatedly hits the same chunks → second touch within window → promote → cache fast path on subsequent calls. ## What changed - **`CacheHitKind.Fallthrough_NotBuilt = 6`** + **`CacheStats.FallthroughNotBuilt`** counter. `IsHit=false`, so the caller routes to slow path. - **`StepCache._chunkMissTracker`** — `Dictionary<long, ChunkMissState>` capped at 4096 entries. State is `(byte missCount, uint lastMissTickStamp)` keyed by chunk key. Window-expired entries reset count to 1; capacity overflow prunes window-old entries first. - **`StepCache.MissPromotionThreshold`** (default `2`) and **`StepCache.MissPromotionWindowMs`** (default `30_000`) — tunable, can be wired through `ServerConfiguration` if shards want different policy. Setting threshold to `1` restores legacy eager-build behavior (used by tests that prime chunks via single `TryGetMask` call). - **`StepCache.TryGetMask` miss branch** — try lazy reader first (file-loaded chunks bypass the tracker entirely; an `.swb` represents an explicit prior decision to keep the chunk warm). Otherwise consult the tracker. - **`BitmapAStarAlgorithm.GetSuccessorsSlowPath`** now layers `IsBlockedByDynamic` on top of `CalcMoves.CheckMovement`. Previously the slow path only ran for `CanFly` creatures and rare cache fallthroughs — `CheckMovement` doesn't iterate same-cell mobiles, so the bitmap fast path's `IsBlockedByDynamic` was the only mobile-blocking check. Now first-touch pathfinds run through the slow path, so the gap had to close. ## Tests 50 pathfinding tests pass (was 47). New / updated: - **`TryGetMask_FirstTouchOnUnbuiltChunk_DefersBuildAndReturnsFallthrough`** — single TryGetMask call returns `Fallthrough_NotBuilt`, no chunk built, no allocation. - **`TryGetMask_SecondTouchWithinWindow_PromotesAndBuilds`** — second call inside the 30s window builds + serves. - **`TryGetMask_SecondTouchAfterWindow_RestartsCounterAndDefers`** — second call outside the window restarts the count, returns Fallthrough again. - **`TryGetMask_DistinctChunks_TrackedIndependently`** — counters are per-chunk; one touch on each of two adjacent chunks both stay in fallthrough. - **`LazyReaderHit_BypassesMissTrackerOnFirstTouch`** — open `.swb` + first touch hits without consulting the tracker. Production with `.swb` loaded skips the gate entirely. - **`MultisVersion_Bump_TriggersDirtyRebuild`** — updated to reflect the new 3-step flow (Fallthrough → Miss_NotBuilt → Miss_DirtyRebuild). - Tests that prime chunks via a single `TryGetMask` call (multi-Z, Tier4, lifecycle, parity, BitmapAStar uses-cache) set `MissPromotionThreshold = 1` to opt into eager behavior. ## Expected BDN impact The Cold column from PR-5's BDN should change as follows once the bench's submodule pointer is updated to this branch: | # | Scenario | Cold (PR-5) | Cold (PR-6 expected) | FastAStar Cold | |--:|-----------------|-------------:|---------------------:|---------------:| | 2 | sewer corridor | 1,627 µs | ~36 µs | 36 µs | | 4 | causeway | 1,533 µs | ~39 µs | 39 µs | | 6 | pet 2-tile | 2,364 µs | ~80 µs | 81 µs | | 8 | npc 5-tile | 3,708 µs | ~140 µs | 141 µs | | 9 | npc 8-tile | 2,386 µs | ~200 µs | 197 µs | WarmNoFile and LazyWarm rows should be unchanged — they were always cache-warm. The miss tracker only fires when neither resident chunks nor the lazy reader can satisfy the request. ## Future work (not in this PR) - **Background-thread bake**: builds outside the game thread so even promoted chunks don't pay the 700 µs build cost on the main thread. Rule 10 (no Task.Run) applies, so this needs careful design — the bake is a pure data transform but main-thread synchronization on chunk-state transitions has to be threaded through. Defer to a follow-up. - **Long-traverse BDN scenario**: a multi-Find benchmark simulating 50 pet repaths across chunk transitions. Requires restructuring the bench harness; the existing 10-scenario corpus + Cold provider already exercises the gate. - **Swim sourceZ bake**: scenario 5 (sea serpent) shows 56 B alloc on warm paths because the cache's SourceZ is computed under default-walker rules. Swim creatures fall through to slow path. Independent of this PR.
This commit is contained in:
parent
cff9fbda29
commit
9a3d88988c
14 changed files with 1664 additions and 163 deletions
|
|
@ -58,6 +58,7 @@ public class BitmapAStarAlgorithmTests
|
|||
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);
|
||||
|
||||
|
|
@ -249,6 +250,7 @@ public class BitmapAStarAlgorithmTests
|
|||
public void NonGmPlayer_UsesCache_WithStrictDiagonalRule()
|
||||
{
|
||||
StepCache.Instance.Clear();
|
||||
StepCache.Instance.MissPromotionThreshold = 1;
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
|
||||
|
|
@ -277,6 +279,7 @@ public class BitmapAStarAlgorithmTests
|
|||
public void DoorCreature_UsesCache_NotSlowPath()
|
||||
{
|
||||
StepCache.Instance.Clear();
|
||||
StepCache.Instance.MissPromotionThreshold = 1;
|
||||
var map = Map.Maps[1];
|
||||
|
||||
var stub = new DoorOpenerStub(World.NewMobile);
|
||||
|
|
@ -303,6 +306,7 @@ public class BitmapAStarAlgorithmTests
|
|||
public void ObstacleCreature_UsesCache_NotSlowPath()
|
||||
{
|
||||
StepCache.Instance.Clear();
|
||||
StepCache.Instance.MissPromotionThreshold = 1;
|
||||
var map = Map.Maps[1];
|
||||
|
||||
var stub = new ObstacleClimberStub(World.NewMobile);
|
||||
|
|
@ -325,6 +329,74 @@ public class BitmapAStarAlgorithmTests
|
|||
"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 DefaultWalkerStub();
|
||||
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;
|
||||
|
||||
var map = Map.Maps[1];
|
||||
var stub = new DefaultWalkerStub();
|
||||
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>
|
||||
/// Plain Mobile — RequiresSlowPath returns false, the bitmap algorithm uses the cache
|
||||
/// fast path on every expansion.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using Server.Engines.Pathing.Cache;
|
||||
using Xunit;
|
||||
|
|
@ -17,15 +18,30 @@ public class StepCacheFileTests
|
|||
{
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 1; // eager build to populate chunks for save
|
||||
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
|
||||
// Populate three distinct chunks by querying different sectors.
|
||||
// Populate three distinct chunks by querying different sectors. Query at each cell's
|
||||
// real standable surface Z (where the cache anchors) so first-touch yields a clean
|
||||
// hit rather than an off-surface fallthrough.
|
||||
var sourceQueries = new[] { (1500, 1600), (1516, 1600), (1500, 1616) };
|
||||
foreach (var (x, y) in sourceQueries)
|
||||
var standZ = new sbyte[sourceQueries.Length];
|
||||
{
|
||||
cache.TryGetMask(map, x, y, sourceZ: 10);
|
||||
Span<sbyte> surfZ = stackalloc sbyte[16];
|
||||
for (var i = 0; i < sourceQueries.Length; i++)
|
||||
{
|
||||
var (qx, qy) = sourceQueries[i];
|
||||
var n = StepProbe.ComputeStandableSurfaceZs(map, qx, qy, surfZ);
|
||||
Assert.True(n > 0, $"({qx},{qy}) has no standable surface — bad test cell");
|
||||
standZ[i] = surfZ[0];
|
||||
}
|
||||
}
|
||||
for (var i = 0; i < sourceQueries.Length; i++)
|
||||
{
|
||||
var (x, y) = sourceQueries[i];
|
||||
cache.TryGetMask(map, x, y, standZ[i]);
|
||||
}
|
||||
|
||||
Assert.Equal(3, cache.GetStats().ResidentChunks);
|
||||
|
|
@ -36,7 +52,7 @@ public class StepCacheFileTests
|
|||
for (var i = 0; i < sourceQueries.Length; i++)
|
||||
{
|
||||
var (x, y) = sourceQueries[i];
|
||||
expected[i] = cache.TryGetMask(map, x, y, sourceZ: 10);
|
||||
expected[i] = cache.TryGetMask(map, x, y, standZ[i]);
|
||||
}
|
||||
|
||||
var path = Path.Combine(Path.GetTempPath(), $"step-cache-roundtrip-{System.Guid.NewGuid():N}.swb");
|
||||
|
|
@ -66,7 +82,7 @@ public class StepCacheFileTests
|
|||
for (var i = 0; i < sourceQueries.Length; i++)
|
||||
{
|
||||
var (x, y) = sourceQueries[i];
|
||||
var lookup = cache.TryGetMask(map, x, y, sourceZ: 10);
|
||||
var lookup = cache.TryGetMask(map, x, y, standZ[i]);
|
||||
Assert.Equal(CacheHitKind.Miss_NotBuilt, lookup.HitKind);
|
||||
Assert.Equal(expected[i].WalkMask, lookup.WalkMask);
|
||||
Assert.Equal(expected[i].WetMask, lookup.WetMask);
|
||||
|
|
@ -171,6 +187,7 @@ public class StepCacheFileTests
|
|||
{
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 1; // eager build to populate chunks for save
|
||||
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
|
|
@ -210,4 +227,175 @@ public class StepCacheFileTests
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// First-touch on a chunk that the lazy reader can satisfy must NOT route through the
|
||||
/// miss tracker — file-loaded chunks represent an explicit prior decision to keep
|
||||
/// them warm. This guards the deployment shape where an admin ships .swb files and
|
||||
/// expects the very first NPC pathfind in any region to use cache (not slow path).
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// A chunk with an injected swim layer must serialize and deserialize via the lazy
|
||||
/// reader without losing the layer. Validates v3 file format end-to-end: swim layer
|
||||
/// fields survive Save → Clear → LazyOpen → first-touch query.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SwimLayer_RoundTrips_ThroughLazyReader()
|
||||
{
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 1;
|
||||
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
|
||||
// Build a chunk and inject a synthetic swim layer onto cell (1500, 1600).
|
||||
cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||
|
||||
var chunksField = typeof(StepCache).GetField(
|
||||
"_chunks",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
|
||||
);
|
||||
var chunks = (System.Collections.Generic.Dictionary<long, StepChunk>)chunksField!.GetValue(cache)!;
|
||||
var key = StepCache.EncodeKey(map.MapID, 1500 >> 4, 1600 >> 4);
|
||||
var chunk = chunks[key];
|
||||
|
||||
chunk.AllocateSwimLayer();
|
||||
var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
|
||||
chunk.SwimSourceZ[cellIndex] = -7;
|
||||
chunk.SwimMask[cellIndex] = 0b0000_1111;
|
||||
chunk.SwimZN_Layer[cellIndex] = -7;
|
||||
chunk.SwimZNE_Layer[cellIndex] = -7;
|
||||
chunk.SwimZE_Layer[cellIndex] = -7;
|
||||
chunk.SwimZSE_Layer[cellIndex] = -7;
|
||||
|
||||
var path = Path.Combine(Path.GetTempPath(), $"step-cache-swim-{System.Guid.NewGuid():N}.swb");
|
||||
try
|
||||
{
|
||||
Assert.Equal(1, cache.SaveToFile(path, map.MapID));
|
||||
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 1;
|
||||
Assert.True(cache.TryOpenLazyReader(path, map.MapID));
|
||||
|
||||
// Pull the chunk back via a query at swim Z; the layer must hit and serve our
|
||||
// injected mask. Walk-Z query of the same cell should still hit the walk
|
||||
// layer with whatever the bake produced.
|
||||
var swim = cache.TryGetMask(map, 1500, 1600, sourceZ: -7);
|
||||
Assert.True(swim.IsHit);
|
||||
Assert.Equal((byte)0, swim.WalkMask);
|
||||
Assert.Equal((byte)0b0000_1111, swim.WetMask);
|
||||
Assert.Equal((sbyte)-7, swim.SwimZ_N);
|
||||
Assert.Equal((sbyte)-7, swim.SwimZ_E);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache.Clear();
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PreloadOnLazyOpen=true must materialize every chunk in the .swb file into the
|
||||
/// resident set immediately, eliminating first-touch file-read latency. Counterpart
|
||||
/// to <see cref="LazyReader_DoesNotMaterializeUntilQueried"/> which proves the
|
||||
/// default lazy behavior.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryOpenLazyReader_WithPreloadFlag_MaterializesAllChunksImmediately()
|
||||
{
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 1;
|
||||
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
|
||||
var coords = new (int, int)[]
|
||||
{
|
||||
(1500, 1600), (1516, 1600), (1500, 1616), (1516, 1616), (1532, 1600)
|
||||
};
|
||||
foreach (var (x, y) in coords)
|
||||
{
|
||||
cache.TryGetMask(map, x, y, sourceZ: 10);
|
||||
}
|
||||
|
||||
var path = Path.Combine(Path.GetTempPath(), $"step-cache-preload-{System.Guid.NewGuid():N}.swb");
|
||||
try
|
||||
{
|
||||
Assert.Equal(coords.Length, cache.SaveToFile(path, map.MapID));
|
||||
|
||||
cache.Clear();
|
||||
cache.PreloadOnLazyOpen = true;
|
||||
try
|
||||
{
|
||||
Assert.True(cache.TryOpenLazyReader(path, map.MapID));
|
||||
|
||||
// Every chunk should be resident — no further queries needed.
|
||||
Assert.Equal(coords.Length, cache.GetStats().ResidentChunks);
|
||||
Assert.Equal(0L, cache.GetStats().BuildsTotal); // came from file, not baker
|
||||
|
||||
// Subsequent query is a clean Hit, not a Miss_NotBuilt.
|
||||
var lookup = cache.TryGetMask(map, coords[0].Item1, coords[0].Item2, sourceZ: 10);
|
||||
Assert.Equal(CacheHitKind.Hit, lookup.HitKind);
|
||||
Assert.Equal(coords.Length, cache.GetStats().ResidentChunks);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache.PreloadOnLazyOpen = false;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache.Clear();
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LazyReaderHit_BypassesMissTrackerOnFirstTouch()
|
||||
{
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 1; // eager build for save phase
|
||||
|
||||
var map = Map.Maps[1];
|
||||
|
||||
// Build + save one chunk.
|
||||
cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||
var path = Path.Combine(Path.GetTempPath(), $"step-cache-bypass-{System.Guid.NewGuid():N}.swb");
|
||||
try
|
||||
{
|
||||
Assert.Equal(1, cache.SaveToFile(path, map.MapID));
|
||||
|
||||
// Reset to a fresh state with the file open as a lazy reader and the deferred
|
||||
// promotion threshold restored to 2.
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 2;
|
||||
Assert.True(cache.TryOpenLazyReader(path, map.MapID));
|
||||
|
||||
// First touch must NOT return Fallthrough_NotBuilt — the lazy reader has the
|
||||
// chunk and serves it before the miss tracker is consulted.
|
||||
var lookup = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||
Assert.True(lookup.IsHit);
|
||||
Assert.Equal(CacheHitKind.Miss_NotBuilt, lookup.HitKind);
|
||||
Assert.Equal(1, cache.GetStats().ResidentChunks);
|
||||
Assert.Equal(0L, cache.GetStats().FallthroughNotBuilt);
|
||||
Assert.Equal(0L, cache.GetStats().BuildsTotal); // came from file, not baker
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache.Clear();
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,38 +26,167 @@ public class StepCacheLifecycleTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetMask_FirstQuery_BuildsChunkAndReturnsBakerOutput()
|
||||
public void TryGetMask_FirstTouchOnUnbuiltChunk_DefersBuildAndReturnsFallthrough()
|
||||
{
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 2;
|
||||
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
|
||||
// Pinned cell (1500, 1600, z=10): mask=0xC1
|
||||
// First touch on a chunk that has no resident copy and no lazy reader behind it
|
||||
// must NOT eagerly build. Caller (BitmapAStarAlgorithm) interprets IsHit=false as
|
||||
// "use slow path" — pets/hireables passing briefly through a chunk avoid the
|
||||
// ~700µs BuildChunk cost they'd never amortize.
|
||||
var lookup = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||
|
||||
Assert.True(lookup.IsHit);
|
||||
Assert.Equal(CacheHitKind.Miss_NotBuilt, lookup.HitKind);
|
||||
Assert.Equal((byte)0xC1, lookup.WalkMask);
|
||||
Assert.Equal((sbyte)10, lookup.WalkZ_N);
|
||||
Assert.Equal((sbyte)10, lookup.WalkZ_W);
|
||||
Assert.Equal((sbyte)10, lookup.WalkZ_NW);
|
||||
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 TryGetMask_SecondTouchWithinWindow_PromotesAndBuilds()
|
||||
{
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 2;
|
||||
|
||||
var map = Map.Maps[1];
|
||||
|
||||
// First touch defers; second touch inside the promotion window builds + serves.
|
||||
// Pinned cell (1500, 1600, z=10): mask=0xC1
|
||||
var first = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||
Assert.False(first.IsHit);
|
||||
|
||||
var second = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||
Assert.True(second.IsHit);
|
||||
Assert.Equal(CacheHitKind.Miss_NotBuilt, second.HitKind);
|
||||
Assert.Equal((byte)0xC1, second.WalkMask);
|
||||
Assert.Equal((sbyte)10, second.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);
|
||||
|
||||
// Second query of same cell → Hit
|
||||
var lookup2 = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||
Assert.True(lookup2.IsHit);
|
||||
Assert.Equal(CacheHitKind.Hit, lookup2.HitKind);
|
||||
Assert.Equal((byte)0xC1, lookup2.WalkMask);
|
||||
// Third query of same cell → Hit (chunk now resident).
|
||||
var third = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||
Assert.True(third.IsHit);
|
||||
Assert.Equal(CacheHitKind.Hit, third.HitKind);
|
||||
Assert.Equal((byte)0xC1, third.WalkMask);
|
||||
}
|
||||
|
||||
var stats2 = cache.GetStats();
|
||||
Assert.Equal(1, stats2.ResidentChunks);
|
||||
Assert.Equal(1L, stats2.Hits);
|
||||
[Fact]
|
||||
public void TryGetMask_SecondTouchAfterWindow_RestartsCounterAndDefers()
|
||||
{
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 2;
|
||||
cache.MissPromotionWindowMs = 1; // 1ms window for testability
|
||||
|
||||
var map = Map.Maps[1];
|
||||
|
||||
var first = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||
Assert.False(first.IsHit);
|
||||
|
||||
System.Threading.Thread.Sleep(20); // exceed the window
|
||||
|
||||
// Second touch lands outside the window: tracker resets the count to 1, returns
|
||||
// Fallthrough_NotBuilt again — chunks the player just glanced through don't get
|
||||
// promoted just because they get re-touched minutes later by an unrelated NPC.
|
||||
var second = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||
Assert.False(second.IsHit);
|
||||
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, second.HitKind);
|
||||
Assert.Equal(0, cache.GetStats().ResidentChunks);
|
||||
Assert.Equal(2L, cache.GetStats().FallthroughNotBuilt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetMask_MultipleCallsInSameFindGeneration_StayInFallthrough()
|
||||
{
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 2;
|
||||
|
||||
var map = Map.Maps[1];
|
||||
|
||||
// Open a pathfind. Multiple TryGetMask calls inside this Find target the same chunk
|
||||
// (different cells). The promotion gate counts distinct Finds, not raw probes — these
|
||||
// calls must NOT increment the per-chunk counter, even though there are many of them.
|
||||
// Without this, A* expansion would trip the gate on the second cell expansion in any
|
||||
// visited chunk, defeating the whole point of deferred promotion.
|
||||
cache.BeginFindGeneration();
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
// All cells are inside chunk (1500>>4, 1600>>4) = (93, 100).
|
||||
var lookup = cache.TryGetMask(map, 1500 + i, 1600, sourceZ: 10);
|
||||
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);
|
||||
|
||||
// Begin a NEW Find — this is the second distinct touch under the per-Find gate.
|
||||
// The chunk now crosses the threshold and promotes.
|
||||
cache.BeginFindGeneration();
|
||||
var promoted = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||
Assert.True(promoted.IsHit);
|
||||
Assert.Equal(CacheHitKind.Miss_NotBuilt, promoted.HitKind);
|
||||
Assert.Equal(1, cache.GetStats().ResidentChunks);
|
||||
Assert.Equal(1L, cache.GetStats().BuildsTotal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetMask_TwoFindGenerationsAcrossWindow_RestartsCounter()
|
||||
{
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 2;
|
||||
cache.MissPromotionWindowMs = 1; // 1ms window for testability
|
||||
|
||||
var map = Map.Maps[1];
|
||||
|
||||
cache.BeginFindGeneration();
|
||||
Assert.False(cache.TryGetMask(map, 1500, 1600, sourceZ: 10).IsHit);
|
||||
|
||||
System.Threading.Thread.Sleep(20); // exceed window
|
||||
|
||||
// Second Find lands outside the window. Even though it's a distinct generation,
|
||||
// the elapsed-time check resets the counter to 1, so no promotion.
|
||||
cache.BeginFindGeneration();
|
||||
var second = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||
Assert.False(second.IsHit);
|
||||
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, second.HitKind);
|
||||
Assert.Equal(0, cache.GetStats().ResidentChunks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetMask_DistinctChunks_TrackedIndependently()
|
||||
{
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 2;
|
||||
|
||||
var map = Map.Maps[1];
|
||||
|
||||
// Two different chunks, one touch each — both must defer (each has its own counter).
|
||||
var chunkA = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||
var chunkB = cache.TryGetMask(map, 1600, 1700, sourceZ: 10); // different chunk
|
||||
|
||||
Assert.False(chunkA.IsHit);
|
||||
Assert.False(chunkB.IsHit);
|
||||
Assert.Equal(0, cache.GetStats().ResidentChunks);
|
||||
Assert.Equal(2L, cache.GetStats().FallthroughNotBuilt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -80,11 +209,13 @@ public class StepCacheLifecycleTests
|
|||
{
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 2;
|
||||
|
||||
var map = Map.Maps[1];
|
||||
var sector = map.GetRealSector(1500 >> 4, 1600 >> 4);
|
||||
|
||||
// First query: builds chunk, snapshots current MultisVersion.
|
||||
// First touch defers (Fallthrough_NotBuilt); second touch promotes and builds.
|
||||
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, cache.TryGetMask(map, 1500, 1600, 10).HitKind);
|
||||
Assert.Equal(CacheHitKind.Miss_NotBuilt, cache.TryGetMask(map, 1500, 1600, 10).HitKind);
|
||||
|
||||
// Bump _multisVersion via reflection.
|
||||
|
|
@ -96,18 +227,18 @@ public class StepCacheLifecycleTests
|
|||
var current = (int)versionField.GetValue(sector);
|
||||
versionField.SetValue(sector, current + 1);
|
||||
|
||||
// Second query: detects version mismatch, rebuilds.
|
||||
// Third query: detects version mismatch, rebuilds.
|
||||
Assert.Equal(CacheHitKind.Miss_DirtyRebuild, cache.TryGetMask(map, 1500, 1600, 10).HitKind);
|
||||
|
||||
var stats = cache.GetStats();
|
||||
Assert.Equal(1L, stats.MissesDirtyRebuild);
|
||||
Assert.Equal(2L, stats.BuildsTotal);
|
||||
|
||||
// Mutual-exclusivity invariant: every successful TryGetMask hits exactly one
|
||||
// outcome counter. Two queries above both returned true (the test cell is not
|
||||
// multi-Z and not off-map), so the three outcome counters must sum to 2 and the
|
||||
// fallthrough counters must be zero.
|
||||
// Mutual-exclusivity invariant: hits + miss-builds + dirty-rebuilds = served-result count.
|
||||
// Three calls returned an answer; two were "served from a build" (Miss_NotBuilt + Miss_DirtyRebuild),
|
||||
// and the first was a Fallthrough_NotBuilt (no build, slow-path signal).
|
||||
Assert.Equal(2L, stats.MissesNotBuilt + stats.MissesDirtyRebuild + stats.Hits);
|
||||
Assert.Equal(1L, stats.FallthroughNotBuilt);
|
||||
Assert.Equal(0L, stats.FallthroughMultiZ);
|
||||
Assert.Equal(0L, stats.FallthroughOffMap);
|
||||
}
|
||||
|
|
@ -117,6 +248,7 @@ public class StepCacheLifecycleTests
|
|||
{
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 1; // eager build for prime-then-inspect tests
|
||||
|
||||
var map = Map.Maps[1];
|
||||
|
||||
|
|
@ -165,6 +297,7 @@ public class StepCacheLifecycleTests
|
|||
{
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 1;
|
||||
|
||||
var map = Map.Maps[1];
|
||||
cache.TryGetMask(map, 1500, 1600, 10);
|
||||
|
|
@ -205,11 +338,122 @@ public class StepCacheLifecycleTests
|
|||
Assert.Equal((sbyte)42, lookup.WalkZ_NE);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SwimLayer_NotInjected_StaysFallthroughOnSourceZMismatch()
|
||||
{
|
||||
// Sanity check: a chunk WITHOUT a swim layer falls through on source-Z mismatch
|
||||
// exactly like before. Validates we didn't accidentally serve garbage when the
|
||||
// chunk has no shore cells.
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 1;
|
||||
|
||||
var map = Map.Maps[1];
|
||||
|
||||
cache.TryGetMask(map, 1500, 1600, sourceZ: 10); // build chunk
|
||||
var beforeMismatch = cache.GetStats().FallthroughSourceZMismatch;
|
||||
|
||||
// Same cell but query Z far from baked Z → source-Z guard fires.
|
||||
var lookup = cache.TryGetMask(map, 1500, 1600, sourceZ: 100);
|
||||
Assert.False(lookup.IsHit);
|
||||
Assert.Equal(CacheHitKind.Fallthrough_SourceZMismatch, lookup.HitKind);
|
||||
Assert.Equal(beforeMismatch + 1L, cache.GetStats().FallthroughSourceZMismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SwimLayer_InjectedMatchingZ_ReturnsHitFromSwimLayer()
|
||||
{
|
||||
// Inject a synthetic swim layer onto a resident chunk and verify a query at the
|
||||
// swim source Z routes through the swim-layer fallback, returning the swim mask.
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 1;
|
||||
|
||||
var map = Map.Maps[1];
|
||||
cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||
|
||||
var chunksField = typeof(StepCache).GetField(
|
||||
"_chunks",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
|
||||
);
|
||||
var chunks = (System.Collections.Generic.Dictionary<long, StepChunk>)chunksField!.GetValue(cache)!;
|
||||
var key = StepCache.EncodeKey(map.MapID, 1500 >> 4, 1600 >> 4);
|
||||
var chunk = chunks[key];
|
||||
|
||||
chunk.AllocateSwimLayer();
|
||||
var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
|
||||
chunk.SwimSourceZ[cellIndex] = -5;
|
||||
chunk.SwimMask[cellIndex] = 0b0000_0011;
|
||||
chunk.SwimZN_Layer[cellIndex] = -5;
|
||||
chunk.SwimZNE_Layer[cellIndex] = -5;
|
||||
// Other directions stay 0 — Mask bits 0 and 1 cover N and NE.
|
||||
|
||||
// Query at the chunk's primary SourceZ — primary path serves walk-layer data,
|
||||
// swim layer not consulted.
|
||||
var bakedSourceZ = chunk.SourceZ[cellIndex];
|
||||
var walkLookup = cache.TryGetMask(map, 1500, 1600, bakedSourceZ);
|
||||
Assert.True(walkLookup.IsHit);
|
||||
Assert.Equal(CacheHitKind.Hit, walkLookup.HitKind);
|
||||
// Walk-layer query produces walk-layer walkMask (whatever the bake found), NOT
|
||||
// the synthetic swim mask we injected.
|
||||
|
||||
// Query at the swim source Z — primary source-Z guard fails (|−5 − bakedZ| > 2
|
||||
// assuming baked Z is land surface), swim-layer fallback serves with our mask.
|
||||
if (System.Math.Abs(-5 - bakedSourceZ) <= 2)
|
||||
{
|
||||
// Bake landed near water Z — adjust the test to a clearer swim Z.
|
||||
chunk.SwimSourceZ[cellIndex] = (sbyte)(bakedSourceZ - 20);
|
||||
}
|
||||
var swimLookup = cache.TryGetMask(map, 1500, 1600, chunk.SwimSourceZ[cellIndex]);
|
||||
Assert.True(swimLookup.IsHit);
|
||||
Assert.Equal(CacheHitKind.Hit, swimLookup.HitKind);
|
||||
Assert.Equal((byte)0, swimLookup.WalkMask); // walk = 0 at swim Z
|
||||
Assert.Equal(chunk.SwimMask[cellIndex], swimLookup.WetMask);
|
||||
Assert.Equal(chunk.SwimZN_Layer[cellIndex], swimLookup.SwimZ_N);
|
||||
Assert.Equal(chunk.SwimZNE_Layer[cellIndex], swimLookup.SwimZ_NE);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SwimLayer_InjectedButCellHasNoSentinel_FallsThrough()
|
||||
{
|
||||
// Chunk has the swim layer (some other cell is shore), but THIS cell is inland
|
||||
// (SwimSourceZ = NoSwimLayerCell). Query at non-matching walk Z must fall through,
|
||||
// not erroneously match -128 against the query.
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 1;
|
||||
|
||||
var map = Map.Maps[1];
|
||||
cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||
|
||||
var chunksField = typeof(StepCache).GetField(
|
||||
"_chunks",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
|
||||
);
|
||||
var chunks = (System.Collections.Generic.Dictionary<long, StepChunk>)chunksField!.GetValue(cache)!;
|
||||
var key = StepCache.EncodeKey(map.MapID, 1500 >> 4, 1600 >> 4);
|
||||
var chunk = chunks[key];
|
||||
|
||||
// Allocate layer but leave THIS cell at the sentinel.
|
||||
chunk.AllocateSwimLayer();
|
||||
var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
|
||||
Assert.Equal(StepChunk.NoSwimLayerCell, chunk.SwimSourceZ[cellIndex]);
|
||||
|
||||
var beforeMismatch = cache.GetStats().FallthroughSourceZMismatch;
|
||||
// Query at -128 (the sentinel value) — must NOT match. The guard short-circuits
|
||||
// on the sentinel before computing |sourceZ - SwimSourceZ|.
|
||||
var lookup = cache.TryGetMask(map, 1500, 1600, sbyte.MinValue);
|
||||
Assert.False(lookup.IsHit);
|
||||
Assert.Equal(CacheHitKind.Fallthrough_SourceZMismatch, lookup.HitKind);
|
||||
Assert.Equal(beforeMismatch + 1L, cache.GetStats().FallthroughSourceZMismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tier4Strata_NonMatchingZ_FallsThrough()
|
||||
{
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 1;
|
||||
|
||||
var map = Map.Maps[1];
|
||||
cache.TryGetMask(map, 1500, 1600, 10);
|
||||
|
|
@ -246,6 +490,7 @@ public class StepCacheLifecycleTests
|
|||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MaxResidentChunks = 4;
|
||||
cache.MissPromotionThreshold = 1;
|
||||
|
||||
try
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System;
|
||||
using Server.Engines.Pathing.Cache;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
|
@ -22,6 +23,7 @@ public class StepCacheParityTests
|
|||
{
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 1; // sweep cells expecting cache to answer immediately
|
||||
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
|
|
@ -31,16 +33,22 @@ public class StepCacheParityTests
|
|||
var multiZ = 0;
|
||||
var wetCells = 0;
|
||||
|
||||
// The cache anchors each cell at the surface a creature actually STANDS on
|
||||
// (clearance-aware), not the land average. Query at that same standable Z so the
|
||||
// source-Z guard doesn't false-positive (e.g. on a raised causeway or sewer walkway
|
||||
// whose surface sits well above the land). Cells with no standable walk surface are
|
||||
// skipped — there's nothing for a walker to compare against.
|
||||
Span<sbyte> surfZ = stackalloc sbyte[16];
|
||||
|
||||
for (var x = xStart; x < xStart + size; x++)
|
||||
{
|
||||
for (var y = yStart; y < yStart + size; y++)
|
||||
{
|
||||
map.GetAverageZ(x, y, out _, out var avgZ, out _);
|
||||
|
||||
// The cache bakes from the slow path's standing Z (the Z a creature actually
|
||||
// stands at on this cell). Query with the same Z so the source-Z guard
|
||||
// doesn't false-positive on every paver cell.
|
||||
var sourceZ = (sbyte)StepProbe.ComputeStandingZ(map, x, y, avgZ);
|
||||
if (StepProbe.ComputeStandableSurfaceZs(map, x, y, surfZ) == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var sourceZ = surfZ[0];
|
||||
|
||||
var baker = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
using System.Collections.Generic;
|
||||
using Server.Engines.Pathing.Cache;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Server.Tests.Pathfinding;
|
||||
|
||||
/// <summary>
|
||||
/// Parity coverage for "walkable static surface above a land tile" terrain — sewers,
|
||||
/// dungeon walkways, bridges, raised foundations, and stacked building floors.
|
||||
///
|
||||
/// The original parity tests only queried at the LAND-anchored standing Z and skipped
|
||||
/// multi-Z fallthroughs, so they never noticed that a query at the REAL walk Z — the static
|
||||
/// surface a creature actually stands on — returns
|
||||
/// <see cref="CacheHitKind.Fallthrough_SourceZMismatch"/>, because the baker anchored
|
||||
/// SourceZ at the land average instead of the walkway. In the Britain sewer that's a ~98%
|
||||
/// cache miss on a known walk-path (confirmed via [PathDiag).
|
||||
///
|
||||
/// Method: flood-fill outward from a known-walkable start using
|
||||
/// <see cref="Movement.Movement.CheckMovement"/> — the slow path the cache mirrors. Each
|
||||
/// reached (x, y, z) is a genuine standing state at its TRUE Z (CheckMovement returns the
|
||||
/// destination Z it lands on), exactly the set of states A* would query. For every reached
|
||||
/// state the cache must serve a Hit and agree with the slow path. This naturally follows
|
||||
/// ramped stairs (each tread at its own Z) and climbs to upper floors, so one start covers
|
||||
/// the whole connected structure — no fragile fixed-Z assumption.
|
||||
///
|
||||
/// A bare test world has no spawned items/mobiles, so CheckMovement reduces to static
|
||||
/// walkability (no door/dynamic interference). Parity restricted to cardinal directions:
|
||||
/// the cache stores raw masks and applies the diagonal corner-cut at query time, so a raw
|
||||
/// diagonal bit legitimately differs from CheckMovement's diagonal result.
|
||||
///
|
||||
/// EXPECTED: RED before the standable-surface bake (reached states fall through at their
|
||||
/// true Z); GREEN after.
|
||||
/// </summary>
|
||||
[Collection("Sequential Pathfinding Tests")]
|
||||
public class StepCacheStaticSurfaceParityTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public StepCacheStaticSurfaceParityTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// label, start X, Y, Z (a real in-game walkable tile), max states to explore. Seeds are
|
||||
// chosen to span the terrain classes the standable-surface bake must get right; the
|
||||
// flood-fill spreads from each across a wide local area, so a handful of seeds exercises
|
||||
// thousands of distinct (cell, Z) states without an exhaustive whole-map walk.
|
||||
// sewer — static walkway @ z=5 over impassable land; covers dungeon walkways + bridges.
|
||||
// inn — stair foot @ z=10; climbs the stairs onto the 1st & 2nd floors (multi-Z).
|
||||
// plain — open Britain ground; guards against clearance false-positives on flat land.
|
||||
// town — Britain cobblestones near the inn; mixed buildings, stairs, raised floors.
|
||||
[InlineData("brit_sewer_walkway", 6034, 1476, 5, 2500)]
|
||||
[InlineData("brit_inn_stairs_to_floors", 1495, 1628, 10, 2500)]
|
||||
[InlineData("trammel_open_plain", 1500, 1600, 10, 2500)]
|
||||
[InlineData("brit_town_cobblestones", 1494, 1626, 10, 2500)] // plain ground: guards against clearance false-positives
|
||||
public void CacheServesReachableWalkStates(string label, int sx, int sy, int sz, int maxStates)
|
||||
{
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 1; // eager build — expect the cache to answer every state
|
||||
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
|
||||
var stub = new ParityStubMobile();
|
||||
stub.MoveToWorld(new Point3D(sx, sy, sz), map);
|
||||
|
||||
// Sanity: the start must itself be a walkable standing state via the slow path.
|
||||
var startWalkable = false;
|
||||
for (var d = 0; d < 8; d++)
|
||||
{
|
||||
if (Movement.Movement.CheckMovement(stub, map, new Point3D(sx, sy, sz), (Direction)d, out _))
|
||||
{
|
||||
startWalkable = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Assert.True(startWalkable, $"[{label}] start ({sx},{sy},{sz}) is not walkable per the slow path — bad waypoint");
|
||||
|
||||
var visited = new HashSet<(int x, int y, int z)>();
|
||||
var queue = new Queue<(int x, int y, int z)>();
|
||||
visited.Add((sx, sy, sz));
|
||||
queue.Enqueue((sx, sy, sz));
|
||||
|
||||
var states = 0;
|
||||
var fellThrough = 0;
|
||||
var disagreements = 0;
|
||||
const int maxLog = 12;
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (x, y, z) = queue.Dequeue();
|
||||
states++;
|
||||
|
||||
var loc = new Point3D(x, y, z);
|
||||
var lookup = cache.TryGetMask(map, x, y, (sbyte)z);
|
||||
|
||||
if (!lookup.IsHit)
|
||||
{
|
||||
if (fellThrough < maxLog)
|
||||
{
|
||||
_output.WriteLine($"FELL THROUGH @ ({x},{y},{z}) hitKind={lookup.HitKind}");
|
||||
}
|
||||
fellThrough++;
|
||||
}
|
||||
|
||||
for (var d = 0; d < 8; d++)
|
||||
{
|
||||
var dir = (Direction)d;
|
||||
var slowOk = Movement.Movement.CheckMovement(stub, map, loc, dir, out var nz);
|
||||
|
||||
// Expand the frontier through every legal move (incl. diagonals).
|
||||
if (slowOk)
|
||||
{
|
||||
var nx = x;
|
||||
var ny = y;
|
||||
Movement.Movement.Offset(dir, ref nx, ref ny);
|
||||
var next = (nx, ny, (int)nz);
|
||||
if (visited.Count < maxStates && visited.Add(next))
|
||||
{
|
||||
queue.Enqueue(next);
|
||||
}
|
||||
}
|
||||
|
||||
// Parity on cardinals only (diagonals carry the query-time corner-cut rule).
|
||||
if ((d & 1) == 0 && lookup.IsHit)
|
||||
{
|
||||
var cacheOk = lookup.IsWalkable(dir);
|
||||
if (cacheOk != slowOk)
|
||||
{
|
||||
if (disagreements < maxLog)
|
||||
{
|
||||
_output.WriteLine($"WALK DIFF @ ({x},{y},{z}) dir={dir} slow={slowOk} cache={cacheOk}");
|
||||
}
|
||||
disagreements++;
|
||||
}
|
||||
else if (slowOk && nz != lookup.GetWalkZ(dir))
|
||||
{
|
||||
if (disagreements < maxLog)
|
||||
{
|
||||
_output.WriteLine($"Z DIFF @ ({x},{y},{z}) dir={dir} slow={nz} cache={lookup.GetWalkZ(dir)}");
|
||||
}
|
||||
disagreements++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stub.Delete();
|
||||
|
||||
var fallthroughPct = states == 0 ? 0 : 100.0 * fellThrough / states;
|
||||
_output.WriteLine($"[{label}] states={states} fellThrough={fellThrough} ({fallthroughPct:F2}%) disagreements={disagreements}");
|
||||
|
||||
Assert.True(states > 50, $"[{label}] only explored {states} states — flood-fill stalled, bad waypoint");
|
||||
|
||||
// Correctness is strict: where the cache DOES answer, it must agree with the slow path.
|
||||
Assert.Equal(0, disagreements);
|
||||
|
||||
// Coverage: nearly every reachable state should be cache-served. A small residual is
|
||||
// expected and acceptable — a walkable surface sitting directly under a bridge/stair
|
||||
// ramp falls through to the slow path (correct, just uncached) because the bake's
|
||||
// clearance check is intentionally conservative there. A real anchor regression shows
|
||||
// up as a large fraction (the pre-fix sewer was ~98%), which this still catches.
|
||||
Assert.True(
|
||||
fallthroughPct < 1.0,
|
||||
$"[{label}] cache fell through on {fallthroughPct:F2}% ({fellThrough}/{states}) of reachable states — coverage regression"
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default static walker: inherits straight from Mobile so MovementImpl sees no
|
||||
/// BaseCreature flags (CanSwim/CanFly false, bc==null). Mirrors the existing parity stub.
|
||||
/// </summary>
|
||||
private class ParityStubMobile : Mobile
|
||||
{
|
||||
public ParityStubMobile()
|
||||
{
|
||||
Body = 0xC9;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue