McRogueFace/tests/unit/dijkstra_flee_test.py
John McCardle 767d0d4b0f Extend pathfinding API with heuristics, multi-root Dijkstra, and FLEE primitives; refs #315
Phase A (Python surface):
- New mcrfpy.Heuristic IntEnum: EUCLIDEAN, MANHATTAN, CHEBYSHEV, DIAGONAL, ZERO
- Grid.find_path() accepts heuristic= and weight= kwargs (weighted A*)
- Grid.get_dijkstra_map() accepts roots= (list of positions or DiscreteMap mask)

Phase B (FLEE primitives):
- DijkstraMap.invert() returns a new map with inverted distance field
- DijkstraMap.descent_step(pos) returns steepest-descent neighbor or None

DijkstraMap internally switched from the C++ TCODDijkstra wrapper to the C API
(TCOD_dijkstra_*) because multi-root compute and invert/get_descent are not
exposed on the wrapper. Single-root Dijkstra cache is preserved for backward
compatibility; multi-root and mask paths bypass the cache since cache keys
would be ill-defined.

New tests: heuristic_enum_test, find_path_heuristic_test, multi_root_dijkstra_test,
dijkstra_flee_test. Baseline JSONs for dijkstra_bench and gridview_render_bench
refreshed against the new implementation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 09:18:49 -04:00

85 lines
2.9 KiB
Python

"""DijkstraMap.invert() + descent_step() produce FLEE behavior.
Build a Dijkstra map rooted on a threat, invert it, and confirm that walking
descent steps from a nearby cell strictly increases distance from the threat.
"""
import mcrfpy
import sys
def make_grid(w, h):
g = mcrfpy.Grid(grid_size=(w, h))
for y in range(h):
for x in range(w):
c = g.at(x, y)
c.walkable = True
c.transparent = True
return g
def main():
g = make_grid(20, 20)
threat = (10, 10)
threat_map = g.get_dijkstra_map(threat)
assert threat_map.distance(threat) == 0.0
# invert() returns a NEW map; the original is unchanged.
safety_map = threat_map.invert()
assert safety_map is not threat_map, "invert() should return a new object"
assert threat_map.distance(threat) == 0.0, "original map must not be mutated"
# After invert, the threat cell itself is a local minimum (low safety),
# and cells far from the threat are peaks.
# descent_step on the safety map from a cell near the threat must move AWAY,
# i.e. its distance in the *original* (threat-rooted) map strictly increases.
start = (11, 10)
start_dist = threat_map.distance(start)
pos = start
for _ in range(5):
nxt = safety_map.descent_step(pos)
if nxt is None:
break
nxt_tuple = (int(nxt.x), int(nxt.y))
# Must actually move.
assert nxt_tuple != pos, f"descent stuck at {pos}"
# Must move to a walkable cell inside the grid.
assert 0 <= nxt_tuple[0] < 20 and 0 <= nxt_tuple[1] < 20
new_dist = threat_map.distance(nxt_tuple)
assert new_dist >= start_dist, (
f"FLEE descent from {pos} to {nxt_tuple}: threat distance dropped "
f"from {start_dist} to {new_dist}")
pos = nxt_tuple
start_dist = new_dist
# descent_step on the original (non-inverted) map from a far cell SEEKs the threat.
far = (0, 0)
nxt = threat_map.descent_step(far)
assert nxt is not None
nxt_tuple = (int(nxt.x), int(nxt.y))
# Closer to the threat than `far`.
assert threat_map.distance(nxt_tuple) < threat_map.distance(far), \
"descent on threat_map should SEEK the root"
# descent_step at the root itself has no better neighbor — returns None.
at_root = safety_map.descent_step(threat)
# Note: at_root might not be None on the inverted map since the threat is a local
# minimum of the inverted field — any neighbor has lower (or equal) value. So allow
# either None or a valid step. Just ensure we don't crash.
_ = at_root
# Out-of-bounds raises IndexError.
try:
safety_map.descent_step((999, 999))
except IndexError:
pass
else:
raise AssertionError("expected IndexError for out-of-bounds descent_step")
print("PASS")
if __name__ == "__main__":
main()
sys.exit(0)