From 2501da37d32840f827751d4bac440d97767f0cac Mon Sep 17 00:00:00 2001 From: Anton Soloviev Date: Sun, 11 Oct 2026 16:08:31 -0700 Subject: [PATCH 1/4] Reset traversal links when adding pathfinder roots --- CHANGELOG.md | 4 ++++ tcod/path.py | 1 + tests/test_path.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 tests/test_path.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b128a56..83bf3717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +### Fixed + +- Adding a root to an already resolved `tcod.path.Pathfinder` no longer leaves a stale predecessor which can cause cyclic paths after resolving again. + ## [21.2.1] - 2026-06-04 ### Deployment diff --git a/tcod/path.py b/tcod/path.py index 0717ddee..b9ffefb9 100644 --- a/tcod/path.py +++ b/tcod/path.py @@ -1220,6 +1220,7 @@ def add_root(self, index: tuple[int, ...], value: int = 0) -> None: msg = f"Index must be {self._distance.ndim} items, got {index!r}" raise TypeError(msg) self._distance[index] = value + self._travel[index] = index self._update_heuristic(None) lib.TCOD_frontier_push(self._frontier_p, index, value, value) diff --git a/tests/test_path.py b/tests/test_path.py new file mode 100644 index 00000000..d82b46ae --- /dev/null +++ b/tests/test_path.py @@ -0,0 +1,42 @@ +"""Tests for incremental pathfinding.""" + +import numpy as np +import pytest + +import tcod.path + + +@pytest.mark.parametrize("shape", [(4, 7), (3, 4, 7)]) +@pytest.mark.parametrize("order", ["C", "F"]) +@pytest.mark.parametrize("value", [-3, 0, 3]) +def test_add_root_after_resolve(shape: tuple[int, ...], order: str, value: int) -> None: + graph = tcod.path.CustomGraph(shape, order=order) + cost = np.ones(shape, dtype=np.int8) + for axis in range(len(shape)): + for direction in (-1, 1): + edge = [0] * len(shape) + edge[axis] = direction + graph.add_edge(tuple(edge), cost=cost) + + pathfinder = tcod.path.Pathfinder(graph) + origin = (0,) * len(shape) + new_root = tuple(size - 1 for size in shape) + pathfinder.add_root(origin) + pathfinder.resolve() + assert tuple(pathfinder.path_from(new_root)[-1]) == origin + + pathfinder.add_root(new_root, value=value) + pathfinder.resolve() + np.testing.assert_array_equal(pathfinder.path_from(new_root), [new_root]) + coordinates = np.indices(shape) + expected = np.minimum( + coordinates.sum(axis=0), + sum(size - 1 - coordinates[axis] for axis, size in enumerate(shape)) + value, + ) + np.testing.assert_array_equal(pathfinder.distance, expected) + for index in np.ndindex(shape): + path = pathfinder.path_from(index) + root = tuple(path[-1]) + assert root in (origin, new_root) + root_value = value if root == new_root else 0 + assert len(path) - 1 + root_value == expected[index] From 12910cf645a793c768f2aa83bdb690fd69f88329 Mon Sep 17 00:00:00 2001 From: Anton Soloviev Date: Sun, 11 Oct 2026 22:05:26 -0700 Subject: [PATCH 2/4] Keep cheaper root distances and traversal when adding destinations --- CHANGELOG.md | 2 +- tcod/path.py | 3 +++ tests/test_path.py | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83bf3717..0800c032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ### Fixed -- Adding a root to an already resolved `tcod.path.Pathfinder` no longer leaves a stale predecessor which can cause cyclic paths after resolving again. +- Adding a root to an already resolved `tcod.path.Pathfinder` resets its predecessor only when its value improves the current distance. This prevents cyclic paths while preserving routes to cheaper roots. ## [21.2.1] - 2026-06-04 diff --git a/tcod/path.py b/tcod/path.py index b9ffefb9..913f943e 100644 --- a/tcod/path.py +++ b/tcod/path.py @@ -1212,6 +1212,7 @@ def add_root(self, index: tuple[int, ...], value: int = 0) -> None: `value` is the distance to use for this root. Zero is typical, but if multiple roots are added they can be given different weights. + The root is ignored if `value` is not less than its current distance. """ index = tuple(index) # Check for bad input. if self._order == "F": # Convert to ij indexing order. @@ -1219,6 +1220,8 @@ def add_root(self, index: tuple[int, ...], value: int = 0) -> None: if len(index) != self._distance.ndim: msg = f"Index must be {self._distance.ndim} items, got {index!r}" raise TypeError(msg) + if value >= self._distance[index]: + return self._distance[index] = value self._travel[index] = index self._update_heuristic(None) diff --git a/tests/test_path.py b/tests/test_path.py index d82b46ae..9aaf8fe9 100644 --- a/tests/test_path.py +++ b/tests/test_path.py @@ -40,3 +40,44 @@ def test_add_root_after_resolve(shape: tuple[int, ...], order: str, value: int) assert root in (origin, new_root) root_value = value if root == new_root else 0 assert len(path) - 1 + root_value == expected[index] + + +@pytest.mark.parametrize("shape", [(4, 7), (3, 4, 7)]) +@pytest.mark.parametrize("order", ["C", "F"]) +@pytest.mark.parametrize("first_value", [-5, 3]) +@pytest.mark.parametrize("relative_value", [-1, 0, 1, 20]) +@pytest.mark.parametrize("partial", [False, True]) +def test_add_root_preserves_better_distances( + shape: tuple[int, ...], order: str, first_value: int, relative_value: int, partial: bool +) -> None: + graph = tcod.path.CustomGraph(shape, order=order) + cost = np.ones(shape, dtype=np.int8) + for axis in range(len(shape)): + for direction in (-1, 1): + edge = [0] * len(shape) + edge[axis] = direction + graph.add_edge(tuple(edge), cost=cost) + pathfinder = tcod.path.Pathfinder(graph) + origin = (0,) * len(shape) + new_root = tuple(size // 2 for size in shape) + pathfinder.add_root(origin, value=first_value) + pathfinder.resolve(new_root if partial else None) + old_distances = pathfinder.distance.copy() + old_traversal = pathfinder.traversal.copy() + old_path = pathfinder.path_from(new_root).copy() + value = int(old_distances[new_root]) + relative_value + pathfinder.add_root(new_root, value=value) + if relative_value >= 0: + np.testing.assert_array_equal(pathfinder.distance, old_distances) + np.testing.assert_array_equal(pathfinder.traversal, old_traversal) + np.testing.assert_array_equal(pathfinder.path_from(new_root), old_path) + else: + assert pathfinder.distance[new_root] == value + np.testing.assert_array_equal(pathfinder.path_from(new_root), [new_root]) + pathfinder.resolve() + coordinates = np.indices(shape) + expected = np.minimum( + first_value + coordinates.sum(axis=0), + value + sum(abs(new_root[axis] - coordinates[axis]) for axis in range(len(shape))), + ) + np.testing.assert_array_equal(pathfinder.distance, expected) From f1255289cff323bd04c09fd5bb007336e1b0db8d Mon Sep 17 00:00:00 2001 From: Anton Soloviev Date: Mon, 12 Oct 2026 00:46:44 -0700 Subject: [PATCH 3/4] Explain weighted root preference and ignored additions --- tcod/path.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tcod/path.py b/tcod/path.py index 913f943e..5464aba6 100644 --- a/tcod/path.py +++ b/tcod/path.py @@ -1205,14 +1205,15 @@ def clear(self) -> None: lib.TCOD_frontier_clear(self._frontier_p) def add_root(self, index: tuple[int, ...], value: int = 0) -> None: - """Add a root node and insert it into the pathfinder frontier. + """Add a root node for subsequent pathfinding. `index` is the root point to insert. The length of `index` must match the dimensions of the graph. `value` is the distance to use for this root. Zero is typical, but if multiple roots are added they can be given different weights. - The root is ignored if `value` is not less than its current distance. + Roots with lower `value` have higher priority and can suppress nearby + roots with higher values. """ index = tuple(index) # Check for bad input. if self._order == "F": # Convert to ij indexing order. @@ -1221,7 +1222,7 @@ def add_root(self, index: tuple[int, ...], value: int = 0) -> None: msg = f"Index must be {self._distance.ndim} items, got {index!r}" raise TypeError(msg) if value >= self._distance[index]: - return + return # Keep existing routes when they are at least as cheap. self._distance[index] = value self._travel[index] = index self._update_heuristic(None) From fa431ada4cfb7eaaae6bb4b89e3766f5d50360bb Mon Sep 17 00:00:00 2001 From: Anton Soloviev Date: Mon, 12 Oct 2026 01:20:15 -0700 Subject: [PATCH 4/4] Snapshot root state after lazy path queries --- tests/test_path.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_path.py b/tests/test_path.py index 9aaf8fe9..13ac3779 100644 --- a/tests/test_path.py +++ b/tests/test_path.py @@ -62,9 +62,9 @@ def test_add_root_preserves_better_distances( new_root = tuple(size // 2 for size in shape) pathfinder.add_root(origin, value=first_value) pathfinder.resolve(new_root if partial else None) + old_path = pathfinder.path_from(new_root).copy() old_distances = pathfinder.distance.copy() old_traversal = pathfinder.traversal.copy() - old_path = pathfinder.path_from(new_root).copy() value = int(old_distances[new_root]) + relative_value pathfinder.add_root(new_root, value=value) if relative_value >= 0: