Extend pathfinding API with heuristics, multi-root Dijkstra, and FLEE primitives; refs #315

Phase A (Python surface):
- New mcrfpy.Heuristic IntEnum: EUCLIDEAN, MANHATTAN, CHEBYSHEV, DIAGONAL, ZERO
- Grid.find_path() accepts heuristic= and weight= kwargs (weighted A*)
- Grid.get_dijkstra_map() accepts roots= (list of positions or DiscreteMap mask)

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-04-18 09:18:49 -04:00
commit 767d0d4b0f
11 changed files with 860 additions and 98 deletions

View file

@ -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) {

142
src/PyHeuristic.cpp Normal file
View file

@ -0,0 +1,142 @@
#include "PyHeuristic.h"
#include <cstring>
#include <sstream>
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<int>(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<int>(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;
}
}

39
src/PyHeuristic.h Normal file
View file

@ -0,0 +1,39 @@
#pragma once
#include "Common.h"
#include "Python.h"
#include <libtcod.h>
#include <cstdint>
// 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;
};

View file

@ -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<sf::Vector2i>& 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<int> 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<int>(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<sf::Vector2i> DijkstraMap::getPathFrom(int x, int y) const {
std::vector<sf::Vector2i> 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> 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<DijkstraMap>(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<float>(root.x), static_cast<float>(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<DijkstraMap>(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<char**>(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<float>(step.x),
static_cast<float>(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<char**>(kwlist),
&start_obj, &end_obj, &diagonal_cost, &collide_label)) {
if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|fzOf", const_cast<char**>(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<sf::Vector2i>();
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<sf::Vector2i>* 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<char**>(kwlist),
&root_obj, &diagonal_cost, &collide_label)) {
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OfzO", const_cast<char**>(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<sf::Vector2i> 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<DijkstraMap>(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<DijkstraMap>(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<DijkstraMap>(
self->data->getTCODMap(), root_x, root_y, diagonal_cost);
std::shared_ptr<DijkstraMap> 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<sf::Vector2i> 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<size_t>(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<DijkstraMap>(tcmap, mask_roots, diagonal_cost);
} else {
dijkstra = std::make_shared<DijkstraMap>(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}
};

View file

@ -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<sf::Vector2i>& 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<sf::Vector2i> 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<DijkstraMap> 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<sf::Vector2i>& 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<sf::Vector2i> 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);

View file

@ -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
}
]

View file

@ -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": {

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)