diff --git a/CHANGELOG.md b/CHANGELOG.md index ef13aa8b..10c9a855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ### Fixed +- 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. - Lazy pathfinder queries reconsider previously visited goals after adding a root or lowering a root's distance. ## [21.2.1] - 2026-06-04 diff --git a/tcod/path.py b/tcod/path.py index 975e07ca..aab2634b 100644 --- a/tcod/path.py +++ b/tcod/path.py @@ -1205,13 +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. + 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. @@ -1219,7 +1221,10 @@ 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 # Keep existing routes when they are at least as cheap. 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..13ac3779 --- /dev/null +++ b/tests/test_path.py @@ -0,0 +1,83 @@ +"""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] + + +@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_path = pathfinder.path_from(new_root).copy() + old_distances = pathfinder.distance.copy() + old_traversal = pathfinder.traversal.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)