From c1a9523ac2911fd83c0791e50743e5bed47766a1 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Thu, 2 Apr 2026 01:34:19 -0400 Subject: [PATCH] Add collision label support for pathfinding (closes #302) 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 --- src/GridData.cpp | 2 + src/GridData.h | 8 +- src/UIEntity.cpp | 96 ++++++++ src/UIEntity.h | 22 ++ src/UIGrid.cpp | 85 ++++++-- src/UIGridPathfinding.cpp | 76 ++++++- tests/unit/pathfinding_collide_test.py | 289 +++++++++++++++++++++++++ 7 files changed, 546 insertions(+), 32 deletions(-) create mode 100644 tests/unit/pathfinding_collide_test.py diff --git a/src/GridData.cpp b/src/GridData.cpp index a972b6c..e2aaf45 100644 --- a/src/GridData.cpp +++ b/src/GridData.cpp @@ -84,6 +84,7 @@ void GridData::syncTCODMap() } } fov_dirty = true; + transparency_generation++; } void GridData::syncTCODMapCell(int x, int y) @@ -92,6 +93,7 @@ void GridData::syncTCODMapCell(int x, int y) const UIGridPoint& point = at(x, y); tcod_map->setProperties(x, y, point.transparent, point.walkable); fov_dirty = true; + transparency_generation++; } void GridData::computeFOV(int x, int y, int radius, bool light_walls, TCOD_fov_algorithm_t algo) diff --git a/src/GridData.h b/src/GridData.h index 89dce0e..2c98c36 100644 --- a/src/GridData.h +++ b/src/GridData.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include "PyCallable.h" @@ -78,10 +79,15 @@ public: bool fov_last_light_walls = true; TCOD_fov_algorithm_t fov_last_algo = FOV_BASIC; + // #303 - Transparency generation counter for per-entity FOV caching + // Bumped whenever any cell's transparent/walkable property changes + uint32_t transparency_generation = 0; + // ========================================================================= // Pathfinding caches // ========================================================================= - std::map, std::shared_ptr> dijkstra_maps; + // Cache key: (root_x, root_y, collide_label) where collide_label="" means no collision filtering + std::map, std::shared_ptr> dijkstra_maps; // ========================================================================= // Layer system (#147, #150) diff --git a/src/UIEntity.cpp b/src/UIEntity.cpp index 69e7dcf..f6f30b3 100644 --- a/src/UIEntity.cpp +++ b/src/UIEntity.cpp @@ -1,5 +1,6 @@ #include "UIEntity.h" #include "UIGrid.h" +#include "UIGridPathfinding.h" #include "McRFPy_API.h" #include #include @@ -883,6 +884,77 @@ PyObject* UIEntity::path_to(PyUIEntityObject* self, PyObject* args, PyObject* kw return path_list; } +PyObject* UIEntity::find_path(PyUIEntityObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"target", "diagonal_cost", "collide", NULL}; + PyObject* target_obj = NULL; + float diagonal_cost = 1.41f; + const char* collide_label = NULL; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|fz", const_cast(kwlist), + &target_obj, &diagonal_cost, &collide_label)) { + return NULL; + } + + if (!self->data || !self->data->grid) { + PyErr_SetString(PyExc_ValueError, + "Entity must be associated with a grid to compute paths"); + return NULL; + } + + auto grid = self->data->grid; + + // Extract target position + int target_x, target_y; + if (!UIGridPathfinding::ExtractPosition(target_obj, &target_x, &target_y, + grid.get(), "target")) { + return NULL; + } + + int start_x = self->data->cell_position.x; + int start_y = self->data->cell_position.y; + + // Bounds check + if (start_x < 0 || start_x >= grid->grid_w || start_y < 0 || start_y >= grid->grid_h || + target_x < 0 || target_x >= grid->grid_w || target_y < 0 || target_y >= grid->grid_h) { + PyErr_SetString(PyExc_ValueError, "Position out of grid bounds"); + return NULL; + } + + // Build args to delegate to Grid.find_path + // Create a temporary PyUIGridObject wrapper for the grid + auto grid_type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Grid"); + if (!grid_type) return NULL; + auto pyGrid = (PyUIGridObject*)grid_type->tp_alloc(grid_type, 0); + Py_DECREF(grid_type); + if (!pyGrid) return NULL; + new (&pyGrid->data) std::shared_ptr(grid); + + // Build keyword args for Grid.find_path + PyObject* start_tuple = Py_BuildValue("(ii)", start_x, start_y); + PyObject* target_tuple = Py_BuildValue("(ii)", target_x, target_y); + PyObject* fwd_args = PyTuple_Pack(2, start_tuple, target_tuple); + Py_DECREF(start_tuple); + Py_DECREF(target_tuple); + + PyObject* fwd_kwds = PyDict_New(); + PyObject* py_diag = PyFloat_FromDouble(diagonal_cost); + PyDict_SetItemString(fwd_kwds, "diagonal_cost", py_diag); + Py_DECREF(py_diag); + if (collide_label) { + PyObject* py_collide = PyUnicode_FromString(collide_label); + PyDict_SetItemString(fwd_kwds, "collide", py_collide); + Py_DECREF(py_collide); + } + + PyObject* result = UIGridPathfinding::Grid_find_path(pyGrid, fwd_args, fwd_kwds); + + Py_DECREF(fwd_args); + Py_DECREF(fwd_kwds); + Py_DECREF(pyGrid); + + return result; +} + PyObject* UIEntity::update_visibility(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored)) { self->data->updateVisibility(); @@ -1002,6 +1074,18 @@ PyMethodDef UIEntity::methods[] = { " path = entity.path_to(10, 5)\n" " path = entity.path_to((10, 5))\n" " path = entity.path_to(pos=(10, 5))"}, + {"find_path", (PyCFunction)UIEntity::find_path, METH_VARARGS | METH_KEYWORDS, + "find_path(target, diagonal_cost=1.41, collide=None) -> AStarPath | None\n\n" + "Find a path from this entity to the target position.\n\n" + "Args:\n" + " target: Target as Vector, Entity, or (x, y) tuple.\n" + " diagonal_cost: Cost of diagonal movement (default 1.41).\n" + " collide: Label string. Entities with this label block pathfinding.\n\n" + "Returns:\n" + " AStarPath object, or None if no path exists.\n\n" + "Example:\n" + " path = entity.find_path((10, 5))\n" + " path = entity.find_path(player, collide='enemy')"}, {"update_visibility", (PyCFunction)UIEntity::update_visibility, METH_NOARGS, "update_visibility() -> None\n\n" "Update entity's visibility state based on current FOV.\n\n" @@ -1362,6 +1446,18 @@ PyMethodDef UIEntity_all_methods[] = { " path = entity.path_to(10, 5)\n" " path = entity.path_to((10, 5))\n" " path = entity.path_to(pos=(10, 5))"}, + {"find_path", (PyCFunction)UIEntity::find_path, METH_VARARGS | METH_KEYWORDS, + "find_path(target, diagonal_cost=1.41, collide=None) -> AStarPath | None\n\n" + "Find a path from this entity to the target position.\n\n" + "Args:\n" + " target: Target as Vector, Entity, or (x, y) tuple.\n" + " diagonal_cost: Cost of diagonal movement (default 1.41).\n" + " collide: Label string. Entities with this label block pathfinding.\n\n" + "Returns:\n" + " AStarPath object, or None if no path exists.\n\n" + "Example:\n" + " path = entity.find_path((10, 5))\n" + " path = entity.find_path(player, collide='enemy')"}, {"update_visibility", (PyCFunction)UIEntity::update_visibility, METH_NOARGS, "update_visibility() -> None\n\n" "Update entity's visibility state based on current FOV.\n\n" diff --git a/src/UIEntity.h b/src/UIEntity.h index b5fd45c..858fa83 100644 --- a/src/UIEntity.h +++ b/src/UIEntity.h @@ -79,6 +79,27 @@ public: float move_speed = 0.15f; // #300: animation duration for movement (0 = instant) std::string target_label; // #300: label to search for with TARGET trigger int sight_radius = 10; // #300: FOV radius for TARGET trigger + + // #303 - Per-entity FOV result cache for TARGET trigger optimization + // Caches the visibility bitmap from the last FOV computation so that + // entities that haven't moved skip recomputation entirely. + struct TargetFOVCache { + sf::Vector2i origin{-1, -1}; + int radius = -1; + uint32_t transparency_gen = 0; + std::vector visibility; // (2*radius+1)^2 bitmap + int vis_side = 0; // 2*radius+1 + + bool isValid(sf::Vector2i pos, int r, uint32_t gen) const { + return vis_side > 0 && pos == origin && r == radius && gen == transparency_gen; + } + bool isVisible(int x, int y) const { + int dx = x - origin.x + radius; + int dy = y - origin.y + radius; + if (dx < 0 || dx >= vis_side || dy < 0 || dy >= vis_side) return false; + return visibility[dy * vis_side + dx]; + } + } target_fov_cache; //void render(sf::Vector2f); //override final; UIEntity(); @@ -120,6 +141,7 @@ public: static PyObject* index(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored)); static PyObject* die(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored)); static PyObject* path_to(PyUIEntityObject* self, PyObject* args, PyObject* kwds); + static PyObject* find_path(PyUIEntityObject* self, PyObject* args, PyObject* kwds); static PyObject* update_visibility(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored)); static PyObject* visible_entities(PyUIEntityObject* self, PyObject* args, PyObject* kwds); static int init(PyUIEntityObject* self, PyObject* args, PyObject* kwds); diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index e5a1a79..4876857 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -2153,26 +2153,28 @@ PyMethodDef UIGrid::methods[] = { " True if the cell is visible, False otherwise\n\n" "Must call compute_fov() first to calculate visibility."}, {"find_path", (PyCFunction)UIGridPathfinding::Grid_find_path, METH_VARARGS | METH_KEYWORDS, - "find_path(start, end, diagonal_cost: float = 1.41) -> AStarPath | None\n\n" + "find_path(start, end, diagonal_cost=1.41, collide=None) -> AStarPath | None\n\n" "Compute A* path between two points.\n\n" "Args:\n" " start: Starting position as Vector, Entity, or (x, y) tuple\n" " end: Target position as Vector, Entity, or (x, y) tuple\n" - " diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n" + " diagonal_cost: Cost of diagonal movement (default: 1.41)\n" + " collide: Label string. Entities with this label block pathfinding.\n\n" "Returns:\n" " AStarPath object if path exists, None otherwise.\n\n" "The returned AStarPath can be iterated or walked step-by-step."}, {"get_dijkstra_map", (PyCFunction)UIGridPathfinding::Grid_get_dijkstra_map, METH_VARARGS | METH_KEYWORDS, - "get_dijkstra_map(root, diagonal_cost: float = 1.41) -> DijkstraMap\n\n" + "get_dijkstra_map(root, diagonal_cost=1.41, collide=None) -> DijkstraMap\n\n" "Get or create a Dijkstra distance map for a root position.\n\n" "Args:\n" " root: Root position as Vector, Entity, or (x, y) tuple\n" - " diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n" + " diagonal_cost: Cost of diagonal movement (default: 1.41)\n" + " collide: Label string. Entities with this label block pathfinding.\n\n" "Returns:\n" " DijkstraMap object for querying distances and paths.\n\n" - "Grid caches DijkstraMaps by root position. Multiple requests for the\n" - "same root return the same cached map. Call clear_dijkstra_maps() after\n" - "changing grid walkability to invalidate the cache."}, + "Grid caches DijkstraMaps by (root, collide) key. Multiple requests for\n" + "the same root and collide label return the same cached map. Call\n" + "clear_dijkstra_maps() after changing grid walkability to invalidate."}, {"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS, "clear_dijkstra_maps() -> None\n\n" "Clear all cached Dijkstra maps.\n\n" @@ -2329,27 +2331,64 @@ PyObject* UIGrid::py_step(PyUIGridObject* self, PyObject* args, PyObject* kwds) if (entity->behavior.type == BehaviorType::IDLE) continue; // Check TARGET trigger (if target_label set) + // #303: Tiered optimization: + // Tier 1: O(1) label check (target_label.empty()) + // Tier 2: O(bucket) spatial hash pre-filter + // Tier 3: O(radius^2) bounded FOV (TCOD respects radius) + // Tier 4: Per-entity FOV cache — skip recomputation when + // entity hasn't moved and map transparency unchanged if (!entity->target_label.empty()) { - // Quick check: are there any entities with target_label nearby? + // Tier 2: Spatial hash proximity pre-filter auto nearby = grid->spatial_hash.queryRadius( static_cast(entity->cell_position.x), static_cast(entity->cell_position.y), static_cast(entity->sight_radius)); - for (auto& target : nearby) { - if (target.get() == entity.get()) continue; - if (target->labels.count(entity->target_label)) { - // Compute FOV to verify line of sight + // Collect matching targets before touching FOV + std::vector> matching_targets; + for (auto& candidate : nearby) { + if (candidate.get() != entity.get() && + candidate->labels.count(entity->target_label)) { + matching_targets.push_back(candidate); + } + } + + if (!matching_targets.empty()) { + auto& cache = entity->target_fov_cache; + + // Tier 4: Check per-entity FOV cache + if (!cache.isValid(entity->cell_position, entity->sight_radius, + grid->transparency_generation)) { + // Cache miss — compute FOV and snapshot the visibility bitmap grid->computeFOV(entity->cell_position.x, entity->cell_position.y, entity->sight_radius, true, grid->fov_algorithm); - if (grid->isInFOV(target->cell_position.x, target->cell_position.y)) { - // Fire TARGET trigger + + int r = entity->sight_radius; + int side = 2 * r + 1; + cache.origin = entity->cell_position; + cache.radius = r; + cache.transparency_gen = grid->transparency_generation; + cache.vis_side = side; + cache.visibility.resize(side * side); + for (int dy = -r; dy <= r; dy++) { + for (int dx = -r; dx <= r; dx++) { + cache.visibility[(dy + r) * side + (dx + r)] = + grid->isInFOV(entity->cell_position.x + dx, + entity->cell_position.y + dy); + } + } + } + + // Check targets against cached visibility + for (auto& target : matching_targets) { + if (cache.isVisible(target->cell_position.x, + target->cell_position.y)) { PyObject* target_pyobj = Py_None; if (target->pyobject) { target_pyobj = target->pyobject; } fireStepCallback(entity, 2 /* TARGET */, target_pyobj); - goto next_entity; // Skip behavior execution after TARGET + goto next_entity; } } } @@ -2433,26 +2472,28 @@ PyMethodDef UIGrid_all_methods[] = { " True if the cell is visible, False otherwise\n\n" "Must call compute_fov() first to calculate visibility."}, {"find_path", (PyCFunction)UIGridPathfinding::Grid_find_path, METH_VARARGS | METH_KEYWORDS, - "find_path(start, end, diagonal_cost: float = 1.41) -> AStarPath | None\n\n" + "find_path(start, end, diagonal_cost=1.41, collide=None) -> AStarPath | None\n\n" "Compute A* path between two points.\n\n" "Args:\n" " start: Starting position as Vector, Entity, or (x, y) tuple\n" " end: Target position as Vector, Entity, or (x, y) tuple\n" - " diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n" + " diagonal_cost: Cost of diagonal movement (default: 1.41)\n" + " collide: Label string. Entities with this label block pathfinding.\n\n" "Returns:\n" " AStarPath object if path exists, None otherwise.\n\n" "The returned AStarPath can be iterated or walked step-by-step."}, {"get_dijkstra_map", (PyCFunction)UIGridPathfinding::Grid_get_dijkstra_map, METH_VARARGS | METH_KEYWORDS, - "get_dijkstra_map(root, diagonal_cost: float = 1.41) -> DijkstraMap\n\n" + "get_dijkstra_map(root, diagonal_cost=1.41, collide=None) -> DijkstraMap\n\n" "Get or create a Dijkstra distance map for a root position.\n\n" "Args:\n" " root: Root position as Vector, Entity, or (x, y) tuple\n" - " diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n" + " diagonal_cost: Cost of diagonal movement (default: 1.41)\n" + " collide: Label string. Entities with this label block pathfinding.\n\n" "Returns:\n" " DijkstraMap object for querying distances and paths.\n\n" - "Grid caches DijkstraMaps by root position. Multiple requests for the\n" - "same root return the same cached map. Call clear_dijkstra_maps() after\n" - "changing grid walkability to invalidate the cache."}, + "Grid caches DijkstraMaps by (root, collide) key. Multiple requests for\n" + "the same root and collide label return the same cached map. Call\n" + "clear_dijkstra_maps() after changing grid walkability to invalidate."}, {"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS, "clear_dijkstra_maps() -> None\n\n" "Clear all cached Dijkstra maps.\n\n" diff --git a/src/UIGridPathfinding.cpp b/src/UIGridPathfinding.cpp index 8f8331f..8225159 100644 --- a/src/UIGridPathfinding.cpp +++ b/src/UIGridPathfinding.cpp @@ -458,14 +458,55 @@ PyObject* UIGridPathfinding::DijkstraMap_to_heightmap(PyDijkstraMapObject* self, // Grid Factory Methods //============================================================================= +// Helper: collect cells occupied by entities with a given label, mark non-walkable. +// Returns the list of (x, y, was_walkable) for restoration. +static std::vector> markCollisionLabel( + GridData* grid, const std::string& collide_label) +{ + std::vector> restore_list; + if (!grid->entities || collide_label.empty()) return restore_list; + + TCODMap* tcod_map = grid->getTCODMap(); + if (!tcod_map) return restore_list; + + for (auto& entity : *grid->entities) { + if (!entity) continue; + if (entity->labels.count(collide_label)) { + int ex = entity->cell_position.x; + int ey = entity->cell_position.y; + if (ex >= 0 && ex < grid->grid_w && ey >= 0 && ey < grid->grid_h) { + bool was_walkable = tcod_map->isWalkable(ex, ey); + if (was_walkable) { + tcod_map->setProperties(ex, ey, tcod_map->isTransparent(ex, ey), false); + restore_list.emplace_back(ex, ey, true); + } + } + } + } + return restore_list; +} + +// Helper: restore walkability after pathfinding. +static void restoreCollisionLabel(GridData* grid, + const std::vector>& restore_list) +{ + TCODMap* tcod_map = grid->getTCODMap(); + if (!tcod_map) return; + + for (auto& [x, y, was_walkable] : restore_list) { + tcod_map->setProperties(x, y, tcod_map->isTransparent(x, y), was_walkable); + } +} + PyObject* UIGridPathfinding::Grid_find_path(PyUIGridObject* self, PyObject* args, PyObject* kwds) { - static const char* kwlist[] = {"start", "end", "diagonal_cost", NULL}; + static const char* kwlist[] = {"start", "end", "diagonal_cost", "collide", NULL}; PyObject* start_obj = NULL; PyObject* end_obj = NULL; float diagonal_cost = 1.41f; + const char* collide_label = NULL; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|f", const_cast(kwlist), - &start_obj, &end_obj, &diagonal_cost)) { + if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|fz", const_cast(kwlist), + &start_obj, &end_obj, &diagonal_cost, &collide_label)) { return NULL; } @@ -489,9 +530,18 @@ PyObject* UIGridPathfinding::Grid_find_path(PyUIGridObject* self, PyObject* args return NULL; } + // Mark-and-restore: temporarily block cells occupied by entities with collide label + std::string label_str = collide_label ? collide_label : ""; + auto restore_list = markCollisionLabel(self->data.get(), label_str); + // Compute path using temporary TCODPath TCODPath tcod_path(self->data->getTCODMap(), diagonal_cost); - if (!tcod_path.compute(x1, y1, x2, y2)) { + bool found = tcod_path.compute(x1, y1, x2, y2); + + // Restore walkability before returning + restoreCollisionLabel(self->data.get(), restore_list); + + if (!found) { Py_RETURN_NONE; // No path exists } @@ -518,12 +568,13 @@ PyObject* UIGridPathfinding::Grid_find_path(PyUIGridObject* self, PyObject* args } PyObject* UIGridPathfinding::Grid_get_dijkstra_map(PyUIGridObject* self, PyObject* args, PyObject* kwds) { - static const char* kwlist[] = {"root", "diagonal_cost", NULL}; + static const char* kwlist[] = {"root", "diagonal_cost", "collide", NULL}; PyObject* root_obj = NULL; float diagonal_cost = 1.41f; + const char* collide_label = NULL; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|f", const_cast(kwlist), - &root_obj, &diagonal_cost)) { + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|fz", const_cast(kwlist), + &root_obj, &diagonal_cost, &collide_label)) { return NULL; } @@ -543,12 +594,13 @@ PyObject* UIGridPathfinding::Grid_get_dijkstra_map(PyUIGridObject* self, PyObjec return NULL; } - auto key = std::make_pair(root_x, root_y); + std::string label_str = collide_label ? collide_label : ""; + auto key = std::make_tuple(root_x, root_y, label_str); // Check cache auto it = self->data->dijkstra_maps.find(key); if (it != self->data->dijkstra_maps.end()) { - // Check diagonal cost matches (or we could ignore this) + // Check diagonal cost matches if (std::abs(it->second->getDiagonalCost() - diagonal_cost) < 0.001f) { // Return existing PyDijkstraMapObject* result = (PyDijkstraMapObject*)mcrfpydef::PyDijkstraMapType.tp_alloc( @@ -561,10 +613,16 @@ PyObject* UIGridPathfinding::Grid_get_dijkstra_map(PyUIGridObject* self, PyObjec self->data->dijkstra_maps.erase(it); } + // Mark-and-restore: temporarily block cells with collide label + auto restore_list = markCollisionLabel(self->data.get(), label_str); + // Create new DijkstraMap auto dijkstra = std::make_shared( self->data->getTCODMap(), root_x, root_y, diagonal_cost); + // Restore walkability + restoreCollisionLabel(self->data.get(), restore_list); + // Cache it self->data->dijkstra_maps[key] = dijkstra; diff --git a/tests/unit/pathfinding_collide_test.py b/tests/unit/pathfinding_collide_test.py new file mode 100644 index 0000000..53639de --- /dev/null +++ b/tests/unit/pathfinding_collide_test.py @@ -0,0 +1,289 @@ +"""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)