feat: expand cache to nearly all mobiles + dynamic-obstacle pass (#2447)
## Summary Builds on PR #2446's cache-direct A*. The previous PR conservatively routed players + creatures with capability flags entirely through the slow path. This PR pushes that line: most mobile classes now use the cache, with the right rule set layered on top per-mobile, and the cache fast-path now does the dynamic items / mobiles check that PR #2446 had silently skipped. ## What changed - **Non-GM players** now use the cache. Diagonal corner-cut applies the strict AND-rule (BOTH cardinal partners walkable) by reading the same source-cell mask byte the creature OR-rule reads — both rules are evaluable from one byte. - **Creatures with `CanOpenDoors` / `CanMoveOverObstacles`** now use the cache. Reading `MovementImpl` confirmed those flags only affect dynamic items, never static tiles, so they were over-conservatively excluded before. - **Swim creatures** now use the cache via a capability overlay. `StepProbe` bakes a second rule set (`canSwim=true, cantWalk=true`) producing `WetMask` + `SwimZ_*`. The algorithm composes `effectiveMask = (walkMask & !cantWalk) | (wetMask & canSwim)` per direction; walk Z preferred when both apply. - **Dynamic-obstacle pass.** Cache fast-path now mirrors `MovementImpl`'s per-cell items + mobiles collision check (`GetItemsAt` / `GetMobilesAt` at the target cell, with `CanOpenDoors` / `CanMoveOverObstacles` / spell-field overrides). This closes a correctness gap from PR #2446 — the cache fast-path was silently skipping dynamic obstacles entirely. - **`StepCache.TryGetMask` returns `StepMask` struct** instead of 11 out parameters. `HitKind` rolls into the struct with an `IsHit` accessor. Sets up wet/swim without ballooning the call site. - **`StepChunk.MultiZCells` is lazy-init.** Most chunks are entirely single-Z; allocating the 32-byte bitmap up-front wasted ~256KB at full cap. - **Admin commands.** `[PathCacheStats` (resident chunks + hit/miss/eviction counters) and `[PathCacheClear` (drop everything, zero counters). - **Feature flag.** `bitmap_pathfinding_cache` (default true) gates the cache fast-path. Flipped off, every cell expansion routes to `MovementImpl` — equivalent to PR #2446's slow-path-only behavior. Safety net for shipping the new behavior. `RequiresSlowPath` shrinks to just `CanFly` — flying creatures Z-jump arbitrarily, which the cache's static-Z model can't accommodate.
This commit is contained in:
parent
6a3804addc
commit
9066e8fd00
16 changed files with 856 additions and 290 deletions
|
|
@ -1,6 +1,7 @@
|
|||
using Server.Engines.Pathing.Cache;
|
||||
using Server.Mobiles;
|
||||
using Server.PathAlgorithms.BitmapAStar;
|
||||
using Server.Systems.FeatureFlags;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
|
|
@ -54,7 +55,7 @@ public class BitmapAStarAlgorithmTests
|
|||
[Theory]
|
||||
[InlineData(1500, 1600, 1498, 1598)] // NW, 2 cells diagonal
|
||||
[InlineData(1500, 1600, 1497, 1599)] // NW-ish, 3 W + 1 N
|
||||
public void CapabilityCreature_FindsPath_ViaInlineSlowPath(int sx, int sy, int gx, int gy)
|
||||
public void SwimCreature_FindsPath_ViaCacheCapabilityOverlay(int sx, int sy, int gx, int gy)
|
||||
{
|
||||
StepCache.Instance.Clear();
|
||||
var map = Map.Maps[1];
|
||||
|
|
@ -62,27 +63,270 @@ public class BitmapAStarAlgorithmTests
|
|||
|
||||
var stub = new SwimmingStub(World.NewMobile);
|
||||
stub.DefaultMobileInit();
|
||||
stub.CanSwim = true; // forces non-default-walker → inline GetSuccessorsSlowPath
|
||||
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();
|
||||
|
||||
// Capability creature: assert reachability — exact length depends on terrain and
|
||||
// tie-breaking, but a swimmer should always reach a goal a default walker reaches
|
||||
// on dry land (CanSwim is permissive, never restrictive).
|
||||
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 DefaultWalkerStub();
|
||||
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 DefaultWalkerStub();
|
||||
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)
|
||||
{
|
||||
Server.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;
|
||||
|
||||
// Find any ItemID whose TileData has ImpassableSurface so the dynamic pass
|
||||
// rejects the cell. Pinning to a specific ID would couple the test to UO art data.
|
||||
ushort blockerItemId = 0;
|
||||
for (ushort id = 1; id < TileData.MaxItemValue; id++)
|
||||
{
|
||||
if (TileData.ItemTable[id].ImpassableSurface)
|
||||
{
|
||||
blockerItemId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Assert.NotEqual<ushort>(0, blockerItemId);
|
||||
|
||||
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 DefaultWalkerStub();
|
||||
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)
|
||||
{
|
||||
Server.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 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 statsBefore = StepCache.Instance.GetStats();
|
||||
var prevFlag = ContentFeatureFlags.BitmapPathfindingCache;
|
||||
try
|
||||
{
|
||||
ContentFeatureFlags.BitmapPathfindingCache = false;
|
||||
var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal);
|
||||
Assert.NotNull(result);
|
||||
Assert.NotEmpty(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ContentFeatureFlags.BitmapPathfindingCache = prevFlag;
|
||||
}
|
||||
var statsAfter = StepCache.Instance.GetStats();
|
||||
|
||||
stub.Delete();
|
||||
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();
|
||||
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();
|
||||
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();
|
||||
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)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plain Mobile — IsDefaultWalker returns true, the bitmap algorithm uses the cache
|
||||
/// Plain Mobile — RequiresSlowPath returns false, the bitmap algorithm uses the cache
|
||||
/// fast path on every expansion.
|
||||
/// </summary>
|
||||
private sealed class DefaultWalkerStub : Mobile
|
||||
|
|
@ -94,10 +338,23 @@ public class BitmapAStarAlgorithmTests
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// BaseCreature with CanSwim=true — IsDefaultWalker returns false, the bitmap
|
||||
/// algorithm short-circuits GetSuccessors to GetSuccessorsSlowPath on every cell.
|
||||
/// Use the Serial constructor (deserialization path) to bypass NPCSpeeds init,
|
||||
/// which requires the npc-speeds.json table loaded — not available in tests.
|
||||
/// 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 CanSwim=true — uses the cache via capability overlay (walk OR
|
||||
/// (wet AND canSwim)). Use the Serial constructor (deserialization path) to bypass
|
||||
/// NPCSpeeds init, which requires the npc-speeds.json table loaded — not available
|
||||
/// in tests.
|
||||
/// </summary>
|
||||
private sealed class SwimmingStub : BaseCreature
|
||||
{
|
||||
|
|
@ -106,4 +363,38 @@ public class BitmapAStarAlgorithmTests
|
|||
Body = 0xC9;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,20 +35,14 @@ public class StepCacheLifecycleTests
|
|||
Assert.NotNull(map);
|
||||
|
||||
// Pinned cell (1500, 1600, z=10): mask=0xC1
|
||||
var ok = cache.TryGetMask(
|
||||
map, 1500, 1600, sourceZ: 10,
|
||||
out var mask,
|
||||
out var dN, out var dNE, out var dE, out var dSE,
|
||||
out var dS, out var dSW, out var dW, out var dNW,
|
||||
out var hitKind
|
||||
);
|
||||
var lookup = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(CacheHitKind.Miss_NotBuilt, hitKind);
|
||||
Assert.Equal((byte)0xC1, mask);
|
||||
Assert.Equal((sbyte)10, dN);
|
||||
Assert.Equal((sbyte)10, dW);
|
||||
Assert.Equal((sbyte)10, dNW);
|
||||
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);
|
||||
|
||||
var stats = cache.GetStats();
|
||||
Assert.Equal(1, stats.ResidentChunks);
|
||||
|
|
@ -56,15 +50,10 @@ public class StepCacheLifecycleTests
|
|||
Assert.Equal(1L, stats.BuildsTotal);
|
||||
|
||||
// Second query of same cell → Hit
|
||||
var ok2 = cache.TryGetMask(
|
||||
map, 1500, 1600, sourceZ: 10,
|
||||
out var mask2,
|
||||
out _, out _, out _, out _, out _, out _, out _, out _,
|
||||
out var hitKind2
|
||||
);
|
||||
Assert.True(ok2);
|
||||
Assert.Equal(CacheHitKind.Hit, hitKind2);
|
||||
Assert.Equal((byte)0xC1, mask2);
|
||||
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);
|
||||
|
||||
var stats2 = cache.GetStats();
|
||||
Assert.Equal(1, stats2.ResidentChunks);
|
||||
|
|
@ -79,15 +68,11 @@ public class StepCacheLifecycleTests
|
|||
|
||||
var map = Map.Maps[1];
|
||||
|
||||
var ok = cache.TryGetMask(
|
||||
map, -1, -1, sourceZ: 0,
|
||||
out var mask, out _, out _, out _, out _, out _, out _, out _, out _,
|
||||
out var hitKind
|
||||
);
|
||||
var lookup = cache.TryGetMask(map, -1, -1, sourceZ: 0);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Equal(CacheHitKind.Fallthrough_OffMap, hitKind);
|
||||
Assert.Equal((byte)0, mask);
|
||||
Assert.False(lookup.IsHit);
|
||||
Assert.Equal(CacheHitKind.Fallthrough_OffMap, lookup.HitKind);
|
||||
Assert.Equal((byte)0, lookup.WalkMask);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -100,10 +85,7 @@ public class StepCacheLifecycleTests
|
|||
var sector = map.GetRealSector(1500 >> 4, 1600 >> 4);
|
||||
|
||||
// First query: builds chunk, snapshots current MultisVersion.
|
||||
cache.TryGetMask(map, 1500, 1600, 10,
|
||||
out _, out _, out _, out _, out _, out _, out _, out _, out _,
|
||||
out var firstHitKind);
|
||||
Assert.Equal(CacheHitKind.Miss_NotBuilt, firstHitKind);
|
||||
Assert.Equal(CacheHitKind.Miss_NotBuilt, cache.TryGetMask(map, 1500, 1600, 10).HitKind);
|
||||
|
||||
// Bump _multisVersion via reflection.
|
||||
var versionField = typeof(Map.Sector).GetField(
|
||||
|
|
@ -115,10 +97,7 @@ public class StepCacheLifecycleTests
|
|||
versionField.SetValue(sector, current + 1);
|
||||
|
||||
// Second query: detects version mismatch, rebuilds.
|
||||
cache.TryGetMask(map, 1500, 1600, 10,
|
||||
out _, out _, out _, out _, out _, out _, out _, out _, out _,
|
||||
out var secondHitKind);
|
||||
Assert.Equal(CacheHitKind.Miss_DirtyRebuild, secondHitKind);
|
||||
Assert.Equal(CacheHitKind.Miss_DirtyRebuild, cache.TryGetMask(map, 1500, 1600, 10).HitKind);
|
||||
|
||||
var stats = cache.GetStats();
|
||||
Assert.Equal(1L, stats.MissesDirtyRebuild);
|
||||
|
|
@ -142,8 +121,7 @@ public class StepCacheLifecycleTests
|
|||
var map = Map.Maps[1];
|
||||
|
||||
// Build a chunk first so it exists.
|
||||
cache.TryGetMask(map, 1500, 1600, 10,
|
||||
out _, out _, out _, out _, out _, out _, out _, out _, out _, out _);
|
||||
cache.TryGetMask(map, 1500, 1600, 10);
|
||||
|
||||
// Snapshot current FallthroughMultiZ in case (1500, 1600) is naturally multi-Z
|
||||
// in real tile data; we only assert the synthetic injection produces a delta of 1.
|
||||
|
|
@ -165,12 +143,10 @@ public class StepCacheLifecycleTests
|
|||
var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
|
||||
chunk.MarkCellMultiZ(cellIndex);
|
||||
|
||||
var ok = cache.TryGetMask(map, 1500, 1600, 10,
|
||||
out _, out _, out _, out _, out _, out _, out _, out _, out _,
|
||||
out var hitKind);
|
||||
var lookup = cache.TryGetMask(map, 1500, 1600, 10);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Equal(CacheHitKind.Fallthrough_MultiZ, hitKind);
|
||||
Assert.False(lookup.IsHit);
|
||||
Assert.Equal(CacheHitKind.Fallthrough_MultiZ, lookup.HitKind);
|
||||
|
||||
var stats = cache.GetStats();
|
||||
Assert.Equal(preInjectionFallthroughMultiZ + 1L, stats.FallthroughMultiZ);
|
||||
|
|
@ -192,8 +168,7 @@ public class StepCacheLifecycleTests
|
|||
{
|
||||
var x = 1500 + (i * 16);
|
||||
var y = 1600;
|
||||
cache.TryGetMask(map, x, y, 10,
|
||||
out _, out _, out _, out _, out _, out _, out _, out _, out _, out _);
|
||||
cache.TryGetMask(map, x, y, 10);
|
||||
System.Threading.Thread.Sleep(2); // ensure LastTouchedTicks differs
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ public class StepCacheParityTests
|
|||
var disagreements = 0;
|
||||
var samples = 0;
|
||||
var multiZ = 0;
|
||||
var wetCells = 0;
|
||||
|
||||
for (var x = xStart; x < xStart + size; x++)
|
||||
{
|
||||
|
|
@ -43,41 +44,49 @@ public class StepCacheParityTests
|
|||
|
||||
var baker = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
|
||||
|
||||
var ok = cache.TryGetMask(
|
||||
map, x, y, sourceZ,
|
||||
out var mask,
|
||||
out var dN, out var dNE, out var dE, out var dSE,
|
||||
out var dS, out var dSW, out var dW, out var dNW,
|
||||
out var hitKind
|
||||
);
|
||||
var lookup = cache.TryGetMask(map, x, y, sourceZ);
|
||||
|
||||
samples++;
|
||||
|
||||
if (hitKind == CacheHitKind.Fallthrough_MultiZ)
|
||||
if (lookup.HitKind == CacheHitKind.Fallthrough_MultiZ)
|
||||
{
|
||||
multiZ++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Assert.True(ok, $"Cache returned !ok at ({x},{y}) hitKind={hitKind}");
|
||||
Assert.True(lookup.IsHit, $"Cache returned !ok at ({x},{y}) hitKind={lookup.HitKind}");
|
||||
|
||||
if (mask != baker.Mask)
|
||||
if (lookup.WalkMask != baker.WalkMask)
|
||||
{
|
||||
disagreements++;
|
||||
_output.WriteLine($"MASK DIFF @ ({x},{y}) cache=0x{mask:X2} baker=0x{baker.Mask:X2}");
|
||||
_output.WriteLine($"WALK MASK DIFF @ ({x},{y}) cache=0x{lookup.WalkMask:X2} baker=0x{baker.WalkMask:X2}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dN != baker.DestZ_N || dNE != baker.DestZ_NE || dE != baker.DestZ_E || dSE != baker.DestZ_SE
|
||||
|| dS != baker.DestZ_S || dSW != baker.DestZ_SW || dW != baker.DestZ_W || dNW != baker.DestZ_NW)
|
||||
if (lookup.WetMask != baker.WetMask)
|
||||
{
|
||||
disagreements++;
|
||||
_output.WriteLine($"Z DIFF @ ({x},{y}) cache=({dN},{dNE},{dE},{dSE},{dS},{dSW},{dW},{dNW}) baker=({baker.DestZ_N},{baker.DestZ_NE},{baker.DestZ_E},{baker.DestZ_SE},{baker.DestZ_S},{baker.DestZ_SW},{baker.DestZ_W},{baker.DestZ_NW})");
|
||||
_output.WriteLine($"WET MASK DIFF @ ({x},{y}) cache=0x{lookup.WetMask:X2} baker=0x{baker.WetMask:X2}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lookup.WetMask != 0)
|
||||
{
|
||||
wetCells++;
|
||||
}
|
||||
|
||||
if (lookup.WalkZ_N != baker.WalkZ_N || lookup.WalkZ_NE != baker.WalkZ_NE
|
||||
|| lookup.WalkZ_E != baker.WalkZ_E || lookup.WalkZ_SE != baker.WalkZ_SE
|
||||
|| lookup.WalkZ_S != baker.WalkZ_S || lookup.WalkZ_SW != baker.WalkZ_SW
|
||||
|| lookup.WalkZ_W != baker.WalkZ_W || lookup.WalkZ_NW != baker.WalkZ_NW)
|
||||
{
|
||||
disagreements++;
|
||||
_output.WriteLine($"Z DIFF @ ({x},{y}) cache=({lookup.WalkZ_N},{lookup.WalkZ_NE},{lookup.WalkZ_E},{lookup.WalkZ_SE},{lookup.WalkZ_S},{lookup.WalkZ_SW},{lookup.WalkZ_W},{lookup.WalkZ_NW}) baker=({baker.WalkZ_N},{baker.WalkZ_NE},{baker.WalkZ_E},{baker.WalkZ_SE},{baker.WalkZ_S},{baker.WalkZ_SW},{baker.WalkZ_W},{baker.WalkZ_NW})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_output.WriteLine($"[{label}] samples={samples} disagreements={disagreements} multiZ={multiZ}");
|
||||
_output.WriteLine($"[{label}] samples={samples} disagreements={disagreements} multiZ={multiZ} wetCells={wetCells}");
|
||||
|
||||
// Non-vacuity: at least the inn region must have at least one cell that produced a real cache answer.
|
||||
if (label == "britain_inn_dense")
|
||||
|
|
@ -87,4 +96,40 @@ public class StepCacheParityTests
|
|||
|
||||
Assert.Equal(0, disagreements);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-vacuity guard for the swim bake: scans a wide swath of the south-Britain bay
|
||||
/// (Atlantic coast) and asserts at least one cell has a non-zero WetMask. Catches the
|
||||
/// failure mode where StepProbe silently bakes zero swim output everywhere.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SwimBake_ProducesWetCells_OnKnownWaterRegion()
|
||||
{
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
|
||||
// South Britain → Britain bay, includes Atlantic shoreline. 64×64 = 4096 cells;
|
||||
// even a partial coastline straddle should yield dozens of wet cells.
|
||||
const int xStart = 1430;
|
||||
const int yStart = 1740;
|
||||
const int size = 64;
|
||||
|
||||
var wetCells = 0;
|
||||
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 _);
|
||||
var sourceZ = (sbyte)StepProbe.ComputeStandingZ(map, x, y, avgZ);
|
||||
var baker = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
|
||||
if (baker.WetMask != 0)
|
||||
{
|
||||
wetCells++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_output.WriteLine($"south-britain swim probe: wetCells={wetCells} of 4096");
|
||||
Assert.True(wetCells > 0, "swim bake produced zero wet cells across a 64×64 coastal region");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ public class StaticWalkabilityParityTests
|
|||
}
|
||||
}
|
||||
|
||||
var newZ = bakerResult.GetDestZ(dir);
|
||||
var newZ = bakerResult.GetWalkZ(dir);
|
||||
|
||||
if (oldOk)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -19,10 +19,10 @@ public class StepProbeTests
|
|||
{
|
||||
var result = StepProbe.ComputeMaskAt(null, 100, 100, 0);
|
||||
|
||||
Assert.Equal(0, result.Mask);
|
||||
Assert.Equal(0, result.WalkMask);
|
||||
for (var d = 0; d < 8; d++)
|
||||
{
|
||||
Assert.Equal(0, result.GetDestZ((Direction)d));
|
||||
Assert.Equal(0, result.GetWalkZ((Direction)d));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -31,10 +31,10 @@ public class StepProbeTests
|
|||
{
|
||||
var result = StepProbe.ComputeMaskAt(Map.Internal, 100, 100, 0);
|
||||
|
||||
Assert.Equal(0, result.Mask);
|
||||
Assert.Equal(0, result.WalkMask);
|
||||
for (var d = 0; d < 8; d++)
|
||||
{
|
||||
Assert.Equal(0, result.GetDestZ((Direction)d));
|
||||
Assert.Equal(0, result.GetWalkZ((Direction)d));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -98,7 +98,7 @@ public class StepProbeTests
|
|||
var sourceZ = (sbyte)avgZ;
|
||||
var result = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
|
||||
|
||||
if (result.Mask != 0xFF)
|
||||
if (result.WalkMask != 0xFF)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
|
@ -106,7 +106,7 @@ public class StepProbeTests
|
|||
var allSameZ = true;
|
||||
for (var d = 0; d < 8; d++)
|
||||
{
|
||||
if (result.GetDestZ((Direction)d) != sourceZ)
|
||||
if (result.GetWalkZ((Direction)d) != sourceZ)
|
||||
{
|
||||
allSameZ = false;
|
||||
break;
|
||||
|
|
@ -143,7 +143,7 @@ public class StepProbeTests
|
|||
var sourceZ = (sbyte)avgZ;
|
||||
var result = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
|
||||
|
||||
if (result.Mask != 0xFF)
|
||||
if (result.WalkMask != 0xFF)
|
||||
{
|
||||
blockedCellCount++;
|
||||
}
|
||||
|
|
@ -172,12 +172,12 @@ public class StepProbeTests
|
|||
var second = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
|
||||
var third = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
|
||||
|
||||
Assert.Equal(first.Mask, second.Mask);
|
||||
Assert.Equal(first.Mask, third.Mask);
|
||||
Assert.Equal(first.WalkMask, second.WalkMask);
|
||||
Assert.Equal(first.WalkMask, third.WalkMask);
|
||||
for (var d = 0; d < 8; d++)
|
||||
{
|
||||
Assert.Equal(first.GetDestZ((Direction)d), second.GetDestZ((Direction)d));
|
||||
Assert.Equal(first.GetDestZ((Direction)d), third.GetDestZ((Direction)d));
|
||||
Assert.Equal(first.GetWalkZ((Direction)d), second.GetWalkZ((Direction)d));
|
||||
Assert.Equal(first.GetWalkZ((Direction)d), third.GetWalkZ((Direction)d));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -198,7 +198,7 @@ public class StepProbeTests
|
|||
var result = StepProbe.ComputeMaskAt(map, 1500, 1600, sourceZ);
|
||||
|
||||
Assert.Equal((sbyte)10, sourceZ);
|
||||
Assert.Equal((byte)0xC1, result.Mask);
|
||||
Assert.Equal((byte)0xC1, result.WalkMask);
|
||||
|
||||
Assert.True(result.IsWalkable(Direction.North));
|
||||
Assert.False(result.IsWalkable(Direction.Right));
|
||||
|
|
@ -209,9 +209,9 @@ public class StepProbeTests
|
|||
Assert.True(result.IsWalkable(Direction.West));
|
||||
Assert.True(result.IsWalkable(Direction.Up));
|
||||
|
||||
Assert.Equal((sbyte)10, result.GetDestZ(Direction.North));
|
||||
Assert.Equal((sbyte)10, result.GetDestZ(Direction.West));
|
||||
Assert.Equal((sbyte)10, result.GetDestZ(Direction.Up));
|
||||
Assert.Equal((sbyte)10, result.GetWalkZ(Direction.North));
|
||||
Assert.Equal((sbyte)10, result.GetWalkZ(Direction.West));
|
||||
Assert.Equal((sbyte)10, result.GetWalkZ(Direction.Up));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -229,7 +229,7 @@ public class StepProbeTests
|
|||
var result = StepProbe.ComputeMaskAt(map, 1480, 1610, sourceZ);
|
||||
|
||||
Assert.Equal((sbyte)20, sourceZ);
|
||||
Assert.Equal((byte)0x3F, result.Mask);
|
||||
Assert.Equal((byte)0x3F, result.WalkMask);
|
||||
|
||||
Assert.True(result.IsWalkable(Direction.North));
|
||||
Assert.True(result.IsWalkable(Direction.Right));
|
||||
|
|
@ -242,7 +242,7 @@ public class StepProbeTests
|
|||
|
||||
for (var d = 0; d < 6; d++)
|
||||
{
|
||||
Assert.Equal((sbyte)20, result.GetDestZ((Direction)d));
|
||||
Assert.Equal((sbyte)20, result.GetWalkZ((Direction)d));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue