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:
Kamron Batman 2026-05-06 00:14:08 -07:00 committed by GitHub
parent 6a3804addc
commit 9066e8fd00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 856 additions and 290 deletions

View file

@ -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;
}
}

View file

@ -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
}

View file

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

View file

@ -62,7 +62,7 @@ public class StaticWalkabilityParityTests
}
}
var newZ = bakerResult.GetDestZ(dir);
var newZ = bakerResult.GetWalkZ(dir);
if (oldOk)
{

View file

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