From 22b6cb83e24d665d5b4c7f426cd21943e1ee541e Mon Sep 17 00:00:00 2001 From: erwan-joly Date: Tue, 13 Oct 2026 19:41:56 +1300 Subject: [PATCH] fix: cached paths skip the start cell and are no longer shared between maps (2.1.1) A fresh JPS path starts at the first step, but the entries cached along it started at the cell itself, so a cache hit made the walker spend a step on its own cell. The path caches were also static and keyed only by coordinates, so one map's path could be served on another map. Both caches are now keyed by grid too. Co-Authored-By: Claude Opus 5.5 --- .../NosCore.PathFinder.csproj | 2 +- .../Pathfinder/GoalBasedPathfinder.cs | 6 +-- .../Pathfinder/JumpPointSearchPathfinder.cs | 5 +- .../JumpPointSearchPathfinderTests.cs | 46 +++++++++++++++++++ 4 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/NosCore.PathFinder/NosCore.PathFinder.csproj b/src/NosCore.PathFinder/NosCore.PathFinder.csproj index 76edcc2..3c75461 100644 --- a/src/NosCore.PathFinder/NosCore.PathFinder.csproj +++ b/src/NosCore.PathFinder/NosCore.PathFinder.csproj @@ -12,7 +12,7 @@ https://github.com/NosCoreIO/NosCore.PathFinder.git nostale, noscore, nostale private server source, nostale emulator - 2.1.0 + 2.1.1 false NosCore's PathFinder diff --git a/src/NosCore.PathFinder/Pathfinder/GoalBasedPathfinder.cs b/src/NosCore.PathFinder/Pathfinder/GoalBasedPathfinder.cs index 1204b70..7877993 100644 --- a/src/NosCore.PathFinder/Pathfinder/GoalBasedPathfinder.cs +++ b/src/NosCore.PathFinder/Pathfinder/GoalBasedPathfinder.cs @@ -27,7 +27,7 @@ public GoalBasedPathfinder(IMapGrid mapGrid, IHeuristic heuristic) public GoalBasedPathfinder(IMapGrid mapGrid, IHeuristic heuristic, BrushFire brushfire) : this(mapGrid, heuristic) { - BrushFirecache.Set(brushfire.Origin, brushfire, DateTimeOffset.Now.AddSeconds(10)); + BrushFirecache.Set((mapGrid, brushfire.Origin), brushfire, DateTimeOffset.Now.AddSeconds(10)); } public IEnumerable<(short X, short Y)> FindPath((short X, short Y) start, (short X, short Y) end) @@ -39,10 +39,10 @@ public GoalBasedPathfinder(IMapGrid mapGrid, IHeuristic heuristic, BrushFire bru return list; } - if (!BrushFirecache.TryGetValue(end, out BrushFire brushFire)) + if (!BrushFirecache.TryGetValue((_mapGrid, end), out BrushFire brushFire)) { brushFire = _mapGrid.LoadBrushFire(end, _heuristic); - BrushFirecache.Set(end, brushFire, DateTimeOffset.Now.AddSeconds(10)); + BrushFirecache.Set((_mapGrid, end), brushFire, DateTimeOffset.Now.AddSeconds(10)); } if (!brushFire.Distances.ContainsKey(start)) diff --git a/src/NosCore.PathFinder/Pathfinder/JumpPointSearchPathfinder.cs b/src/NosCore.PathFinder/Pathfinder/JumpPointSearchPathfinder.cs index db7810a..f3c5e77 100644 --- a/src/NosCore.PathFinder/Pathfinder/JumpPointSearchPathfinder.cs +++ b/src/NosCore.PathFinder/Pathfinder/JumpPointSearchPathfinder.cs @@ -59,7 +59,8 @@ public JumpPointSearchPathfinder(IMapGrid mapGrid, IHeuristic heuristic) public IEnumerable<(short X, short Y)> FindPath((short X, short Y) start, (short X, short Y) end) { - if (Cache.TryGetValue((start, end), out IEnumerable<(short X, short Y)>? cachedList)) + // Keyed by grid too: the cache is shared by every pathfinder instance, one per map. + if (Cache.TryGetValue((_mapGrid, start, end), out IEnumerable<(short X, short Y)>? cachedList)) { return cachedList ?? Array.Empty<(short X, short Y)>(); } @@ -67,7 +68,7 @@ public JumpPointSearchPathfinder(IMapGrid mapGrid, IHeuristic heuristic) var path = BuildFullPath(GetJumpList(start, end).ToList()); for (var i = 0; i < path.Count; i++) { - Cache.Set((path[i], end), path.Skip(i), DateTimeOffset.Now.AddSeconds(10)); + Cache.Set((_mapGrid, path[i], end), path.Skip(i + 1), DateTimeOffset.Now.AddSeconds(10)); } return path; } diff --git a/test/NosCore.PathFinder.Tests/JumpPointSearchPathfinderTests.cs b/test/NosCore.PathFinder.Tests/JumpPointSearchPathfinderTests.cs index 087c2dc..884cbc5 100644 --- a/test/NosCore.PathFinder.Tests/JumpPointSearchPathfinderTests.cs +++ b/test/NosCore.PathFinder.Tests/JumpPointSearchPathfinderTests.cs @@ -53,5 +53,51 @@ public void Test_JumpPointSearchPathfinder() TestHelper.VerifyFile("jump-point-search-pathfinder.png", image, listPixel, "Jump Point Search Pathfinder (break at walls)"); } + + [TestMethod] + public void ACachedPathStartsOnTheFirstStepLikeAFreshOne() + { + var pathfinder = new JumpPointSearchPathfinder(Walled(new[] + { + new byte[] { 0, 0, 0, 0, 0, 0 }, + }), new OctileDistanceHeuristic()); + var fresh = pathfinder.FindPath((1, 1), (6, 1)).ToList(); + + var cached = pathfinder.FindPath(fresh[0], (6, 1)).ToList(); + + CollectionAssert.AreEqual(fresh.Skip(1).ToList(), cached); + } + + [TestMethod] + public void TwoMapsDoNotShareCachedPaths() + { + var open = new JumpPointSearchPathfinder(Walled(new[] + { + new byte[] { 0, 0, 0, 0, 0 }, + new byte[] { 0, 0, 0, 0, 0 }, + }), new OctileDistanceHeuristic()); + var blocked = new JumpPointSearchPathfinder(Walled(new[] + { + new byte[] { 0, 0, 1, 0, 0 }, + new byte[] { 0, 0, 0, 0, 0 }, + }), new OctileDistanceHeuristic()); + + var straight = open.FindPath((1, 1), (5, 1)).ToList(); + var around = blocked.FindPath(straight[0], (5, 1)).ToList(); + + Assert.Contains(((short)3, (short)1), straight); + Assert.DoesNotContain(((short)3, (short)1), around, "the blocked map must not reuse the open map's path through its wall"); + Assert.AreEqual(((short)5, (short)1), around.Last()); + } + + private static TestMap Walled(byte[][] inside) + { + var width = inside[0].Length + 2; + var wall = Enumerable.Repeat((byte)1, width).ToArray(); + return new TestMap(new[] { wall } + .Concat(inside.Select(row => new byte[] { 1 }.Concat(row).Append((byte)1).ToArray())) + .Append(wall) + .ToArray()); + } } }