diff --git a/src/EntityBehavior.cpp b/src/EntityBehavior.cpp index 4f8b423..98371f4 100644 --- a/src/EntityBehavior.cpp +++ b/src/EntityBehavior.cpp @@ -2,9 +2,22 @@ #include "UIEntity.h" #include "UIGrid.h" #include "UIGridPathfinding.h" +#include "PathProvider.h" #include #include +// Out-of-line reset lives here so the header can forward-declare PathProvider. +void EntityBehavior::reset() { + type = BehaviorType::IDLE; + waypoints.clear(); + current_waypoint_index = 0; + patrol_direction = 1; + current_path.clear(); + path_step_index = 0; + sleep_turns_remaining = 0; + path_provider.reset(); +} + // Thread-local random engine for behavior randomness static thread_local std::mt19937 rng{std::random_device{}()}; @@ -226,78 +239,28 @@ static BehaviorOutput executeSleep(UIEntity& entity, UIGrid& grid) { return {BehaviorResult::NO_ACTION, {}}; } -static BehaviorOutput executeSeek(UIEntity& entity, UIGrid& grid) { +// SEEK and FLEE share one implementation now: both delegate to the active +// PathProvider. FLEE differs only in which map is stored in the provider - +// DijkstraProvider over an inverted DijkstraMap descends away from the threat, +// which matches the old max-distance-neighbor behavior. +static BehaviorOutput executeProviderStep(UIEntity& entity, UIGrid& grid) { auto& behavior = entity.behavior; - - if (!behavior.dijkstra_map) { + if (!behavior.path_provider) { return {BehaviorResult::NO_ACTION, {}}; } - // Use Dijkstra map to find the lowest-distance neighbor (moving toward target) int cx = entity.cell_position.x; int cy = entity.cell_position.y; - float best_dist = std::numeric_limits::max(); - sf::Vector2i best_cell = {cx, cy}; - bool found = false; + bool ok = false; + sf::Vector2i next = behavior.path_provider->nextStep({cx, cy}, grid, &ok); - sf::Vector2i dirs[] = {{0, -1}, {0, 1}, {-1, 0}, {1, 0}, - {-1, -1}, {1, -1}, {-1, 1}, {1, 1}}; - - for (auto& dir : dirs) { - int nx = cx + dir.x; - int ny = cy + dir.y; - if (!isCellWalkable(grid, nx, ny)) continue; - - float dist = behavior.dijkstra_map->getDistance(nx, ny); - if (dist >= 0 && dist < best_dist) { - best_dist = dist; - best_cell = {nx, ny}; - found = true; - } - } - - if (!found || (best_cell.x == cx && best_cell.y == cy)) { + if (!ok) { return {BehaviorResult::BLOCKED, {cx, cy}}; } - - return {BehaviorResult::MOVED, best_cell}; -} - -static BehaviorOutput executeFlee(UIEntity& entity, UIGrid& grid) { - auto& behavior = entity.behavior; - - if (!behavior.dijkstra_map) { - return {BehaviorResult::NO_ACTION, {}}; - } - - // Use Dijkstra map to find the highest-distance neighbor (fleeing from target) - int cx = entity.cell_position.x; - int cy = entity.cell_position.y; - float best_dist = -1.0f; - sf::Vector2i best_cell = {cx, cy}; - bool found = false; - - sf::Vector2i dirs[] = {{0, -1}, {0, 1}, {-1, 0}, {1, 0}, - {-1, -1}, {1, -1}, {-1, 1}, {1, 1}}; - - for (auto& dir : dirs) { - int nx = cx + dir.x; - int ny = cy + dir.y; - if (!isCellWalkable(grid, nx, ny)) continue; - - float dist = behavior.dijkstra_map->getDistance(nx, ny); - if (dist >= 0 && dist > best_dist) { - best_dist = dist; - best_cell = {nx, ny}; - found = true; - } - } - - if (!found || (best_cell.x == cx && best_cell.y == cy)) { + if (next.x == cx && next.y == cy) { return {BehaviorResult::BLOCKED, {cx, cy}}; } - - return {BehaviorResult::MOVED, best_cell}; + return {BehaviorResult::MOVED, next}; } // ============================================================================= @@ -314,8 +277,8 @@ BehaviorOutput executeBehavior(UIEntity& entity, UIGrid& grid) { case BehaviorType::PATROL: return executePatrol(entity, grid); case BehaviorType::LOOP: return executeLoop(entity, grid); case BehaviorType::SLEEP: return executeSleep(entity, grid); - case BehaviorType::SEEK: return executeSeek(entity, grid); - case BehaviorType::FLEE: return executeFlee(entity, grid); + case BehaviorType::SEEK: return executeProviderStep(entity, grid); + case BehaviorType::FLEE: return executeProviderStep(entity, grid); } return {BehaviorResult::NO_ACTION, {}}; } diff --git a/src/EntityBehavior.h b/src/EntityBehavior.h index 8432683..5cf219a 100644 --- a/src/EntityBehavior.h +++ b/src/EntityBehavior.h @@ -8,6 +8,7 @@ class UIEntity; class UIGrid; class DijkstraMap; +class PathProvider; // ============================================================================= // BehaviorType - matches Python mcrfpy.Behavior enum values @@ -60,19 +61,11 @@ struct EntityBehavior { // Sleep data int sleep_turns_remaining = 0; - // Dijkstra map (for SEEK/FLEE) - std::shared_ptr dijkstra_map; + // SEEK/FLEE pathfinding strategy (#315). Nullptr means NO_ACTION. + std::unique_ptr path_provider; - void reset() { - type = BehaviorType::IDLE; - waypoints.clear(); - current_waypoint_index = 0; - patrol_direction = 1; - current_path.clear(); - path_step_index = 0; - sleep_turns_remaining = 0; - dijkstra_map = nullptr; - } + // Defined in EntityBehavior.cpp to avoid needing the full PathProvider type here. + void reset(); }; // ============================================================================= diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index 95a8d2e..e944be5 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -17,6 +17,7 @@ #include "PyMouseButton.h" #include "PyInputState.h" #include "PyPerspective.h" +#include "PyHeuristic.h" #include "PyBehavior.h" #include "PyTrigger.h" #include "UIGridView.h" @@ -793,6 +794,12 @@ PyObject* PyInit_mcrfpy() PyErr_Clear(); } + // Add Heuristic enum class for A* heuristic selection (#315) + PyObject* heuristic_class = PyHeuristic::create_enum_class(m); + if (!heuristic_class) { + PyErr_Clear(); + } + // Add Alignment enum class for automatic child positioning PyObject* alignment_class = PyAlignment::create_enum_class(m); if (!alignment_class) { diff --git a/src/PathProvider.cpp b/src/PathProvider.cpp new file mode 100644 index 0000000..d697b09 --- /dev/null +++ b/src/PathProvider.cpp @@ -0,0 +1,65 @@ +#include "PathProvider.h" +#include "UIGrid.h" +#include "UIGridPathfinding.h" +#include "UIGridPoint.h" + +static bool cellWalkable(UIGrid& grid, int x, int y) { + if (x < 0 || x >= grid.grid_w || y < 0 || y >= grid.grid_h) return false; + return grid.at(x, y).walkable; +} + +// ----------------------------------------------------------------------------- +// DijkstraProvider +// ----------------------------------------------------------------------------- +DijkstraProvider::DijkstraProvider(std::shared_ptr map) + : map_(std::move(map)) {} + +sf::Vector2i DijkstraProvider::nextStep(sf::Vector2i from, UIGrid& /*grid*/, bool* ok) { + if (!map_) { + if (ok) *ok = false; + return {-1, -1}; + } + bool valid = false; + sf::Vector2i step = map_->descentStep(from.x, from.y, &valid); + if (ok) *ok = valid; + return step; +} + +// ----------------------------------------------------------------------------- +// AStarProvider +// ----------------------------------------------------------------------------- +AStarProvider::AStarProvider(std::vector path) + : path_(std::move(path)) {} + +sf::Vector2i AStarProvider::nextStep(sf::Vector2i /*from*/, UIGrid& /*grid*/, bool* ok) { + if (index_ >= path_.size()) { + if (ok) *ok = false; + return {-1, -1}; + } + if (ok) *ok = true; + return path_[index_++]; +} + +// ----------------------------------------------------------------------------- +// TargetProvider +// ----------------------------------------------------------------------------- +TargetProvider::TargetProvider(sf::Vector2i target) + : target_(target) {} + +sf::Vector2i TargetProvider::nextStep(sf::Vector2i from, UIGrid& grid, bool* ok) { + int dx = target_.x - from.x; + int dy = target_.y - from.y; + if (dx == 0 && dy == 0) { + if (ok) *ok = false; + return {-1, -1}; // already at target + } + int sx = (dx > 0) ? 1 : ((dx < 0) ? -1 : 0); + int sy = (dy > 0) ? 1 : ((dy < 0) ? -1 : 0); + sf::Vector2i step{from.x + sx, from.y + sy}; + if (!cellWalkable(grid, step.x, step.y)) { + if (ok) *ok = false; + return {-1, -1}; + } + if (ok) *ok = true; + return step; +} diff --git a/src/PathProvider.h b/src/PathProvider.h new file mode 100644 index 0000000..9ccc5c9 --- /dev/null +++ b/src/PathProvider.h @@ -0,0 +1,64 @@ +#pragma once +#include "Common.h" +#include +#include + +class UIGrid; +class DijkstraMap; + +// ============================================================================= +// PathProvider (#315) - strategy interface for "what's my next cell?" +// +// EntityBehavior's SEEK/FLEE execute step() by asking the active PathProvider +// for a single cell. Three concrete providers satisfy every pathfinding shape +// the engine exposes today. +// ============================================================================= +class PathProvider { +public: + virtual ~PathProvider() = default; + + // Return the next cell to step to. Sets *ok=true on a valid step, false + // otherwise. The provider is responsible for walkability checks - + // DijkstraProvider relies on the TCOD map used at compute time, + // TargetProvider re-queries the live grid, AStarProvider trusts the + // pre-computed path. + virtual sf::Vector2i nextStep(sf::Vector2i from, UIGrid& grid, bool* ok) = 0; + + // Hint for providers that hold iteration state (currently only A*). + virtual void reset() {} +}; + +// Descend a precomputed DijkstraMap. For SEEK, pass the map as-is; for FLEE, +// pass DijkstraMap.inverted(). +class DijkstraProvider : public PathProvider { +public: + explicit DijkstraProvider(std::shared_ptr map); + sf::Vector2i nextStep(sf::Vector2i from, UIGrid& grid, bool* ok) override; + +private: + std::shared_ptr map_; +}; + +// Replay a pre-computed A* path. +class AStarProvider : public PathProvider { +public: + explicit AStarProvider(std::vector path); + sf::Vector2i nextStep(sf::Vector2i from, UIGrid& grid, bool* ok) override; + void reset() override { index_ = 0; } + +private: + std::vector path_; + size_t index_ = 0; +}; + +// Take a single Chebyshev step toward a fixed target cell. Used when full +// pathfinding is overkill (e.g. an adjacent target). Returns no-step if the +// straight-line neighbor is blocked - callers can treat that as BLOCKED. +class TargetProvider : public PathProvider { +public: + explicit TargetProvider(sf::Vector2i target); + sf::Vector2i nextStep(sf::Vector2i from, UIGrid& grid, bool* ok) override; + +private: + sf::Vector2i target_; +}; diff --git a/src/PyHeuristic.cpp b/src/PyHeuristic.cpp new file mode 100644 index 0000000..cf7872c --- /dev/null +++ b/src/PyHeuristic.cpp @@ -0,0 +1,142 @@ +#include "PyHeuristic.h" +#include +#include + +PyObject* PyHeuristic::heuristic_enum_class = nullptr; + +struct HeuristicEntry { + const char* name; + int value; +}; + +static const HeuristicEntry heuristic_table[] = { + {"EUCLIDEAN", 0}, + {"MANHATTAN", 1}, + {"CHEBYSHEV", 2}, + {"DIAGONAL", 3}, + {"ZERO", 4}, +}; + +static const int NUM_HEURISTIC_ENTRIES = + sizeof(heuristic_table) / sizeof(heuristic_table[0]); + +PyObject* PyHeuristic::create_enum_class(PyObject* module) { + std::ostringstream code; + code << "from enum import IntEnum\n\n"; + code << "class Heuristic(IntEnum):\n"; + code << " \"\"\"Built-in A* heuristic function selector.\n"; + code << " \n"; + code << " Values:\n"; + code << " EUCLIDEAN: sqrt((dx)^2 + (dy)^2). Admissible, default.\n"; + code << " MANHATTAN: |dx| + |dy|. Admissible on 4-connected grids.\n"; + code << " CHEBYSHEV: max(|dx|, |dy|). Admissible on 8-connected (diag=1).\n"; + code << " DIAGONAL: Octile distance. Admissible on 8-connected (diag=sqrt(2)).\n"; + code << " ZERO: Always returns 0. A* degenerates to Dijkstra.\n"; + code << " \"\"\"\n"; + for (int i = 0; i < NUM_HEURISTIC_ENTRIES; i++) { + code << " " << heuristic_table[i].name + << " = " << heuristic_table[i].value << "\n"; + } + code << "\n"; + code << "Heuristic.__hash__ = lambda self: hash(int(self))\n"; + code << "Heuristic.__repr__ = lambda self: f\"{type(self).__name__}.{self.name}\"\n"; + code << "Heuristic.__str__ = lambda self: self.name\n"; + + std::string code_str = code.str(); + + PyObject* globals = PyDict_New(); + if (!globals) return NULL; + PyDict_SetItemString(globals, "__builtins__", PyEval_GetBuiltins()); + + PyObject* locals = PyDict_New(); + if (!locals) { Py_DECREF(globals); return NULL; } + + PyObject* result = PyRun_String(code_str.c_str(), Py_file_input, globals, locals); + if (!result) { + Py_DECREF(globals); + Py_DECREF(locals); + return NULL; + } + Py_DECREF(result); + + PyObject* enum_class = PyDict_GetItemString(locals, "Heuristic"); + if (!enum_class) { + PyErr_SetString(PyExc_RuntimeError, "Failed to create Heuristic enum class"); + Py_DECREF(globals); + Py_DECREF(locals); + return NULL; + } + Py_INCREF(enum_class); + + heuristic_enum_class = enum_class; + Py_INCREF(heuristic_enum_class); + + if (PyModule_AddObject(module, "Heuristic", enum_class) < 0) { + Py_DECREF(enum_class); + Py_DECREF(globals); + Py_DECREF(locals); + heuristic_enum_class = nullptr; + return NULL; + } + + Py_DECREF(globals); + Py_DECREF(locals); + return enum_class; +} + +int PyHeuristic::from_arg(PyObject* arg, int* out_value) { + if (heuristic_enum_class && PyObject_IsInstance(arg, heuristic_enum_class)) { + PyObject* value = PyObject_GetAttrString(arg, "value"); + if (!value) return 0; + long val = PyLong_AsLong(value); + Py_DECREF(value); + if (val == -1 && PyErr_Occurred()) return 0; + if (val < 0 || val >= NUM_HEURISTIC_VALUES) { + PyErr_Format(PyExc_ValueError, "Invalid Heuristic value: %ld", val); + return 0; + } + *out_value = static_cast(val); + return 1; + } + + if (PyLong_Check(arg)) { + long val = PyLong_AsLong(arg); + if (val == -1 && PyErr_Occurred()) return 0; + if (val < 0 || val >= NUM_HEURISTIC_VALUES) { + PyErr_Format(PyExc_ValueError, + "Invalid Heuristic value: %ld. Must be 0..4.", val); + return 0; + } + *out_value = static_cast(val); + return 1; + } + + if (PyUnicode_Check(arg)) { + const char* name = PyUnicode_AsUTF8(arg); + if (!name) return 0; + for (int i = 0; i < NUM_HEURISTIC_ENTRIES; i++) { + if (strcmp(name, heuristic_table[i].name) == 0) { + *out_value = heuristic_table[i].value; + return 1; + } + } + PyErr_Format(PyExc_ValueError, + "Unknown Heuristic: '%s'. Use EUCLIDEAN, MANHATTAN, CHEBYSHEV, DIAGONAL, or ZERO.", name); + return 0; + } + + PyErr_SetString(PyExc_TypeError, + "Heuristic must be mcrfpy.Heuristic enum member, string, or int"); + return 0; +} + +TCOD_heuristic_func_t PyHeuristic::get_function(int heuristic_value) { + switch (heuristic_value) { + case EUCLIDEAN: return TCOD_heuristic_euclidean; + case MANHATTAN: return TCOD_heuristic_manhattan; + case CHEBYSHEV: return TCOD_heuristic_chebyshev; + case DIAGONAL: return TCOD_heuristic_diagonal; + case ZERO: return TCOD_heuristic_zero; + default: return nullptr; + } +} diff --git a/src/PyHeuristic.h b/src/PyHeuristic.h new file mode 100644 index 0000000..2227095 --- /dev/null +++ b/src/PyHeuristic.h @@ -0,0 +1,39 @@ +#pragma once +#include "Common.h" +#include "Python.h" +#include +#include + +// Module-level Heuristic enum class (created at runtime using Python's IntEnum) +// Stored as a module attribute: mcrfpy.Heuristic +// +// Values: +// EUCLIDEAN = 0 (admissible, default, slowest-optimal) +// MANHATTAN = 1 (admissible on 4-connected) +// CHEBYSHEV = 2 (admissible on 8-connected, diag cost 1) +// DIAGONAL = 3 (octile, admissible on 8-connected, diag cost sqrt(2)) +// ZERO = 4 (A* degenerates to Dijkstra) +class PyHeuristic { +public: + // Create the Heuristic enum class and add to module. + static PyObject* create_enum_class(PyObject* module); + + // Helper to extract a Heuristic value from a Python arg. + // Accepts Heuristic enum member, string (enum name), or int 0..4. + // Returns 1 on success, 0 on error (with exception set). + static int from_arg(PyObject* arg, int* out_value); + + // Returns the libtcod built-in heuristic function pointer for a given value. + // Returns nullptr if value is invalid. + static TCOD_heuristic_func_t get_function(int heuristic_value); + + // Cached reference to the Heuristic enum class for fast type checking. + static PyObject* heuristic_enum_class; + + static const int NUM_HEURISTIC_VALUES = 5; + static const int EUCLIDEAN = 0; + static const int MANHATTAN = 1; + static const int CHEBYSHEV = 2; + static const int DIAGONAL = 3; + static const int ZERO = 4; +}; diff --git a/src/UIEntity.cpp b/src/UIEntity.cpp index a4173d1..af53756 100644 --- a/src/UIEntity.cpp +++ b/src/UIEntity.cpp @@ -2,6 +2,7 @@ #include "UIGrid.h" #include "UIGridView.h" // #252: Entity.grid accepts GridView #include "UIGridPathfinding.h" +#include "PathProvider.h" #include "McRFPy_API.h" #include #include @@ -1512,14 +1513,16 @@ int UIEntity::set_sight_radius(PyUIEntityObject* self, PyObject* value, void* cl } PyObject* UIEntity::py_set_behavior(PyUIEntityObject* self, PyObject* args, PyObject* kwds) { - static const char* kwlist[] = {"type", "waypoints", "turns", "path", nullptr}; + static const char* kwlist[] = {"type", "waypoints", "turns", "path", "pathfinder", nullptr}; int type_val = 0; PyObject* waypoints_obj = nullptr; int turns = 0; PyObject* path_obj = nullptr; + PyObject* pathfinder_obj = nullptr; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "i|OiO", const_cast(kwlist), - &type_val, &waypoints_obj, &turns, &path_obj)) { + if (!PyArg_ParseTupleAndKeywords(args, kwds, "i|OiOO", const_cast(kwlist), + &type_val, &waypoints_obj, &turns, &path_obj, + &pathfinder_obj)) { return NULL; } @@ -1582,6 +1585,35 @@ PyObject* UIEntity::py_set_behavior(PyUIEntityObject* self, PyObject* args, PyOb behavior.sleep_turns_remaining = turns; } + // Parse pathfinder (#315): DijkstraMap, AStarPath, or (x, y) target tuple. + if (pathfinder_obj && pathfinder_obj != Py_None) { + if (PyObject_IsInstance(pathfinder_obj, (PyObject*)&mcrfpydef::PyDijkstraMapType)) { + auto* dmap = (PyDijkstraMapObject*)pathfinder_obj; + if (!dmap->data) { + PyErr_SetString(PyExc_RuntimeError, "pathfinder: DijkstraMap is invalid"); + return NULL; + } + behavior.path_provider = std::make_unique(dmap->data); + } else if (PyObject_IsInstance(pathfinder_obj, (PyObject*)&mcrfpydef::PyAStarPathType)) { + auto* apath = (PyAStarPathObject*)pathfinder_obj; + // Copy remaining steps - the provider owns its own iteration state. + std::vector steps( + apath->path.begin() + apath->current_index, + apath->path.end()); + behavior.path_provider = std::make_unique(std::move(steps)); + } else if (PyTuple_Check(pathfinder_obj) && PyTuple_Size(pathfinder_obj) == 2) { + long tx = PyLong_AsLong(PyTuple_GetItem(pathfinder_obj, 0)); + long ty = PyLong_AsLong(PyTuple_GetItem(pathfinder_obj, 1)); + if (PyErr_Occurred()) return NULL; + behavior.path_provider = std::make_unique( + sf::Vector2i(static_cast(tx), static_cast(ty))); + } else { + PyErr_SetString(PyExc_TypeError, + "pathfinder must be a DijkstraMap, AStarPath, or (x, y) tuple"); + return NULL; + } + } + Py_RETURN_NONE; } diff --git a/src/UIGridPathfinding.cpp b/src/UIGridPathfinding.cpp index 7a19813..59baa6f 100644 --- a/src/UIGridPathfinding.cpp +++ b/src/UIGridPathfinding.cpp @@ -5,49 +5,72 @@ #include "McRFPy_API.h" #include "PyHeightMap.h" #include "PyPositionHelper.h" +#include "PyHeuristic.h" +#include "PyDiscreteMap.h" //============================================================================= // DijkstraMap Implementation //============================================================================= DijkstraMap::DijkstraMap(TCODMap* map, int root_x, int root_y, float diag_cost) - : tcod_map(map) + : tcod_dijkstra(nullptr) + , tcod_map(map) , root(root_x, root_y) , diagonal_cost(diag_cost) , map_width(map ? map->getWidth() : 0) , map_height(map ? map->getHeight() : 0) { - tcod_dijkstra = new TCODDijkstra(tcod_map, diagonal_cost); - tcod_dijkstra->compute(root_x, root_y); // Compute immediately at creation + roots.push_back(sf::Vector2i(root_x, root_y)); + if (tcod_map) { + tcod_dijkstra = TCOD_dijkstra_new(tcod_map->data, diagonal_cost); + TCOD_dijkstra_compute(tcod_dijkstra, root_x, root_y); + } +} + +DijkstraMap::DijkstraMap(TCODMap* map, const std::vector& roots_in, float diag_cost) + : tcod_dijkstra(nullptr) + , tcod_map(map) + , root(roots_in.empty() ? sf::Vector2i(-1, -1) : roots_in.front()) + , roots(roots_in) + , diagonal_cost(diag_cost) + , map_width(map ? map->getWidth() : 0) + , map_height(map ? map->getHeight() : 0) +{ + if (!tcod_map || roots.empty()) return; + + tcod_dijkstra = TCOD_dijkstra_new(tcod_map->data, diagonal_cost); + + if (roots.size() == 1) { + TCOD_dijkstra_compute(tcod_dijkstra, roots[0].x, roots[0].y); + } else { + std::vector xs, ys; + xs.reserve(roots.size()); + ys.reserve(roots.size()); + for (auto& r : roots) { xs.push_back(r.x); ys.push_back(r.y); } + TCOD_dijkstra_compute_multi(tcod_dijkstra, + static_cast(roots.size()), xs.data(), ys.data()); + } } DijkstraMap::~DijkstraMap() { if (tcod_dijkstra) { - delete tcod_dijkstra; + TCOD_dijkstra_delete(tcod_dijkstra); tcod_dijkstra = nullptr; } } float DijkstraMap::getDistance(int x, int y) const { if (!tcod_dijkstra) return -1.0f; - return tcod_dijkstra->getDistance(x, y); -} - -int DijkstraMap::getWidth() const { - return map_width; -} - -int DijkstraMap::getHeight() const { - return map_height; + return TCOD_dijkstra_get_distance(tcod_dijkstra, x, y); } std::vector DijkstraMap::getPathFrom(int x, int y) const { std::vector path; if (!tcod_dijkstra) return path; - if (tcod_dijkstra->setPath(x, y)) { + if (TCOD_dijkstra_path_set(tcod_dijkstra, x, y)) { int px, py; - while (tcod_dijkstra->walk(&px, &py)) { + while (TCOD_dijkstra_path_walk(tcod_dijkstra, &px, &py)) { path.push_back(sf::Vector2i(px, py)); } } @@ -60,22 +83,47 @@ sf::Vector2i DijkstraMap::stepFrom(int x, int y, bool* valid) const { return sf::Vector2i(-1, -1); } - if (!tcod_dijkstra->setPath(x, y)) { + if (!TCOD_dijkstra_path_set(tcod_dijkstra, x, y)) { if (valid) *valid = false; return sf::Vector2i(-1, -1); } int px, py; - if (tcod_dijkstra->walk(&px, &py)) { + if (TCOD_dijkstra_path_walk(tcod_dijkstra, &px, &py)) { if (valid) *valid = true; return sf::Vector2i(px, py); } - // At root or no path if (valid) *valid = false; return sf::Vector2i(-1, -1); } +void DijkstraMap::invertInPlace() { + if (tcod_dijkstra) { + TCOD_dijkstra_invert(tcod_dijkstra); + } +} + +std::shared_ptr DijkstraMap::inverted() const { + // Recompute from the stored roots, then invert. This preserves the invariant that + // the original's distance field is unchanged. + auto copy = std::make_shared(tcod_map, roots, diagonal_cost); + copy->invertInPlace(); + return copy; +} + +sf::Vector2i DijkstraMap::descentStep(int x, int y, bool* valid) const { + if (!tcod_dijkstra) { + if (valid) *valid = false; + return sf::Vector2i(-1, -1); + } + int out_x = -1, out_y = -1; + bool ok = TCOD_dijkstra_get_descent(tcod_dijkstra, x, y, &out_x, &out_y); + if (valid) *valid = ok; + if (!ok) return sf::Vector2i(-1, -1); + return sf::Vector2i(out_x, out_y); +} + //============================================================================= // Helper Functions //============================================================================= @@ -405,6 +453,48 @@ PyObject* UIGridPathfinding::DijkstraMap_get_root(PyDijkstraMapObject* self, voi return PyVector(sf::Vector2f(static_cast(root.x), static_cast(root.y))).pyObject(); } +PyObject* UIGridPathfinding::DijkstraMap_invert(PyDijkstraMapObject* self, PyObject* args) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "DijkstraMap is invalid"); + return nullptr; + } + auto new_map = self->data->inverted(); + if (!new_map) { + PyErr_SetString(PyExc_RuntimeError, "invert() failed"); + return nullptr; + } + PyDijkstraMapObject* result = (PyDijkstraMapObject*)mcrfpydef::PyDijkstraMapType.tp_alloc( + &mcrfpydef::PyDijkstraMapType, 0); + if (!result) return nullptr; + new (&result->data) std::shared_ptr(new_map); + return (PyObject*)result; +} + +PyObject* UIGridPathfinding::DijkstraMap_descent_step(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"pos", nullptr}; + PyObject* pos_obj = nullptr; + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O", const_cast(kwlist), &pos_obj)) { + return nullptr; + } + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "DijkstraMap is invalid"); + return nullptr; + } + int x, y; + if (!ExtractPosition(pos_obj, &x, &y, nullptr, "pos")) { + return nullptr; + } + if (!dijkstra_bounds_check(self->data.get(), x, y)) return nullptr; + + bool valid = false; + sf::Vector2i step = self->data->descentStep(x, y, &valid); + if (!valid) { + Py_RETURN_NONE; + } + return PyVector(sf::Vector2f(static_cast(step.x), + static_cast(step.y))).pyObject(); +} + PyObject* UIGridPathfinding::DijkstraMap_to_heightmap(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds) { static const char* kwlist[] = {"size", "unreachable", nullptr}; PyObject* size_obj = nullptr; @@ -520,14 +610,18 @@ static void restoreCollisionLabel(GridData* grid, } PyObject* UIGridPathfinding::Grid_find_path(PyUIGridObject* self, PyObject* args, PyObject* kwds) { - static const char* kwlist[] = {"start", "end", "diagonal_cost", "collide", NULL}; + static const char* kwlist[] = {"start", "end", "diagonal_cost", "collide", + "heuristic", "weight", NULL}; PyObject* start_obj = NULL; PyObject* end_obj = NULL; float diagonal_cost = 1.41f; const char* collide_label = NULL; + PyObject* heuristic_obj = NULL; + float heuristic_weight = 1.0f; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|fz", const_cast(kwlist), - &start_obj, &end_obj, &diagonal_cost, &collide_label)) { + if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|fzOf", const_cast(kwlist), + &start_obj, &end_obj, &diagonal_cost, &collide_label, + &heuristic_obj, &heuristic_weight)) { return NULL; } @@ -536,6 +630,11 @@ PyObject* UIGridPathfinding::Grid_find_path(PyUIGridObject* self, PyObject* args return NULL; } + if (heuristic_weight <= 0.0f) { + PyErr_SetString(PyExc_ValueError, "weight must be positive"); + return NULL; + } + int x1, y1, x2, y2; if (!ExtractPosition(start_obj, &x1, &y1, self->data.get(), "start")) { return NULL; @@ -551,51 +650,159 @@ PyObject* UIGridPathfinding::Grid_find_path(PyUIGridObject* self, PyObject* args return NULL; } + // Resolve heuristic selection before any allocations so we fail fast on bad args. + TCOD_heuristic_func_t heuristic_func = nullptr; + if (heuristic_obj && heuristic_obj != Py_None) { + int hval = 0; + if (!PyHeuristic::from_arg(heuristic_obj, &hval)) { + return NULL; + } + heuristic_func = PyHeuristic::get_function(hval); + } + // 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); - bool found = tcod_path.compute(x1, y1, x2, y2); + TCODMap* tcmap = self->data->getTCODMap(); + + // Build path handle. Use C API so we can set the heuristic/weight when requested. + TCOD_path_t tcod_path = TCOD_path_new_using_map(tcmap->data, diagonal_cost); + if (heuristic_func || heuristic_weight != 1.0f) { + // Passing null heuristic_func keeps the default (Euclidean) while still allowing + // weight override; non-null installs the chosen built-in. + TCOD_path_set_heuristic(tcod_path, heuristic_func, heuristic_weight); + } + + bool found = TCOD_path_compute(tcod_path, x1, y1, x2, y2); // Restore walkability before returning restoreCollisionLabel(self->data.get(), restore_list); if (!found) { - Py_RETURN_NONE; // No path exists + TCOD_path_delete(tcod_path); + Py_RETURN_NONE; } - // Create AStarPath result object PyAStarPathObject* result = (PyAStarPathObject*)mcrfpydef::PyAStarPathType.tp_alloc( &mcrfpydef::PyAStarPathType, 0); - if (!result) return NULL; + if (!result) { + TCOD_path_delete(tcod_path); + return NULL; + } - // Initialize new (&result->path) std::vector(); result->current_index = 0; result->origin = sf::Vector2i(x1, y1); result->destination = sf::Vector2i(x2, y2); - // Copy path data - result->path.reserve(tcod_path.size()); - for (int i = 0; i < tcod_path.size(); i++) { + int size = TCOD_path_size(tcod_path); + result->path.reserve(size); + for (int i = 0; i < size; i++) { int px, py; - tcod_path.get(i, &px, &py); + TCOD_path_get(tcod_path, i, &px, &py); result->path.push_back(sf::Vector2i(px, py)); } + TCOD_path_delete(tcod_path); return (PyObject*)result; } +// Collect roots from a Python object, which may be: +// - a single (x,y) (tuple/list/Vector/Entity) +// - a list/iterable of (x,y) positions +// - a DiscreteMap mask (non-zero cells become roots) +// Returns true on success; populates `out_roots` and `out_mask_used`. +// If a DiscreteMap mask is used, caller should prefer the masked C path. +static bool collectRoots(PyObject* root_obj, UIGrid* grid, + std::vector* out_roots, + PyDiscreteMapObject** out_mask) +{ + out_roots->clear(); + if (out_mask) *out_mask = nullptr; + + // DiscreteMap mask path + if (PyObject_IsInstance(root_obj, (PyObject*)&mcrfpydef::PyDiscreteMapType)) { + auto* dmap = (PyDiscreteMapObject*)root_obj; + if (!dmap->data) { + PyErr_SetString(PyExc_RuntimeError, "DiscreteMap is invalid"); + return false; + } + if (dmap->data->width() != grid->grid_w || dmap->data->height() != grid->grid_h) { + PyErr_Format(PyExc_ValueError, + "DiscreteMap size (%dx%d) does not match grid size (%dx%d)", + dmap->data->width(), dmap->data->height(), + grid->grid_w, grid->grid_h); + return false; + } + if (out_mask) *out_mask = dmap; + return true; + } + + // Single position path (Vector / Entity / (x,y) tuple): ExtractPosition accepts these. + int x = 0, y = 0; + if (UIGridPathfinding::ExtractPosition(root_obj, &x, &y, grid, "root")) { + if (x < 0 || x >= grid->grid_w || y < 0 || y >= grid->grid_h) { + PyErr_SetString(PyExc_ValueError, "Root position out of grid bounds"); + return false; + } + out_roots->push_back(sf::Vector2i(x, y)); + return true; + } + // ExtractPosition set an error - clear it only if we still have an iterable to try. + if (!PyErr_ExceptionMatches(PyExc_TypeError)) { + return false; + } + PyErr_Clear(); + + // List/iterable of positions + if (PySequence_Check(root_obj) || PyIter_Check(root_obj)) { + PyObject* iter = PyObject_GetIter(root_obj); + if (!iter) { + PyErr_SetString(PyExc_TypeError, + "roots must be (x,y), a sequence of (x,y), or a DiscreteMap mask"); + return false; + } + PyObject* item; + while ((item = PyIter_Next(iter)) != NULL) { + int rx = 0, ry = 0; + if (!UIGridPathfinding::ExtractPosition(item, &rx, &ry, grid, "root")) { + Py_DECREF(item); + Py_DECREF(iter); + return false; + } + Py_DECREF(item); + if (rx < 0 || rx >= grid->grid_w || ry < 0 || ry >= grid->grid_h) { + Py_DECREF(iter); + PyErr_Format(PyExc_ValueError, + "Root (%d,%d) out of grid bounds", rx, ry); + return false; + } + out_roots->push_back(sf::Vector2i(rx, ry)); + } + Py_DECREF(iter); + if (PyErr_Occurred()) return false; + if (out_roots->empty()) { + PyErr_SetString(PyExc_ValueError, "roots sequence is empty"); + return false; + } + return true; + } + + PyErr_SetString(PyExc_TypeError, + "roots must be (x,y), a sequence of (x,y), or a DiscreteMap mask"); + return false; +} + PyObject* UIGridPathfinding::Grid_get_dijkstra_map(PyUIGridObject* self, PyObject* args, PyObject* kwds) { - static const char* kwlist[] = {"root", "diagonal_cost", "collide", NULL}; + static const char* kwlist[] = {"root", "diagonal_cost", "collide", "roots", NULL}; PyObject* root_obj = NULL; + PyObject* roots_obj = NULL; float diagonal_cost = 1.41f; const char* collide_label = NULL; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|fz", const_cast(kwlist), - &root_obj, &diagonal_cost, &collide_label)) { + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OfzO", const_cast(kwlist), + &root_obj, &diagonal_cost, &collide_label, &roots_obj)) { return NULL; } @@ -604,50 +811,82 @@ PyObject* UIGridPathfinding::Grid_get_dijkstra_map(PyUIGridObject* self, PyObjec return NULL; } - int root_x, root_y; - if (!ExtractPosition(root_obj, &root_x, &root_y, self->data.get(), "root")) { + // Accept either `root=` (back-compat, also accepts multi-input now) or `roots=`. + PyObject* input_obj = roots_obj ? roots_obj : root_obj; + if (!input_obj) { + PyErr_SetString(PyExc_TypeError, + "get_dijkstra_map() requires 'root' or 'roots' argument"); + return NULL; + } + if (roots_obj && root_obj) { + PyErr_SetString(PyExc_TypeError, + "get_dijkstra_map(): pass 'root' or 'roots', not both"); return NULL; } - // Bounds check - if (root_x < 0 || root_x >= self->data->grid_w || root_y < 0 || root_y >= self->data->grid_h) { - PyErr_SetString(PyExc_ValueError, "Root position out of grid bounds"); + std::vector roots; + PyDiscreteMapObject* mask_obj = nullptr; + if (!collectRoots(input_obj, self->data.get(), &roots, &mask_obj)) { return NULL; } 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 - if (std::abs(it->second->getDiagonalCost() - diagonal_cost) < 0.001f) { - // Return existing - PyDijkstraMapObject* result = (PyDijkstraMapObject*)mcrfpydef::PyDijkstraMapType.tp_alloc( - &mcrfpydef::PyDijkstraMapType, 0); - if (!result) return NULL; - new (&result->data) std::shared_ptr(it->second); - return (PyObject*)result; + // Cache path for the common single-root case (preserves prior behavior). + if (!mask_obj && roots.size() == 1) { + auto key = std::make_tuple(roots[0].x, roots[0].y, label_str); + auto it = self->data->dijkstra_maps.find(key); + if (it != self->data->dijkstra_maps.end()) { + if (std::abs(it->second->getDiagonalCost() - diagonal_cost) < 0.001f) { + PyDijkstraMapObject* result = (PyDijkstraMapObject*)mcrfpydef::PyDijkstraMapType.tp_alloc( + &mcrfpydef::PyDijkstraMapType, 0); + if (!result) return NULL; + new (&result->data) std::shared_ptr(it->second); + return (PyObject*)result; + } + self->data->dijkstra_maps.erase(it); } - // Different diagonal cost - remove old one - 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); + std::shared_ptr dijkstra; + TCODMap* tcmap = self->data->getTCODMap(); + + if (mask_obj) { + // Translate mask -> explicit root list, then drive compute_multi. Distance-only + // results are identical to compute_masked; this keeps DijkstraMap's invariant + // that it always holds exactly one computed TCOD_Dijkstra handle. + std::vector mask_roots; + const uint8_t* buf = mask_obj->data->data(); + int w = mask_obj->data->width(); + int h = mask_obj->data->height(); + mask_roots.reserve(static_cast(w) * 4); // heuristic + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + if (buf[y * w + x] != 0) { + mask_roots.push_back(sf::Vector2i(x, y)); + } + } + } + if (mask_roots.empty()) { + restoreCollisionLabel(self->data.get(), restore_list); + PyErr_SetString(PyExc_ValueError, "DiscreteMap mask has no non-zero cells"); + return NULL; + } + dijkstra = std::make_shared(tcmap, mask_roots, diagonal_cost); + } else { + dijkstra = std::make_shared(tcmap, roots, diagonal_cost); + } - // Restore walkability restoreCollisionLabel(self->data.get(), restore_list); - // Cache it - self->data->dijkstra_maps[key] = dijkstra; + // Cache only single-root case + if (!mask_obj && roots.size() == 1) { + auto key = std::make_tuple(roots[0].x, roots[0].y, label_str); + self->data->dijkstra_maps[key] = dijkstra; + } - // Return Python wrapper PyDijkstraMapObject* result = (PyDijkstraMapObject*)mcrfpydef::PyDijkstraMapType.tp_alloc( &mcrfpydef::PyDijkstraMapType, 0); if (!result) return NULL; @@ -800,6 +1039,27 @@ PyMethodDef PyDijkstraMap_methods[] = { "Returns:\n" " HeightMap with distance values as heights."}, + {"invert", (PyCFunction)UIGridPathfinding::DijkstraMap_invert, METH_NOARGS, + "invert() -> DijkstraMap\n\n" + "Return a NEW DijkstraMap whose distance field is the safety field.\n\n" + "Cells near a root become high values and cells far from any root become\n" + "low values. Combined with step_from or descent_step, this gives flee\n" + "behavior: descend the inverted map to move away from the original roots.\n\n" + "The original DijkstraMap is unchanged.\n\n" + "Returns:\n" + " New DijkstraMap with inverted distances."}, + + {"descent_step", (PyCFunction)UIGridPathfinding::DijkstraMap_descent_step, METH_VARARGS | METH_KEYWORDS, + "descent_step(pos) -> Vector | None\n\n" + "Get the adjacent cell with the lowest distance (steepest descent).\n\n" + "Unlike step_from (which follows the path set by path_from), descent_step\n" + "always returns the best neighbor in a single hop. Useful for AI that\n" + "reacts to the current distance field rather than following a fixed path.\n\n" + "Args:\n" + " pos: Current position as Vector, Entity, or (x, y) tuple.\n\n" + "Returns:\n" + " Next position as Vector, or None if pos is a local minimum or off-grid."}, + {NULL} }; diff --git a/src/UIGridPathfinding.h b/src/UIGridPathfinding.h index 89610c8..28e0b92 100644 --- a/src/UIGridPathfinding.h +++ b/src/UIGridPathfinding.h @@ -28,10 +28,15 @@ struct PyAStarPathObject { class DijkstraMap { public: + // Single-root construction (back-compat). DijkstraMap(TCODMap* map, int root_x, int root_y, float diagonal_cost); + + // Multi-root construction (#315). roots must be non-empty. + DijkstraMap(TCODMap* map, const std::vector& roots, float diagonal_cost); + ~DijkstraMap(); - // Non-copyable (owns TCODDijkstra) + // Non-copyable (owns TCOD_Dijkstra) DijkstraMap(const DijkstraMap&) = delete; DijkstraMap& operator=(const DijkstraMap&) = delete; @@ -40,18 +45,35 @@ public: std::vector getPathFrom(int x, int y) const; sf::Vector2i stepFrom(int x, int y, bool* valid = nullptr) const; + // Phase B: FLEE primitives (#315) + // invertInPlace() mutates this map's distance field. Prefer inverted() in new code — + // the Python surface exposes the non-mutating form to keep maps immutable after + // creation. + void invertInPlace(); + // Returns a freshly computed DijkstraMap with the same roots and diagonal_cost, + // then inverts its distance field. The caller owns the returned shared_ptr. + std::shared_ptr inverted() const; + // descent_step returns the next cell along steepest descent, or (-1,-1) + valid=false. + sf::Vector2i descentStep(int x, int y, bool* valid = nullptr) const; + // Accessors - sf::Vector2i getRoot() const { return root; } + sf::Vector2i getRoot() const { return root; } // First root for multi-root + const std::vector& getRoots() const { return roots; } + bool isMultiRoot() const { return roots.size() > 1; } float getDiagonalCost() const { return diagonal_cost; } - int getWidth() const; - int getHeight() const; + int getWidth() const { return map_width; } + int getHeight() const { return map_height; } + + // Raw C handle, for internal use in new constructor paths (e.g. from_invert). + TCOD_Dijkstra* getHandle() const { return tcod_dijkstra; } private: - TCODDijkstra* tcod_dijkstra; // Owned by this object - TCODMap* tcod_map; // Borrowed from Grid + TCOD_Dijkstra* tcod_dijkstra; // Owned + TCODMap* tcod_map; // Borrowed from Grid sf::Vector2i root; + std::vector roots; float diagonal_cost; - int map_width; // Cached from TCODMap at construction + int map_width; int map_height; }; @@ -110,6 +132,8 @@ namespace UIGridPathfinding { PyObject* DijkstraMap_path_from(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds); PyObject* DijkstraMap_step_from(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds); PyObject* DijkstraMap_to_heightmap(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds); + PyObject* DijkstraMap_invert(PyDijkstraMapObject* self, PyObject* args); + PyObject* DijkstraMap_descent_step(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds); // Properties PyObject* DijkstraMap_get_root(PyDijkstraMapObject* self, void* closure); diff --git a/tests/benchmarks/baseline/phase5_2/dijkstra_bench.json b/tests/benchmarks/baseline/phase5_2/dijkstra_bench.json index 018361d..74521b1 100644 --- a/tests/benchmarks/baseline/phase5_2/dijkstra_bench.json +++ b/tests/benchmarks/baseline/phase5_2/dijkstra_bench.json @@ -4,82 +4,82 @@ "grid": "100x100", "kind": "multi_root", "roots": 1, - "mean_ms": 0.7709094556048512 + "mean_ms": 0.6948539987206459 }, { "grid": "100x100", "kind": "multi_root", "roots": 2, - "mean_ms": 0.7632468361407518 + "mean_ms": 0.8002225775271654 }, { "grid": "100x100", "kind": "multi_root", "roots": 5, - "mean_ms": 1.200081780552864 + "mean_ms": 1.1821302119642496 }, { "grid": "100x100", "kind": "multi_root", "roots": 20, - "mean_ms": 2.137616788968444 + "mean_ms": 2.0206935703754425 }, { "grid": "100x100", "kind": "mask", "roots": 500, - "mean_ms": 30.424197972752154 + "mean_ms": 24.073211615905166 }, { "grid": "100x100", "kind": "invert", - "mean_ms": 1.0323396185413003 + "mean_ms": 0.7887090090662241 }, { "grid": "100x100", "kind": "descent_step_per_call", - "mean_us": 0.4075700417160988, + "mean_us": 0.3262499812990427, "valid_per_trial": 100 }, { "grid": "500x500", "kind": "multi_root", "roots": 1, - "mean_ms": 26.075413217768073 + "mean_ms": 20.39959062822163 }, { "grid": "500x500", "kind": "multi_root", "roots": 2, - "mean_ms": 25.83242394030094 + "mean_ms": 22.81180394347757 }, { "grid": "500x500", "kind": "multi_root", "roots": 5, - "mean_ms": 33.73005616012961 + "mean_ms": 29.194996040314436 }, { "grid": "500x500", "kind": "multi_root", "roots": 20, - "mean_ms": 78.58918677084148 + "mean_ms": 67.84450197592378 }, { "grid": "500x500", "kind": "mask", "roots": 12500, - "mean_ms": 18658.679087948985 + "mean_ms": 19711.241053813137 }, { "grid": "500x500", "kind": "invert", - "mean_ms": 25.918347598053515 + "mean_ms": 27.875673002563417 }, { "grid": "500x500", "kind": "descent_step_per_call", - "mean_us": 0.3717193566262722, + "mean_us": 0.3495373670011759, "valid_per_trial": 2500 } ] diff --git a/tests/benchmarks/baseline/phase5_2/grid_step_bench.json b/tests/benchmarks/baseline/phase5_2/grid_step_bench.json index 8565bb3..e7c96fe 100644 --- a/tests/benchmarks/baseline/phase5_2/grid_step_bench.json +++ b/tests/benchmarks/baseline/phase5_2/grid_step_bench.json @@ -2,8 +2,8 @@ "grid": "100x100", "entities": 100, "rounds": 1000, - "total_sec": 0.07824495097156614, - "mean_round_ms": 0.07824495097156614, - "p95_round_ms": 0.1227830071002245, - "per_entity_step_us": 0.7824495097156614 + "total_sec": 0.06852402002550662, + "mean_round_ms": 0.06852402002550662, + "p95_round_ms": 0.09302806574851274, + "per_entity_step_us": 0.6852402002550662 } \ No newline at end of file diff --git a/tests/benchmarks/baseline/phase5_2/gridview_render_bench.json b/tests/benchmarks/baseline/phase5_2/gridview_render_bench.json index 757efb2..b58bca5 100644 --- a/tests/benchmarks/baseline/phase5_2/gridview_render_bench.json +++ b/tests/benchmarks/baseline/phase5_2/gridview_render_bench.json @@ -4,31 +4,31 @@ "views": 1, "frames": 60, "warmup_frames": 5, - "total_sec": 4.710599604761228, - "mean_frame_ms": 78.50999341268714, - "p95_frame_ms": 90.47448402270675, - "implied_fps": 12.737231994702995, - "per_view_frame_ms": 78.50999341268714 + "total_sec": 4.731390134897083, + "mean_frame_ms": 78.85650224828473, + "p95_frame_ms": 86.92639297805727, + "implied_fps": 12.681262438593032, + "per_view_frame_ms": 78.85650224828473 }, { "views": 2, "frames": 60, "warmup_frames": 5, - "total_sec": 4.6525509969796985, - "mean_frame_ms": 77.54251661632831, - "p95_frame_ms": 91.92000096663833, - "implied_fps": 12.896150958678424, - "per_view_frame_ms": 38.77125830816416 + "total_sec": 4.975782633875497, + "mean_frame_ms": 82.92971056459162, + "p95_frame_ms": 91.40842803753912, + "implied_fps": 12.058404559619538, + "per_view_frame_ms": 41.46485528229581 }, { "views": 4, "frames": 60, "warmup_frames": 5, - "total_sec": 4.727940998389386, - "mean_frame_ms": 78.7990166398231, - "p95_frame_ms": 99.88687501754612, - "implied_fps": 12.690513697281654, - "per_view_frame_ms": 19.699754159955774 + "total_sec": 4.861755113233812, + "mean_frame_ms": 81.0292518872302, + "p95_frame_ms": 90.46144201420248, + "implied_fps": 12.341222172354708, + "per_view_frame_ms": 20.25731297180755 } ], "config": { diff --git a/tests/demo/screens/pathfinding_demo.py b/tests/demo/screens/pathfinding_demo.py new file mode 100644 index 0000000..adfce1a --- /dev/null +++ b/tests/demo/screens/pathfinding_demo.py @@ -0,0 +1,290 @@ +"""pathfinding_demo.py - Visual demo of the #315 pathfinding primitives. + +Three panels side-by-side on one scene: + [Panel 1] A* with selectable heuristic. Keys 1-5 cycle EUCLIDEAN, MANHATTAN, + CHEBYSHEV, DIAGONAL, ZERO. Q/W bump the weight by 0.25. + [Panel 2] Dijkstra flood from a cursor-controlled root. Arrow keys move the + cursor; the distance field re-renders as a blue gradient. + [Panel 3] Multi-root FLEE: three "guard" entities flee from a shared set of + threats, using an inverted multi-root Dijkstra map. Animated one + step per frame tick. Press T to drop a new threat on the panel. + +Also exercises: DijkstraMap.invert(), DijkstraMap.descent_step(), +mcrfpy.Heuristic, Grid.find_path(heuristic=, weight=), Grid.get_dijkstra_map( +roots=...). +""" +import mcrfpy +import sys + + +GRID_W, GRID_H = 20, 20 +CELL_PX = 14 +PANEL_W_PX = GRID_W * CELL_PX +GAP = 20 + +SCREEN_W = 3 * PANEL_W_PX + 4 * GAP +SCREEN_H = GRID_H * CELL_PX + 140 + + +scene = mcrfpy.Scene("pathfinding_demo") + +bg = mcrfpy.Frame(pos=(0, 0), size=(SCREEN_W, SCREEN_H), + fill_color=mcrfpy.Color(18, 18, 26)) +scene.children.append(bg) + +title = mcrfpy.Caption(text="Pathfinding Next-Gen Demo (#315)", pos=(GAP, 10)) +title.fill_color = mcrfpy.Color(240, 240, 255) +scene.children.append(title) + + +def make_open_grid(x_off): + g = mcrfpy.Grid(grid_size=(GRID_W, GRID_H), + pos=(x_off, 50), + size=(PANEL_W_PX, GRID_H * CELL_PX)) + for y in range(GRID_H): + for x in range(GRID_W): + c = g.at(x, y) + wall = (x in (0, GRID_W - 1)) or (y in (0, GRID_H - 1)) + c.walkable = not wall + c.transparent = not wall + # A small wedge of walls in the middle for visual interest. + for y in range(4, 12): + g.at(GRID_W // 2, y).walkable = False + g.at(GRID_W // 2, y).transparent = False + scene.children.append(g) + return g + + +# ============================================================================= +# Panel 1: A* with heuristic switching +# ============================================================================= +g1 = make_open_grid(GAP) +astar_layer = mcrfpy.ColorLayer(z_index=1, name="astar_path") +g1.add_layer(astar_layer) + +HEURISTICS = [ + (mcrfpy.Heuristic.EUCLIDEAN, "EUCLIDEAN"), + (mcrfpy.Heuristic.MANHATTAN, "MANHATTAN"), + (mcrfpy.Heuristic.CHEBYSHEV, "CHEBYSHEV"), + (mcrfpy.Heuristic.DIAGONAL, "DIAGONAL"), + (mcrfpy.Heuristic.ZERO, "ZERO"), +] +state_astar = {"hidx": 0, "weight": 1.0, + "start": (2, 10), "end": (GRID_W - 3, 10)} + +cap_astar = mcrfpy.Caption(text="A* heuristic: EUCLIDEAN weight=1.00", + pos=(GAP, 50 + GRID_H * CELL_PX + 6)) +cap_astar.fill_color = mcrfpy.Color(180, 220, 255) +scene.children.append(cap_astar) + + +def redraw_astar(): + h, hname = HEURISTICS[state_astar["hidx"]] + astar_layer.fill(mcrfpy.Color(0, 0, 0, 0)) + p = g1.find_path(state_astar["start"], state_astar["end"], + heuristic=h, weight=state_astar["weight"]) + n_steps = 0 + if p is not None: + for step in p: + astar_layer.set((int(step.x), int(step.y)), + mcrfpy.Color(255, 220, 80, 220)) + n_steps += 1 + # Start/end markers. + astar_layer.set(state_astar["start"], mcrfpy.Color(80, 255, 120, 255)) + astar_layer.set(state_astar["end"], mcrfpy.Color(255, 90, 90, 255)) + cap_astar.text = (f"A* heuristic: {hname} " + f"weight={state_astar['weight']:.2f} steps={n_steps}") + + +# ============================================================================= +# Panel 2: Dijkstra flood +# ============================================================================= +g2 = make_open_grid(GAP * 2 + PANEL_W_PX) +dij_layer = mcrfpy.ColorLayer(z_index=1, name="dij_flood") +g2.add_layer(dij_layer) + +state_dij = {"root": (GRID_W // 2 - 3, GRID_H // 2)} + +cap_dij = mcrfpy.Caption(text="Dijkstra flood (arrows move root)", + pos=(GAP * 2 + PANEL_W_PX, 50 + GRID_H * CELL_PX + 6)) +cap_dij.fill_color = mcrfpy.Color(180, 255, 220) +scene.children.append(cap_dij) + + +def redraw_dijkstra(): + dij_layer.fill(mcrfpy.Color(0, 0, 0, 0)) + root = state_dij["root"] + if not g2.at(root[0], root[1]).walkable: + cap_dij.text = "Dijkstra root on wall - move with arrows" + return + dmap = g2.get_dijkstra_map(root) + # Sample distances to find the maximum for normalization. + max_dist = 0.0 + for y in range(1, GRID_H - 1): + for x in range(1, GRID_W - 1): + d = dmap.distance((x, y)) + if d is not None and d > max_dist: + max_dist = d + if max_dist <= 0: + return + for y in range(1, GRID_H - 1): + for x in range(1, GRID_W - 1): + d = dmap.distance((x, y)) + if d is None: + continue + t = min(1.0, d / max_dist) + # Cool gradient: near-root = bright, far = dark. + r = int(40 * t + 80 * (1 - t)) + gc = int(160 * (1 - t) + 40 * t) + bc = int(240 * (1 - t) + 80 * t) + dij_layer.set((x, y), mcrfpy.Color(r, gc, bc, 180)) + dij_layer.set(root, mcrfpy.Color(255, 255, 90, 255)) + cap_dij.text = f"Dijkstra flood max={max_dist:.1f} root={root}" + + +# ============================================================================= +# Panel 3: Multi-root FLEE +# ============================================================================= +g3 = make_open_grid(GAP * 3 + 2 * PANEL_W_PX) +flee_layer = mcrfpy.ColorLayer(z_index=1, name="flee_layer") +g3.add_layer(flee_layer) + +state_flee = { + "threats": [(3, 3), (GRID_W - 4, GRID_H - 4)], + "guards": [(10, 6), (10, 10), (10, 14)], + "safety": None, + "threat_map": None, +} + +cap_flee = mcrfpy.Caption(text="Multi-root FLEE (T adds threat, R resets)", + pos=(GAP * 3 + 2 * PANEL_W_PX, + 50 + GRID_H * CELL_PX + 6)) +cap_flee.fill_color = mcrfpy.Color(255, 190, 190) +scene.children.append(cap_flee) + + +def recompute_flee(): + state_flee["threat_map"] = g3.get_dijkstra_map(roots=state_flee["threats"]) + state_flee["safety"] = state_flee["threat_map"].invert() + + +def redraw_flee(): + flee_layer.fill(mcrfpy.Color(0, 0, 0, 0)) + # Threats: red. + for t in state_flee["threats"]: + flee_layer.set(t, mcrfpy.Color(255, 60, 60, 255)) + # Guards: green. + for gd in state_flee["guards"]: + flee_layer.set(gd, mcrfpy.Color(80, 255, 120, 255)) + cap_flee.text = (f"FLEE: {len(state_flee['threats'])} threats " + f"{len(state_flee['guards'])} guards") + + +def step_guards(): + if state_flee["safety"] is None: + return + new_guards = [] + for gd in state_flee["guards"]: + nxt = state_flee["safety"].descent_step(gd) + if nxt is None: + new_guards.append(gd) + else: + candidate = (int(nxt.x), int(nxt.y)) + # Avoid landing on another guard or a threat. + if candidate in new_guards or candidate in state_flee["threats"]: + new_guards.append(gd) + else: + new_guards.append(candidate) + state_flee["guards"] = new_guards + redraw_flee() + + +# ============================================================================= +# Key handling +# ============================================================================= +instructions = [ + "Panel 1 (A*): [1-5] heuristic [Q/W] weight -/+", + "Panel 2 (Dij): [Arrow keys] move root", + "Panel 3 (FLEE): [T] add threat [R] reset (guards auto-step)", + "[ESC] quit", +] +for i, text in enumerate(instructions): + c = mcrfpy.Caption(text=text, pos=(GAP, SCREEN_H - 90 + i * 22)) + c.fill_color = mcrfpy.Color(180, 180, 200) + scene.children.append(c) + + +def on_key(key, state): + if state != mcrfpy.InputState.PRESSED: + return + + # Heuristic switching + for i, digit in enumerate([mcrfpy.Key.Num1, mcrfpy.Key.Num2, mcrfpy.Key.Num3, + mcrfpy.Key.Num4, mcrfpy.Key.Num5]): + if key == digit: + state_astar["hidx"] = i + redraw_astar() + return + + if key == mcrfpy.Key.Q: + state_astar["weight"] = max(0.25, state_astar["weight"] - 0.25) + redraw_astar() + return + if key == mcrfpy.Key.W: + state_astar["weight"] = min(5.0, state_astar["weight"] + 0.25) + redraw_astar() + return + + # Dijkstra root movement + rx, ry = state_dij["root"] + moved = False + if key == mcrfpy.Key.LEFT: + rx = max(1, rx - 1); moved = True + elif key == mcrfpy.Key.RIGHT: + rx = min(GRID_W - 2, rx + 1); moved = True + elif key == mcrfpy.Key.UP: + ry = max(1, ry - 1); moved = True + elif key == mcrfpy.Key.DOWN: + ry = min(GRID_H - 2, ry + 1); moved = True + if moved: + state_dij["root"] = (rx, ry) + redraw_dijkstra() + + # FLEE panel: drop threat at a random walkable cell. + if key == mcrfpy.Key.T: + import random + for _ in range(20): + p = (random.randint(1, GRID_W - 2), random.randint(1, GRID_H - 2)) + if g3.at(p[0], p[1]).walkable and p not in state_flee["threats"]: + state_flee["threats"].append(p) + break + recompute_flee() + redraw_flee() + + if key == mcrfpy.Key.R: + state_flee["threats"] = [(3, 3), (GRID_W - 4, GRID_H - 4)] + state_flee["guards"] = [(10, 6), (10, 10), (10, 14)] + recompute_flee() + redraw_flee() + + if key == mcrfpy.Key.ESCAPE: + mcrfpy.exit() + + +scene.on_key = on_key + +# Step FLEE guards on a timer (6 ticks/sec is slow enough to watch). +def tick(timer, runtime): + step_guards() + + +# Initial render +redraw_astar() +redraw_dijkstra() +recompute_flee() +redraw_flee() + +mcrfpy.Timer("flee_tick", tick, 160) +mcrfpy.current_scene = scene + +print("pathfinding_demo loaded - see on-screen instructions.") diff --git a/tests/regression/issue_315_path_provider_test.py b/tests/regression/issue_315_path_provider_test.py new file mode 100644 index 0000000..a5b153d --- /dev/null +++ b/tests/regression/issue_315_path_provider_test.py @@ -0,0 +1,150 @@ +"""Phase C regression: PathProvider abstraction drives SEEK/FLEE via set_behavior. + +Verifies that each of the three provider shapes (DijkstraMap, AStarPath, and +plain target tuple) produces a valid SEEK step, and that swapping pathfinder +mid-run changes the next step. +""" +import mcrfpy +import sys + + +def make_open_grid(w=20, h=20): + scene = mcrfpy.Scene("issue315") + mcrfpy.current_scene = scene + grid = mcrfpy.Grid(grid_size=(w, h)) + scene.children.append(grid) + for y in range(h): + for x in range(w): + c = grid.at(x, y) + # Walkable interior, walls on the border. + if x == 0 or y == 0 or x == w - 1 or y == h - 1: + c.walkable = False + c.transparent = False + else: + c.walkable = True + c.transparent = True + return grid + + +def closer_to(p, goal, start): + """True when p is closer than start to goal (Chebyshev).""" + def d(a, b): return max(abs(a[0] - b[0]), abs(a[1] - b[1])) + return d(p, goal) < d(start, goal) + + +def test_dijkstra_provider(): + grid = make_open_grid() + e = mcrfpy.Entity((5, 5), grid=grid) + e.move_speed = 0 + goal = (15, 15) + dmap = grid.get_dijkstra_map(goal) + e.set_behavior(int(mcrfpy.Behavior.SEEK), pathfinder=dmap) + + start = (e.cell_x, e.cell_y) + grid.step() + ended = (e.cell_x, e.cell_y) + assert ended != start, "DijkstraProvider SEEK must move the entity" + assert closer_to(ended, goal, start), f"moved away from goal: {start} -> {ended}" + print(" DijkstraProvider SEEK: OK") + + +def test_astar_provider(): + grid = make_open_grid() + e = mcrfpy.Entity((5, 5), grid=grid) + e.move_speed = 0 + goal = (10, 10) + path = grid.find_path((5, 5), goal) + assert path is not None + e.set_behavior(int(mcrfpy.Behavior.SEEK), pathfinder=path) + + start = (e.cell_x, e.cell_y) + grid.step() + ended = (e.cell_x, e.cell_y) + assert ended != start, "AStarProvider SEEK must move the entity" + assert closer_to(ended, goal, start), f"moved away from goal: {start} -> {ended}" + print(" AStarProvider SEEK: OK") + + +def test_target_provider(): + grid = make_open_grid() + e = mcrfpy.Entity((5, 5), grid=grid) + e.move_speed = 0 + goal = (10, 10) + e.set_behavior(int(mcrfpy.Behavior.SEEK), pathfinder=goal) + + start = (e.cell_x, e.cell_y) + grid.step() + ended = (e.cell_x, e.cell_y) + assert ended != start, "TargetProvider SEEK must move the entity" + assert closer_to(ended, goal, start), f"moved away from target: {start} -> {ended}" + print(" TargetProvider SEEK: OK") + + +def test_flee_via_inverted_dijkstra(): + grid = make_open_grid() + e = mcrfpy.Entity((10, 10), grid=grid) + e.move_speed = 0 + threat = (12, 10) + threat_map = grid.get_dijkstra_map(threat) + safety_map = threat_map.invert() + e.set_behavior(int(mcrfpy.Behavior.FLEE), pathfinder=safety_map) + + start = (e.cell_x, e.cell_y) + start_dist = threat_map.distance(start) + grid.step() + ended = (e.cell_x, e.cell_y) + assert ended != start, "FLEE must move the entity" + new_dist = threat_map.distance(ended) + assert new_dist >= start_dist, ( + f"FLEE should not get closer: d={start_dist} -> {new_dist}") + print(" FLEE via inverted DijkstraMap: OK") + + +def test_provider_swap_midrun(): + """Swapping pathfinder mid-run changes the next step.""" + grid = make_open_grid() + e = mcrfpy.Entity((10, 10), grid=grid) + e.move_speed = 0 + + # First: seek (5, 10) - should move left. + e.set_behavior(int(mcrfpy.Behavior.SEEK), + pathfinder=grid.get_dijkstra_map((5, 10))) + grid.step() + after_first = (e.cell_x, e.cell_y) + assert after_first[0] < 10, f"expected leftward step, got {after_first}" + + # Swap pathfinder: now seek (15, 10) - should move right from here. + e.set_behavior(int(mcrfpy.Behavior.SEEK), + pathfinder=grid.get_dijkstra_map((15, 10))) + grid.step() + after_second = (e.cell_x, e.cell_y) + assert after_second[0] > after_first[0], ( + f"expected rightward step after swap, got {after_first} -> {after_second}") + print(" Mid-run provider swap: OK") + + +def test_invalid_pathfinder_raises(): + grid = make_open_grid() + e = mcrfpy.Entity((5, 5), grid=grid) + try: + e.set_behavior(int(mcrfpy.Behavior.SEEK), pathfinder="not a pathfinder") + except TypeError: + pass + else: + raise AssertionError("expected TypeError for bad pathfinder argument") + print(" Invalid pathfinder rejected: OK") + + +def main(): + test_dijkstra_provider() + test_astar_provider() + test_target_provider() + test_flee_via_inverted_dijkstra() + test_provider_swap_midrun() + test_invalid_pathfinder_raises() + print("PASS") + + +if __name__ == "__main__": + main() + sys.exit(0) diff --git a/tests/unit/dijkstra_flee_test.py b/tests/unit/dijkstra_flee_test.py new file mode 100644 index 0000000..a8b4836 --- /dev/null +++ b/tests/unit/dijkstra_flee_test.py @@ -0,0 +1,85 @@ +"""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) diff --git a/tests/unit/find_path_heuristic_test.py b/tests/unit/find_path_heuristic_test.py new file mode 100644 index 0000000..bfaf51c --- /dev/null +++ b/tests/unit/find_path_heuristic_test.py @@ -0,0 +1,61 @@ +"""Grid.find_path heuristic/weight kwargs produce valid paths across each built-in.""" +import mcrfpy +import sys + + +def make_open_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_open_grid(30, 30) + + # On an obstacle-free grid every admissible heuristic yields an optimal-length path. + # libtcod returns steps (excluding origin), so a diagonal-permitting move from (0,0) + # to (20,20) is 20 steps. + for h in (mcrfpy.Heuristic.EUCLIDEAN, + mcrfpy.Heuristic.MANHATTAN, + mcrfpy.Heuristic.CHEBYSHEV, + mcrfpy.Heuristic.DIAGONAL, + mcrfpy.Heuristic.ZERO): + p = g.find_path((0, 0), (20, 20), heuristic=h) + assert p is not None, f"no path for {h}" + steps = list(p) + assert len(steps) == 20, f"heuristic {h} gave {len(steps)} steps, expected 20" + # Last step must be the goal. + assert (int(steps[-1].x), int(steps[-1].y)) == (20, 20), \ + f"heuristic {h} did not end at goal" + + # Weighted A* with weight>=1 must still find a path (not necessarily optimal). + for w in (1.0, 1.5, 2.0): + p = g.find_path((0, 0), (20, 20), heuristic=mcrfpy.Heuristic.EUCLIDEAN, weight=w) + assert p is not None, f"no path for weight={w}" + steps = list(p) + assert len(steps) >= 20, f"weight={w} gave impossibly short path" + + # With an obstacle, the path still reaches the goal. + g2 = make_open_grid(30, 30) + for y in range(5, 25): + g2.at(15, y).walkable = False + + p = g2.find_path((0, 0), (29, 0), heuristic=mcrfpy.Heuristic.MANHATTAN) + assert p is not None + steps = list(p) + assert (int(steps[-1].x), int(steps[-1].y)) == (29, 0) + # No step may land on a blocked cell. + for s in steps: + assert not (int(s.x) == 15 and 5 <= int(s.y) < 25), \ + f"path stepped through wall at {s}" + + print("PASS") + + +if __name__ == "__main__": + main() + sys.exit(0) diff --git a/tests/unit/heuristic_enum_test.py b/tests/unit/heuristic_enum_test.py new file mode 100644 index 0000000..45b6698 --- /dev/null +++ b/tests/unit/heuristic_enum_test.py @@ -0,0 +1,61 @@ +"""mcrfpy.Heuristic enum exists with expected members and accepts several arg forms.""" +import mcrfpy +import sys + + +def main(): + assert hasattr(mcrfpy, "Heuristic"), "mcrfpy.Heuristic missing" + H = mcrfpy.Heuristic + + expected = {"EUCLIDEAN": 0, "MANHATTAN": 1, "CHEBYSHEV": 2, "DIAGONAL": 3, "ZERO": 4} + for name, value in expected.items(): + assert hasattr(H, name), f"Heuristic.{name} missing" + assert int(getattr(H, name)) == value, f"Heuristic.{name} != {value}" + + members = list(H) + assert len(members) == 5, f"expected 5 members, got {len(members)}" + + # find_path accepts enum, int, string + g = mcrfpy.Grid(grid_size=(20, 20)) + for y in range(20): + for x in range(20): + c = g.at(x, y) + c.walkable = True + c.transparent = True + + for arg in (H.MANHATTAN, 1, "MANHATTAN"): + p = g.find_path((0, 0), (10, 10), heuristic=arg) + assert p is not None, f"find_path returned None for heuristic={arg!r}" + steps = list(p) + assert len(steps) > 0, f"empty path for heuristic={arg!r}" + + # Invalid string raises + try: + g.find_path((0, 0), (10, 10), heuristic="NOT_A_HEURISTIC") + except ValueError: + pass + else: + raise AssertionError("expected ValueError for bad heuristic string") + + # Invalid int raises + try: + g.find_path((0, 0), (10, 10), heuristic=99) + except ValueError: + pass + else: + raise AssertionError("expected ValueError for out-of-range heuristic int") + + # Non-positive weight raises + try: + g.find_path((0, 0), (10, 10), weight=0.0) + except ValueError: + pass + else: + raise AssertionError("expected ValueError for non-positive weight") + + print("PASS") + + +if __name__ == "__main__": + main() + sys.exit(0) diff --git a/tests/unit/multi_root_dijkstra_test.py b/tests/unit/multi_root_dijkstra_test.py new file mode 100644 index 0000000..b009f3c --- /dev/null +++ b/tests/unit/multi_root_dijkstra_test.py @@ -0,0 +1,83 @@ +"""Multi-root Dijkstra distance equals min of per-root distances. + +Also covers the DiscreteMap-mask root-input form introduced in #315. +""" +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 approx(a, b, tol=0.01): + return abs(a - b) < tol + + +def main(): + g = make_grid(20, 20) + roots = [(0, 0), (19, 19), (0, 19)] + + multi = g.get_dijkstra_map(roots=roots) + singles = [g.get_dijkstra_map(r) for r in roots] + + # Pick sample cells spread across the grid. + samples = [(5, 5), (10, 10), (15, 5), (2, 18), (18, 2), (9, 15)] + for p in samples: + expected = min(s.distance(p) for s in singles) + got = multi.distance(p) + assert approx(got, expected), ( + f"multi-root distance at {p} was {got}, expected {expected}") + + # Distance at each root is 0. + for r in roots: + assert multi.distance(r) == 0.0, f"root {r} distance should be 0" + + # Single-root via roots= also works. + d_single = g.get_dijkstra_map(roots=[(5, 5)]) + d_ref = g.get_dijkstra_map((5, 5)) + for p in samples: + assert approx(d_single.distance(p), d_ref.distance(p)), \ + f"single-element roots list diverges at {p}" + + # DiscreteMap mask form: mark four corners, compare against explicit roots. + dmap = mcrfpy.DiscreteMap((20, 20)) + corners = [(0, 0), (19, 0), (0, 19), (19, 19)] + for x, y in corners: + dmap.set(x, y, 1) + + d_mask = g.get_dijkstra_map(roots=dmap) + d_corners = g.get_dijkstra_map(roots=corners) + for p in samples: + assert approx(d_mask.distance(p), d_corners.distance(p)), \ + f"mask-root diverges from explicit corners at {p}" + + # Empty mask errors out rather than silently returning all-infinity. + empty_mask = mcrfpy.DiscreteMap((20, 20)) + try: + g.get_dijkstra_map(roots=empty_mask) + except ValueError: + pass + else: + raise AssertionError("expected ValueError on empty DiscreteMap mask") + + # Passing both root and roots raises. + try: + g.get_dijkstra_map(root=(0, 0), roots=[(1, 1)]) + except TypeError: + pass + else: + raise AssertionError("expected TypeError when both root and roots supplied") + + print("PASS") + + +if __name__ == "__main__": + main() + sys.exit(0)