Skip to content

Commit 432932a

Browse files
authored
Merge pull request #191 from antonsoo/fix/reset-added-root-traversal
Keep root costs and traversal consistent when adding destinations
2 parents 50d805b + fa431ad commit 432932a

3 files changed

Lines changed: 90 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version
88

99
### Fixed
1010

11+
- 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.
1112
- Lazy pathfinder queries reconsider previously visited goals after adding a root or lowering a root's distance.
1213

1314
## [21.2.1] - 2026-06-04

‎tcod/path.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1205,21 +1205,26 @@ def clear(self) -> None:
12051205
lib.TCOD_frontier_clear(self._frontier_p)
12061206

12071207
def add_root(self, index: tuple[int, ...], value: int = 0) -> None:
1208-
"""Add a root node and insert it into the pathfinder frontier.
1208+
"""Add a root node for subsequent pathfinding.
12091209
12101210
`index` is the root point to insert. The length of `index` must match
12111211
the dimensions of the graph.
12121212
12131213
`value` is the distance to use for this root. Zero is typical, but
12141214
if multiple roots are added they can be given different weights.
1215+
Roots with lower `value` have higher priority and can suppress nearby
1216+
roots with higher values.
12151217
"""
12161218
index = tuple(index) # Check for bad input.
12171219
if self._order == "F": # Convert to ij indexing order.
12181220
index = index[::-1]
12191221
if len(index) != self._distance.ndim:
12201222
msg = f"Index must be {self._distance.ndim} items, got {index!r}"
12211223
raise TypeError(msg)
1224+
if value >= self._distance[index]:
1225+
return # Keep existing routes when they are at least as cheap.
12221226
self._distance[index] = value
1227+
self._travel[index] = index
12231228
self._update_heuristic(None)
12241229
lib.TCOD_frontier_push(self._frontier_p, index, value, value)
12251230

‎tests/test_path.py‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Tests for incremental pathfinding."""
2+
3+
import numpy as np
4+
import pytest
5+
6+
import tcod.path
7+
8+
9+
@pytest.mark.parametrize("shape", [(4, 7), (3, 4, 7)])
10+
@pytest.mark.parametrize("order", ["C", "F"])
11+
@pytest.mark.parametrize("value", [-3, 0, 3])
12+
def test_add_root_after_resolve(shape: tuple[int, ...], order: str, value: int) -> None:
13+
graph = tcod.path.CustomGraph(shape, order=order)
14+
cost = np.ones(shape, dtype=np.int8)
15+
for axis in range(len(shape)):
16+
for direction in (-1, 1):
17+
edge = [0] * len(shape)
18+
edge[axis] = direction
19+
graph.add_edge(tuple(edge), cost=cost)
20+
21+
pathfinder = tcod.path.Pathfinder(graph)
22+
origin = (0,) * len(shape)
23+
new_root = tuple(size - 1 for size in shape)
24+
pathfinder.add_root(origin)
25+
pathfinder.resolve()
26+
assert tuple(pathfinder.path_from(new_root)[-1]) == origin
27+
28+
pathfinder.add_root(new_root, value=value)
29+
pathfinder.resolve()
30+
np.testing.assert_array_equal(pathfinder.path_from(new_root), [new_root])
31+
coordinates = np.indices(shape)
32+
expected = np.minimum(
33+
coordinates.sum(axis=0),
34+
sum(size - 1 - coordinates[axis] for axis, size in enumerate(shape)) + value,
35+
)
36+
np.testing.assert_array_equal(pathfinder.distance, expected)
37+
for index in np.ndindex(shape):
38+
path = pathfinder.path_from(index)
39+
root = tuple(path[-1])
40+
assert root in (origin, new_root)
41+
root_value = value if root == new_root else 0
42+
assert len(path) - 1 + root_value == expected[index]
43+
44+
45+
@pytest.mark.parametrize("shape", [(4, 7), (3, 4, 7)])
46+
@pytest.mark.parametrize("order", ["C", "F"])
47+
@pytest.mark.parametrize("first_value", [-5, 3])
48+
@pytest.mark.parametrize("relative_value", [-1, 0, 1, 20])
49+
@pytest.mark.parametrize("partial", [False, True])
50+
def test_add_root_preserves_better_distances(
51+
shape: tuple[int, ...], order: str, first_value: int, relative_value: int, partial: bool
52+
) -> None:
53+
graph = tcod.path.CustomGraph(shape, order=order)
54+
cost = np.ones(shape, dtype=np.int8)
55+
for axis in range(len(shape)):
56+
for direction in (-1, 1):
57+
edge = [0] * len(shape)
58+
edge[axis] = direction
59+
graph.add_edge(tuple(edge), cost=cost)
60+
pathfinder = tcod.path.Pathfinder(graph)
61+
origin = (0,) * len(shape)
62+
new_root = tuple(size // 2 for size in shape)
63+
pathfinder.add_root(origin, value=first_value)
64+
pathfinder.resolve(new_root if partial else None)
65+
old_path = pathfinder.path_from(new_root).copy()
66+
old_distances = pathfinder.distance.copy()
67+
old_traversal = pathfinder.traversal.copy()
68+
value = int(old_distances[new_root]) + relative_value
69+
pathfinder.add_root(new_root, value=value)
70+
if relative_value >= 0:
71+
np.testing.assert_array_equal(pathfinder.distance, old_distances)
72+
np.testing.assert_array_equal(pathfinder.traversal, old_traversal)
73+
np.testing.assert_array_equal(pathfinder.path_from(new_root), old_path)
74+
else:
75+
assert pathfinder.distance[new_root] == value
76+
np.testing.assert_array_equal(pathfinder.path_from(new_root), [new_root])
77+
pathfinder.resolve()
78+
coordinates = np.indices(shape)
79+
expected = np.minimum(
80+
first_value + coordinates.sum(axis=0),
81+
value + sum(abs(new_root[axis] - coordinates[axis]) for axis in range(len(shape))),
82+
)
83+
np.testing.assert_array_equal(pathfinder.distance, expected)

0 commit comments

Comments
 (0)