Refactor EntityBehavior SEEK/FLEE to use PathProvider strategy; refs #315

EntityBehavior no longer holds a direct DijkstraMap reference. A new
PathProvider interface has three concrete implementations:

- DijkstraProvider: steps along a (possibly inverted) DijkstraMap. SEEK
  descends a normal map toward roots; FLEE descends an inverted map away
  from threats.
- AStarProvider: follows a pre-computed AStarPath step-by-step.
- TargetProvider: takes a single (x, y) target and picks the Chebyshev
  neighbor closest to it each turn.

Entity.set_behavior() gains a pathfinder= kwarg accepting any of the above
(DijkstraMap, AStarPath, or (x, y) tuple). The old executeSeek/executeFlee
helpers collapse into a single executeProviderStep() that delegates to the
provider.

EntityBehavior.h forward-declares PathProvider so the header stays light.
EntityBehavior::reset() moves out of line to avoid pulling PathProvider
into the header.

New tests: tests/regression/issue_315_path_provider_test.py covers all three
providers driving SEEK, FLEE via inverted DijkstraMap, mid-run pathfinder
swap, and invalid-argument handling. grid_step_bench baseline refreshed
against the new provider dispatch path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-04-18 09:19:05 -04:00
commit 17f2d6e1ef
7 changed files with 349 additions and 82 deletions

View file

@ -2,9 +2,22 @@
#include "UIEntity.h"
#include "UIGrid.h"
#include "UIGridPathfinding.h"
#include "PathProvider.h"
#include <random>
#include <algorithm>
// 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<float>::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, {}};
}

View file

@ -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<DijkstraMap> dijkstra_map;
// SEEK/FLEE pathfinding strategy (#315). Nullptr means NO_ACTION.
std::unique_ptr<PathProvider> 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();
};
// =============================================================================

65
src/PathProvider.cpp Normal file
View file

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

64
src/PathProvider.h Normal file
View file

@ -0,0 +1,64 @@
#pragma once
#include "Common.h"
#include <memory>
#include <vector>
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<DijkstraMap> map);
sf::Vector2i nextStep(sf::Vector2i from, UIGrid& grid, bool* ok) override;
private:
std::shared_ptr<DijkstraMap> map_;
};
// Replay a pre-computed A* path.
class AStarProvider : public PathProvider {
public:
explicit AStarProvider(std::vector<sf::Vector2i> path);
sf::Vector2i nextStep(sf::Vector2i from, UIGrid& grid, bool* ok) override;
void reset() override { index_ = 0; }
private:
std::vector<sf::Vector2i> 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_;
};

View file

@ -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 <algorithm>
#include <cstring>
@ -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<char**>(kwlist),
&type_val, &waypoints_obj, &turns, &path_obj)) {
if (!PyArg_ParseTupleAndKeywords(args, kwds, "i|OiOO", const_cast<char**>(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<DijkstraProvider>(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<sf::Vector2i> steps(
apath->path.begin() + apath->current_index,
apath->path.end());
behavior.path_provider = std::make_unique<AStarProvider>(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<TargetProvider>(
sf::Vector2i(static_cast<int>(tx), static_cast<int>(ty)));
} else {
PyErr_SetString(PyExc_TypeError,
"pathfinder must be a DijkstraMap, AStarPath, or (x, y) tuple");
return NULL;
}
}
Py_RETURN_NONE;
}

View file

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

View file

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