Add `collide` kwarg to Grid.find_path() and Grid.get_dijkstra_map() that treats entities bearing a given label as impassable obstacles via mark-and-restore on the TCOD walkability map. Dijkstra cache key now includes collide label for separate caching. Add Entity.find_path() convenience method that delegates to the grid. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
289 lines
9.2 KiB
Python
289 lines
9.2 KiB
Python
"""Tests for pathfinding with collision labels (#302)
|
|
|
|
Tests Grid.find_path(collide=), Grid.get_dijkstra_map(collide=),
|
|
and Entity.find_path() convenience method.
|
|
"""
|
|
import mcrfpy
|
|
import sys
|
|
|
|
PASS = 0
|
|
FAIL = 0
|
|
|
|
def test(name, condition):
|
|
global PASS, FAIL
|
|
if condition:
|
|
PASS += 1
|
|
else:
|
|
FAIL += 1
|
|
print(f"FAIL: {name}")
|
|
|
|
|
|
def make_grid():
|
|
"""Create a 10x10 grid with all cells walkable."""
|
|
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
|
g = mcrfpy.Grid(grid_size=(10, 10), texture=tex,
|
|
pos=(0, 0), size=(160, 160))
|
|
# Make all cells walkable
|
|
for y in range(10):
|
|
for x in range(10):
|
|
pt = g.at(x, y)
|
|
pt.walkable = True
|
|
pt.transparent = True
|
|
return g
|
|
|
|
|
|
def test_find_path_no_collide():
|
|
"""find_path without collide should ignore entity labels."""
|
|
g = make_grid()
|
|
scene = mcrfpy.Scene("test_fp_nocollide")
|
|
scene.children.append(g)
|
|
|
|
# Place a blocking entity at (3, 0) with label "enemy"
|
|
e = mcrfpy.Entity((3, 0), grid=g)
|
|
e.add_label("enemy")
|
|
|
|
# Without collide, path should go through (3, 0)
|
|
path = g.find_path((0, 0), (5, 0))
|
|
test("find_path without collide returns path", path is not None)
|
|
if path:
|
|
steps = [s for s in path]
|
|
coords = [(int(s.x), int(s.y)) for s in steps]
|
|
test("find_path without collide goes through entity cell",
|
|
(3, 0) in coords)
|
|
|
|
|
|
def test_find_path_with_collide():
|
|
"""find_path with collide should avoid entities with that label."""
|
|
g = make_grid()
|
|
scene = mcrfpy.Scene("test_fp_collide")
|
|
scene.children.append(g)
|
|
|
|
# Place blocking entities in a line at y=0, x=3
|
|
e = mcrfpy.Entity((3, 0), grid=g)
|
|
e.add_label("enemy")
|
|
|
|
# With collide="enemy", path must avoid (3, 0)
|
|
path = g.find_path((0, 0), (5, 0), collide="enemy")
|
|
test("find_path with collide returns path", path is not None)
|
|
if path:
|
|
steps = [s for s in path]
|
|
coords = [(int(s.x), int(s.y)) for s in steps]
|
|
test("find_path with collide avoids entity cell",
|
|
(3, 0) not in coords)
|
|
|
|
|
|
def test_find_path_collide_different_label():
|
|
"""find_path with collide should only block matching labels."""
|
|
g = make_grid()
|
|
scene = mcrfpy.Scene("test_fp_difflabel")
|
|
scene.children.append(g)
|
|
|
|
e = mcrfpy.Entity((3, 0), grid=g)
|
|
e.add_label("friend")
|
|
|
|
# Collide with "enemy" should not block "friend" entities
|
|
path = g.find_path((0, 0), (5, 0), collide="enemy")
|
|
test("find_path collide ignores non-matching labels", path is not None)
|
|
if path:
|
|
steps = [s for s in path]
|
|
coords = [(int(s.x), int(s.y)) for s in steps]
|
|
test("find_path goes through non-matching label entity",
|
|
(3, 0) in coords)
|
|
|
|
|
|
def test_find_path_collide_restores_walkability():
|
|
"""After find_path with collide, cell walkability is restored."""
|
|
g = make_grid()
|
|
scene = mcrfpy.Scene("test_fp_restore")
|
|
scene.children.append(g)
|
|
|
|
e = mcrfpy.Entity((3, 0), grid=g)
|
|
e.add_label("enemy")
|
|
|
|
# Check walkability before
|
|
test("cell walkable before find_path", g.at(3, 0).walkable)
|
|
|
|
path = g.find_path((0, 0), (5, 0), collide="enemy")
|
|
|
|
# Cell should be walkable again after
|
|
test("cell walkable after find_path with collide", g.at(3, 0).walkable)
|
|
|
|
|
|
def test_find_path_collide_blocks_path():
|
|
"""If colliding entities block the only path, find_path returns None."""
|
|
g = make_grid()
|
|
scene = mcrfpy.Scene("test_fp_blocked")
|
|
scene.children.append(g)
|
|
|
|
# Create a wall of enemies across the middle
|
|
for x in range(10):
|
|
e = mcrfpy.Entity((x, 5), grid=g)
|
|
e.add_label("wall")
|
|
|
|
path = g.find_path((0, 0), (0, 9), collide="wall")
|
|
test("find_path returns None when collide blocks all paths", path is None)
|
|
|
|
|
|
def test_dijkstra_no_collide():
|
|
"""get_dijkstra_map without collide ignores labels."""
|
|
g = make_grid()
|
|
scene = mcrfpy.Scene("test_dij_nocollide")
|
|
scene.children.append(g)
|
|
|
|
e = mcrfpy.Entity((3, 0), grid=g)
|
|
e.add_label("enemy")
|
|
|
|
dmap = g.get_dijkstra_map((0, 0))
|
|
dist = dmap.distance((3, 0))
|
|
test("dijkstra without collide reaches entity cell", dist is not None)
|
|
test("dijkstra distance to (3,0) is 3", abs(dist - 3.0) < 0.01)
|
|
|
|
|
|
def test_dijkstra_with_collide():
|
|
"""get_dijkstra_map with collide blocks labeled entities."""
|
|
g = make_grid()
|
|
scene = mcrfpy.Scene("test_dij_collide")
|
|
scene.children.append(g)
|
|
|
|
e = mcrfpy.Entity((3, 0), grid=g)
|
|
e.add_label("enemy")
|
|
|
|
dmap = g.get_dijkstra_map((0, 0), collide="enemy")
|
|
dist = dmap.distance((3, 0))
|
|
# (3,0) is blocked, so distance should be None (unreachable as walkable)
|
|
# Actually, the cell is marked non-walkable for computation, but libtcod
|
|
# may still report a distance. Let's check that path avoids it.
|
|
path = dmap.path_from((5, 0))
|
|
test("dijkstra with collide returns path", path is not None)
|
|
if path:
|
|
steps = [s for s in path]
|
|
coords = [(int(s.x), int(s.y)) for s in steps]
|
|
test("dijkstra path avoids collide entity cell",
|
|
(3, 0) not in coords)
|
|
|
|
|
|
def test_dijkstra_cache_separate_keys():
|
|
"""Dijkstra maps with different collide labels are cached separately."""
|
|
g = make_grid()
|
|
scene = mcrfpy.Scene("test_dij_cache")
|
|
scene.children.append(g)
|
|
|
|
e = mcrfpy.Entity((3, 0), grid=g)
|
|
e.add_label("enemy")
|
|
|
|
dmap_none = g.get_dijkstra_map((0, 0))
|
|
dmap_enemy = g.get_dijkstra_map((0, 0), collide="enemy")
|
|
|
|
# Same root, different collide label = different maps
|
|
dist_none = dmap_none.distance((3, 0))
|
|
dist_enemy = dmap_enemy.distance((3, 0))
|
|
|
|
test("dijkstra no-collide reaches (3,0)", dist_none is not None and dist_none >= 0)
|
|
# With collide, (3,0) is non-walkable, distance should be different
|
|
# (either None or a longer path distance)
|
|
test("dijkstra cache separates collide labels",
|
|
dist_none != dist_enemy or dist_enemy is None)
|
|
|
|
|
|
def test_dijkstra_collide_restores_walkability():
|
|
"""After get_dijkstra_map with collide, walkability is restored."""
|
|
g = make_grid()
|
|
scene = mcrfpy.Scene("test_dij_restore")
|
|
scene.children.append(g)
|
|
|
|
e = mcrfpy.Entity((3, 0), grid=g)
|
|
e.add_label("enemy")
|
|
|
|
test("cell walkable before dijkstra", g.at(3, 0).walkable)
|
|
dmap = g.get_dijkstra_map((0, 0), collide="enemy")
|
|
test("cell walkable after dijkstra with collide", g.at(3, 0).walkable)
|
|
|
|
|
|
def test_entity_find_path():
|
|
"""Entity.find_path() convenience method."""
|
|
g = make_grid()
|
|
scene = mcrfpy.Scene("test_efp")
|
|
scene.children.append(g)
|
|
|
|
player = mcrfpy.Entity((0, 0), grid=g)
|
|
target = mcrfpy.Entity((5, 5), grid=g)
|
|
|
|
path = player.find_path((5, 5))
|
|
test("entity.find_path returns AStarPath", path is not None)
|
|
test("entity.find_path type is AStarPath",
|
|
type(path).__name__ == "AStarPath")
|
|
if path:
|
|
test("entity.find_path origin is entity pos",
|
|
int(path.origin.x) == 0 and int(path.origin.y) == 0)
|
|
test("entity.find_path destination is target",
|
|
int(path.destination.x) == 5 and int(path.destination.y) == 5)
|
|
|
|
|
|
def test_entity_find_path_with_collide():
|
|
"""Entity.find_path() with collide kwarg."""
|
|
g = make_grid()
|
|
scene = mcrfpy.Scene("test_efp_collide")
|
|
scene.children.append(g)
|
|
|
|
player = mcrfpy.Entity((0, 0), grid=g)
|
|
enemy = mcrfpy.Entity((3, 0), grid=g)
|
|
enemy.add_label("enemy")
|
|
|
|
path = player.find_path((5, 0), collide="enemy")
|
|
test("entity.find_path with collide returns path", path is not None)
|
|
if path:
|
|
steps = [s for s in path]
|
|
coords = [(int(s.x), int(s.y)) for s in steps]
|
|
test("entity.find_path with collide avoids enemy",
|
|
(3, 0) not in coords)
|
|
|
|
|
|
def test_entity_find_path_to_entity():
|
|
"""Entity.find_path() accepts an Entity as target."""
|
|
g = make_grid()
|
|
scene = mcrfpy.Scene("test_efp_entity")
|
|
scene.children.append(g)
|
|
|
|
player = mcrfpy.Entity((0, 0), grid=g)
|
|
goal = mcrfpy.Entity((5, 5), grid=g)
|
|
|
|
path = player.find_path(goal)
|
|
test("entity.find_path(entity) returns path", path is not None)
|
|
if path:
|
|
test("entity.find_path(entity) destination correct",
|
|
int(path.destination.x) == 5 and int(path.destination.y) == 5)
|
|
|
|
|
|
def test_entity_find_path_no_grid():
|
|
"""Entity.find_path() raises if entity has no grid."""
|
|
e = mcrfpy.Entity((0, 0))
|
|
try:
|
|
path = e.find_path((5, 5))
|
|
test("entity.find_path without grid raises", False)
|
|
except ValueError:
|
|
test("entity.find_path without grid raises", True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
test_find_path_no_collide()
|
|
test_find_path_with_collide()
|
|
test_find_path_collide_different_label()
|
|
test_find_path_collide_restores_walkability()
|
|
test_find_path_collide_blocks_path()
|
|
test_dijkstra_no_collide()
|
|
test_dijkstra_with_collide()
|
|
test_dijkstra_cache_separate_keys()
|
|
test_dijkstra_collide_restores_walkability()
|
|
test_entity_find_path()
|
|
test_entity_find_path_with_collide()
|
|
test_entity_find_path_to_entity()
|
|
test_entity_find_path_no_grid()
|
|
|
|
total = PASS + FAIL
|
|
print(f"\n{PASS}/{total} tests passed")
|
|
if FAIL:
|
|
print(f"{FAIL} FAILED")
|
|
sys.exit(1)
|
|
else:
|
|
print("PASS")
|
|
sys.exit(0)
|