Phase 2: Entity data model extensions for behavior system
- Add Behavior enum (IDLE..FLEE, 11 values) and Trigger enum (DONE, BLOCKED, TARGET) as runtime IntEnum classes (closes #297, closes #298) - Add entity label system: labels property (frozenset), add_label(), remove_label(), has_label(), constructor kwarg (closes #296) - Add cell_pos integer logical position decoupled from float draw_pos; grid_pos now aliases cell_pos; SpatialHash::updateCell() for cell-based bucket management; FOV/visibility uses cell_position (closes #295) - Add step callback and default_behavior properties to Entity for grid.step() turn management (closes #299) - Update updateVisibility, visible_entities, ColorLayer::updatePerspective to use cell_position instead of float position BREAKING: grid_pos no longer derives from float x/y position. Use cell_pos/grid_pos for logical position, draw_pos for render position. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
94f5f5a3fd
commit
2f1e472245
15 changed files with 886 additions and 34 deletions
|
|
@ -361,9 +361,9 @@ void ColorLayer::updatePerspective() {
|
|||
|
||||
if (!parent_grid) return;
|
||||
|
||||
// Get entity position and grid's FOV settings
|
||||
int source_x = static_cast<int>(entity->position.x);
|
||||
int source_y = static_cast<int>(entity->position.y);
|
||||
// Get entity cell position and grid's FOV settings (#295)
|
||||
int source_x = entity->cell_position.x;
|
||||
int source_y = entity->cell_position.y;
|
||||
int radius = parent_grid->fov_radius;
|
||||
TCOD_fov_algorithm_t algorithm = parent_grid->fov_algorithm;
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
#include "PyKey.h"
|
||||
#include "PyMouseButton.h"
|
||||
#include "PyInputState.h"
|
||||
#include "PyBehavior.h"
|
||||
#include "PyTrigger.h"
|
||||
#include "PySound.h"
|
||||
#include "PySoundBuffer.h"
|
||||
#include "PyMusic.h"
|
||||
|
|
@ -779,6 +781,18 @@ PyObject* PyInit_mcrfpy()
|
|||
PyErr_Clear();
|
||||
}
|
||||
|
||||
// Add Behavior enum class for entity behavior types (#297)
|
||||
PyObject* behavior_class = PyBehavior::create_enum_class(m);
|
||||
if (!behavior_class) {
|
||||
PyErr_Clear();
|
||||
}
|
||||
|
||||
// Add Trigger enum class for entity step callback triggers (#298)
|
||||
PyObject* trigger_class = PyTrigger::create_enum_class(m);
|
||||
if (!trigger_class) {
|
||||
PyErr_Clear();
|
||||
}
|
||||
|
||||
// Add automation submodule
|
||||
PyObject* automation_module = McRFPy_Automation::init_automation_module();
|
||||
if (automation_module != NULL) {
|
||||
|
|
|
|||
116
src/PyBehavior.cpp
Normal file
116
src/PyBehavior.cpp
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
#include "PyBehavior.h"
|
||||
#include <sstream>
|
||||
|
||||
// Static storage for cached enum class reference
|
||||
PyObject* PyBehavior::behavior_enum_class = nullptr;
|
||||
|
||||
struct BehaviorEntry {
|
||||
const char* name;
|
||||
int value;
|
||||
const char* description;
|
||||
};
|
||||
|
||||
static const BehaviorEntry behavior_table[] = {
|
||||
{"IDLE", 0, "No action each turn"},
|
||||
{"CUSTOM", 1, "Calls step callback only, no built-in movement"},
|
||||
{"NOISE4", 2, "Random movement in 4 cardinal directions"},
|
||||
{"NOISE8", 3, "Random movement in 8 directions (incl. diagonals)"},
|
||||
{"PATH", 4, "Follow a precomputed path to completion"},
|
||||
{"WAYPOINT", 5, "Path through a sequence of waypoints in order"},
|
||||
{"PATROL", 6, "Patrol waypoints back and forth (reversing at ends)"},
|
||||
{"LOOP", 7, "Loop through waypoints cyclically"},
|
||||
{"SLEEP", 8, "Wait for N turns, then trigger DONE"},
|
||||
{"SEEK", 9, "Move toward target using Dijkstra map"},
|
||||
{"FLEE", 10, "Move away from target using Dijkstra map"},
|
||||
};
|
||||
|
||||
static const int NUM_BEHAVIOR_ENTRIES = sizeof(behavior_table) / sizeof(behavior_table[0]);
|
||||
|
||||
PyObject* PyBehavior::create_enum_class(PyObject* module) {
|
||||
std::ostringstream code;
|
||||
code << "from enum import IntEnum\n\n";
|
||||
|
||||
code << "class Behavior(IntEnum):\n";
|
||||
code << " \"\"\"Enum representing entity behavior types for grid.step() turn management.\n";
|
||||
code << " \n";
|
||||
code << " Values:\n";
|
||||
for (int i = 0; i < NUM_BEHAVIOR_ENTRIES; i++) {
|
||||
code << " " << behavior_table[i].name << ": " << behavior_table[i].description << "\n";
|
||||
}
|
||||
code << " \"\"\"\n";
|
||||
|
||||
for (int i = 0; i < NUM_BEHAVIOR_ENTRIES; i++) {
|
||||
code << " " << behavior_table[i].name << " = " << behavior_table[i].value << "\n";
|
||||
}
|
||||
|
||||
code << R"(
|
||||
|
||||
def _Behavior_eq(self, other):
|
||||
if isinstance(other, str):
|
||||
if self.name == other:
|
||||
return True
|
||||
return False
|
||||
return int.__eq__(int(self), other)
|
||||
|
||||
Behavior.__eq__ = _Behavior_eq
|
||||
|
||||
def _Behavior_ne(self, other):
|
||||
result = type(self).__eq__(self, other)
|
||||
if result is NotImplemented:
|
||||
return result
|
||||
return not result
|
||||
|
||||
Behavior.__ne__ = _Behavior_ne
|
||||
Behavior.__hash__ = lambda self: hash(int(self))
|
||||
Behavior.__repr__ = lambda self: f"{type(self).__name__}.{self.name}"
|
||||
Behavior.__str__ = lambda self: self.name
|
||||
)";
|
||||
|
||||
std::string code_str = code.str();
|
||||
|
||||
PyObject* globals = PyDict_New();
|
||||
if (!globals) return NULL;
|
||||
|
||||
PyObject* builtins = PyEval_GetBuiltins();
|
||||
PyDict_SetItemString(globals, "__builtins__", builtins);
|
||||
|
||||
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* behavior_class = PyDict_GetItemString(locals, "Behavior");
|
||||
if (!behavior_class) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "Failed to create Behavior enum class");
|
||||
Py_DECREF(globals);
|
||||
Py_DECREF(locals);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
Py_INCREF(behavior_class);
|
||||
|
||||
behavior_enum_class = behavior_class;
|
||||
Py_INCREF(behavior_enum_class);
|
||||
|
||||
if (PyModule_AddObject(module, "Behavior", behavior_class) < 0) {
|
||||
Py_DECREF(behavior_class);
|
||||
Py_DECREF(globals);
|
||||
Py_DECREF(locals);
|
||||
behavior_enum_class = nullptr;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
Py_DECREF(globals);
|
||||
Py_DECREF(locals);
|
||||
|
||||
return behavior_class;
|
||||
}
|
||||
21
src/PyBehavior.h
Normal file
21
src/PyBehavior.h
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#pragma once
|
||||
#include "Common.h"
|
||||
#include "Python.h"
|
||||
|
||||
// Module-level Behavior enum class (created at runtime using Python's IntEnum)
|
||||
// Stored as a module attribute: mcrfpy.Behavior
|
||||
//
|
||||
// Values represent entity behavior types for grid.step() turn management.
|
||||
|
||||
class PyBehavior {
|
||||
public:
|
||||
// Create the Behavior enum class and add to module
|
||||
// Returns the enum class (new reference), or NULL on error
|
||||
static PyObject* create_enum_class(PyObject* module);
|
||||
|
||||
// Cached reference to the Behavior enum class for fast type checking
|
||||
static PyObject* behavior_enum_class;
|
||||
|
||||
// Number of behavior types
|
||||
static const int NUM_BEHAVIORS = 11;
|
||||
};
|
||||
108
src/PyTrigger.cpp
Normal file
108
src/PyTrigger.cpp
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
#include "PyTrigger.h"
|
||||
#include <sstream>
|
||||
|
||||
// Static storage for cached enum class reference
|
||||
PyObject* PyTrigger::trigger_enum_class = nullptr;
|
||||
|
||||
struct TriggerEntry {
|
||||
const char* name;
|
||||
int value;
|
||||
const char* description;
|
||||
};
|
||||
|
||||
static const TriggerEntry trigger_table[] = {
|
||||
{"DONE", 0, "Behavior completed (path exhausted, sleep finished, etc.)"},
|
||||
{"BLOCKED", 1, "Movement blocked by wall or collision"},
|
||||
{"TARGET", 2, "Target entity spotted in FOV"},
|
||||
};
|
||||
|
||||
static const int NUM_TRIGGER_ENTRIES = sizeof(trigger_table) / sizeof(trigger_table[0]);
|
||||
|
||||
PyObject* PyTrigger::create_enum_class(PyObject* module) {
|
||||
std::ostringstream code;
|
||||
code << "from enum import IntEnum\n\n";
|
||||
|
||||
code << "class Trigger(IntEnum):\n";
|
||||
code << " \"\"\"Enum representing trigger types passed to entity step() callbacks.\n";
|
||||
code << " \n";
|
||||
code << " Values:\n";
|
||||
for (int i = 0; i < NUM_TRIGGER_ENTRIES; i++) {
|
||||
code << " " << trigger_table[i].name << ": " << trigger_table[i].description << "\n";
|
||||
}
|
||||
code << " \"\"\"\n";
|
||||
|
||||
for (int i = 0; i < NUM_TRIGGER_ENTRIES; i++) {
|
||||
code << " " << trigger_table[i].name << " = " << trigger_table[i].value << "\n";
|
||||
}
|
||||
|
||||
code << R"(
|
||||
|
||||
def _Trigger_eq(self, other):
|
||||
if isinstance(other, str):
|
||||
if self.name == other:
|
||||
return True
|
||||
return False
|
||||
return int.__eq__(int(self), other)
|
||||
|
||||
Trigger.__eq__ = _Trigger_eq
|
||||
|
||||
def _Trigger_ne(self, other):
|
||||
result = type(self).__eq__(self, other)
|
||||
if result is NotImplemented:
|
||||
return result
|
||||
return not result
|
||||
|
||||
Trigger.__ne__ = _Trigger_ne
|
||||
Trigger.__hash__ = lambda self: hash(int(self))
|
||||
Trigger.__repr__ = lambda self: f"{type(self).__name__}.{self.name}"
|
||||
Trigger.__str__ = lambda self: self.name
|
||||
)";
|
||||
|
||||
std::string code_str = code.str();
|
||||
|
||||
PyObject* globals = PyDict_New();
|
||||
if (!globals) return NULL;
|
||||
|
||||
PyObject* builtins = PyEval_GetBuiltins();
|
||||
PyDict_SetItemString(globals, "__builtins__", builtins);
|
||||
|
||||
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* trigger_class = PyDict_GetItemString(locals, "Trigger");
|
||||
if (!trigger_class) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "Failed to create Trigger enum class");
|
||||
Py_DECREF(globals);
|
||||
Py_DECREF(locals);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
Py_INCREF(trigger_class);
|
||||
|
||||
trigger_enum_class = trigger_class;
|
||||
Py_INCREF(trigger_enum_class);
|
||||
|
||||
if (PyModule_AddObject(module, "Trigger", trigger_class) < 0) {
|
||||
Py_DECREF(trigger_class);
|
||||
Py_DECREF(globals);
|
||||
Py_DECREF(locals);
|
||||
trigger_enum_class = nullptr;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
Py_DECREF(globals);
|
||||
Py_DECREF(locals);
|
||||
|
||||
return trigger_class;
|
||||
}
|
||||
21
src/PyTrigger.h
Normal file
21
src/PyTrigger.h
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#pragma once
|
||||
#include "Common.h"
|
||||
#include "Python.h"
|
||||
|
||||
// Module-level Trigger enum class (created at runtime using Python's IntEnum)
|
||||
// Stored as a module attribute: mcrfpy.Trigger
|
||||
//
|
||||
// Values represent trigger types passed to entity step() callbacks.
|
||||
|
||||
class PyTrigger {
|
||||
public:
|
||||
// Create the Trigger enum class and add to module
|
||||
// Returns the enum class (new reference), or NULL on error
|
||||
static PyObject* create_enum_class(PyObject* module);
|
||||
|
||||
// Cached reference to the Trigger enum class for fast type checking
|
||||
static PyObject* trigger_enum_class;
|
||||
|
||||
// Number of trigger types
|
||||
static const int NUM_TRIGGERS = 3;
|
||||
};
|
||||
|
|
@ -72,6 +72,37 @@ void SpatialHash::update(std::shared_ptr<UIEntity> entity, float old_x, float ol
|
|||
buckets[new_bucket].push_back(entity);
|
||||
}
|
||||
|
||||
void SpatialHash::updateCell(std::shared_ptr<UIEntity> entity, int old_x, int old_y)
|
||||
{
|
||||
if (!entity) return;
|
||||
|
||||
auto old_bucket = getBucket(static_cast<float>(old_x), static_cast<float>(old_y));
|
||||
auto new_bucket = getBucket(static_cast<float>(entity->cell_position.x),
|
||||
static_cast<float>(entity->cell_position.y));
|
||||
|
||||
if (old_bucket == new_bucket) return;
|
||||
|
||||
// Remove from old bucket
|
||||
auto it = buckets.find(old_bucket);
|
||||
if (it != buckets.end()) {
|
||||
auto& bucket = it->second;
|
||||
bucket.erase(
|
||||
std::remove_if(bucket.begin(), bucket.end(),
|
||||
[&entity](const std::weak_ptr<UIEntity>& wp) {
|
||||
auto sp = wp.lock();
|
||||
return !sp || sp == entity;
|
||||
}),
|
||||
bucket.end()
|
||||
);
|
||||
if (bucket.empty()) {
|
||||
buckets.erase(it);
|
||||
}
|
||||
}
|
||||
|
||||
// Add to new bucket
|
||||
buckets[new_bucket].push_back(entity);
|
||||
}
|
||||
|
||||
std::vector<std::shared_ptr<UIEntity>> SpatialHash::queryCell(int x, int y) const
|
||||
{
|
||||
std::vector<std::shared_ptr<UIEntity>> result;
|
||||
|
|
@ -84,9 +115,9 @@ std::vector<std::shared_ptr<UIEntity>> SpatialHash::queryCell(int x, int y) cons
|
|||
auto entity = wp.lock();
|
||||
if (!entity) continue;
|
||||
|
||||
// Exact integer position match
|
||||
if (static_cast<int>(entity->position.x) == x &&
|
||||
static_cast<int>(entity->position.y) == y) {
|
||||
// Match on cell_position (#295)
|
||||
if (entity->cell_position.x == x &&
|
||||
entity->cell_position.y == y) {
|
||||
result.push_back(entity);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,11 @@ public:
|
|||
// This removes from old bucket and inserts into new bucket if needed
|
||||
void update(std::shared_ptr<UIEntity> entity, float old_x, float old_y);
|
||||
|
||||
// Query all entities at a specific cell (exact integer position match)
|
||||
// Update entity position using integer cell coordinates (#295)
|
||||
// Removes from old bucket and inserts into new based on cell_position
|
||||
void updateCell(std::shared_ptr<UIEntity> entity, int old_x, int old_y);
|
||||
|
||||
// Query all entities at a specific cell (uses cell_position for matching)
|
||||
// O(n) where n = entities in the bucket containing this cell
|
||||
std::vector<std::shared_ptr<UIEntity>> queryCell(int x, int y) const;
|
||||
|
||||
|
|
|
|||
251
src/UIEntity.cpp
251
src/UIEntity.cpp
|
|
@ -58,9 +58,9 @@ void UIEntity::updateVisibility()
|
|||
state.visible = false;
|
||||
}
|
||||
|
||||
// Compute FOV from entity's position using grid's FOV settings (#114)
|
||||
int x = static_cast<int>(position.x);
|
||||
int y = static_cast<int>(position.y);
|
||||
// Compute FOV from entity's cell position (#114, #295)
|
||||
int x = cell_position.x;
|
||||
int y = cell_position.y;
|
||||
|
||||
// Use grid's configured FOV algorithm and radius
|
||||
grid->computeFOV(x, y, grid->fov_radius, true, grid->fov_algorithm);
|
||||
|
|
@ -170,19 +170,20 @@ int UIEntity::init(PyUIEntityObject* self, PyObject* args, PyObject* kwds) {
|
|||
const char* name = nullptr;
|
||||
float x = 0.0f, y = 0.0f;
|
||||
PyObject* sprite_offset_obj = nullptr;
|
||||
PyObject* labels_obj = nullptr;
|
||||
|
||||
// Keywords list matches the new spec: positional args first, then all keyword args
|
||||
static const char* kwlist[] = {
|
||||
"grid_pos", "texture", "sprite_index", // Positional args (as per spec)
|
||||
// Keyword-only args
|
||||
"grid", "visible", "opacity", "name", "x", "y", "sprite_offset",
|
||||
"grid", "visible", "opacity", "name", "x", "y", "sprite_offset", "labels",
|
||||
nullptr
|
||||
};
|
||||
|
||||
// Parse arguments with | for optional positional args
|
||||
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOiOifzffO", const_cast<char**>(kwlist),
|
||||
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOiOifzffOO", const_cast<char**>(kwlist),
|
||||
&grid_pos_obj, &texture, &sprite_index, // Positional
|
||||
&grid_obj, &visible, &opacity, &name, &x, &y, &sprite_offset_obj)) {
|
||||
&grid_obj, &visible, &opacity, &name, &x, &y, &sprite_offset_obj, &labels_obj)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
|
@ -258,6 +259,8 @@ int UIEntity::init(PyUIEntityObject* self, PyObject* args, PyObject* kwds) {
|
|||
|
||||
// Set position using grid coordinates
|
||||
self->data->position = sf::Vector2f(x, y);
|
||||
// #295: Initialize cell_position from grid coordinates
|
||||
self->data->cell_position = sf::Vector2i(static_cast<int>(x), static_cast<int>(y));
|
||||
|
||||
// Handle sprite_offset argument (optional tuple, default (0,0))
|
||||
if (sprite_offset_obj && sprite_offset_obj != Py_None) {
|
||||
|
|
@ -275,6 +278,28 @@ int UIEntity::init(PyUIEntityObject* self, PyObject* args, PyObject* kwds) {
|
|||
self->data->sprite.name = std::string(name);
|
||||
}
|
||||
|
||||
// #296 - Parse labels kwarg
|
||||
if (labels_obj && labels_obj != Py_None) {
|
||||
PyObject* iter = PyObject_GetIter(labels_obj);
|
||||
if (!iter) {
|
||||
PyErr_SetString(PyExc_TypeError, "labels must be iterable");
|
||||
return -1;
|
||||
}
|
||||
PyObject* item;
|
||||
while ((item = PyIter_Next(iter)) != NULL) {
|
||||
if (!PyUnicode_Check(item)) {
|
||||
Py_DECREF(item);
|
||||
Py_DECREF(iter);
|
||||
PyErr_SetString(PyExc_TypeError, "labels must contain only strings");
|
||||
return -1;
|
||||
}
|
||||
self->data->labels.insert(PyUnicode_AsUTF8(item));
|
||||
Py_DECREF(item);
|
||||
}
|
||||
Py_DECREF(iter);
|
||||
if (PyErr_Occurred()) return -1;
|
||||
}
|
||||
|
||||
// Handle grid attachment
|
||||
if (grid_obj) {
|
||||
PyUIGridObject* pygrid = (PyUIGridObject*)grid_obj;
|
||||
|
|
@ -897,11 +922,11 @@ PyObject* UIEntity::visible_entities(PyUIEntityObject* self, PyObject* args, PyO
|
|||
radius = grid->fov_radius;
|
||||
}
|
||||
|
||||
// Get current position
|
||||
int x = static_cast<int>(self->data->position.x);
|
||||
int y = static_cast<int>(self->data->position.y);
|
||||
// Get current cell position (#295)
|
||||
int x = self->data->cell_position.x;
|
||||
int y = self->data->cell_position.y;
|
||||
|
||||
// Compute FOV from this entity's position
|
||||
// Compute FOV from this entity's cell position
|
||||
grid->computeFOV(x, y, radius, true, algorithm);
|
||||
|
||||
// Create result list
|
||||
|
|
@ -919,9 +944,9 @@ PyObject* UIEntity::visible_entities(PyUIEntityObject* self, PyObject* args, PyO
|
|||
continue;
|
||||
}
|
||||
|
||||
// Check if entity is in FOV
|
||||
int ex = static_cast<int>(entity->position.x);
|
||||
int ey = static_cast<int>(entity->position.y);
|
||||
// Check if entity is in FOV (#295: use cell_position)
|
||||
int ex = entity->cell_position.x;
|
||||
int ey = entity->cell_position.y;
|
||||
|
||||
if (grid->isInFOV(ex, ey)) {
|
||||
// Create Python Entity object for this entity
|
||||
|
|
@ -1002,6 +1027,162 @@ typedef PyUIEntityObject PyObjectType;
|
|||
// Combine base methods with entity-specific methods
|
||||
// Note: Use UIDRAWABLE_METHODS_BASE (not UIDRAWABLE_METHODS) because UIEntity is NOT a UIDrawable
|
||||
// and the template-based animate helper won't work. Entity has its own animate() method.
|
||||
// #296 - Label system implementations
|
||||
PyObject* UIEntity::get_labels(PyUIEntityObject* self, void* closure) {
|
||||
PyObject* frozen = PyFrozenSet_New(NULL);
|
||||
if (!frozen) return NULL;
|
||||
|
||||
for (const auto& label : self->data->labels) {
|
||||
PyObject* str = PyUnicode_FromString(label.c_str());
|
||||
if (!str) { Py_DECREF(frozen); return NULL; }
|
||||
if (PySet_Add(frozen, str) < 0) {
|
||||
Py_DECREF(str); Py_DECREF(frozen); return NULL;
|
||||
}
|
||||
Py_DECREF(str);
|
||||
}
|
||||
return frozen;
|
||||
}
|
||||
|
||||
int UIEntity::set_labels(PyUIEntityObject* self, PyObject* value, void* closure) {
|
||||
PyObject* iter = PyObject_GetIter(value);
|
||||
if (!iter) {
|
||||
PyErr_SetString(PyExc_TypeError, "labels must be iterable");
|
||||
return -1;
|
||||
}
|
||||
|
||||
std::unordered_set<std::string> new_labels;
|
||||
PyObject* item;
|
||||
while ((item = PyIter_Next(iter)) != NULL) {
|
||||
if (!PyUnicode_Check(item)) {
|
||||
Py_DECREF(item);
|
||||
Py_DECREF(iter);
|
||||
PyErr_SetString(PyExc_TypeError, "labels must contain only strings");
|
||||
return -1;
|
||||
}
|
||||
new_labels.insert(PyUnicode_AsUTF8(item));
|
||||
Py_DECREF(item);
|
||||
}
|
||||
Py_DECREF(iter);
|
||||
if (PyErr_Occurred()) return -1;
|
||||
|
||||
self->data->labels = std::move(new_labels);
|
||||
return 0;
|
||||
}
|
||||
|
||||
PyObject* UIEntity::py_add_label(PyUIEntityObject* self, PyObject* arg) {
|
||||
if (!PyUnicode_Check(arg)) {
|
||||
PyErr_SetString(PyExc_TypeError, "label must be a string");
|
||||
return NULL;
|
||||
}
|
||||
self->data->labels.insert(PyUnicode_AsUTF8(arg));
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
PyObject* UIEntity::py_remove_label(PyUIEntityObject* self, PyObject* arg) {
|
||||
if (!PyUnicode_Check(arg)) {
|
||||
PyErr_SetString(PyExc_TypeError, "label must be a string");
|
||||
return NULL;
|
||||
}
|
||||
self->data->labels.erase(PyUnicode_AsUTF8(arg));
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
PyObject* UIEntity::py_has_label(PyUIEntityObject* self, PyObject* arg) {
|
||||
if (!PyUnicode_Check(arg)) {
|
||||
PyErr_SetString(PyExc_TypeError, "label must be a string");
|
||||
return NULL;
|
||||
}
|
||||
if (self->data->labels.count(PyUnicode_AsUTF8(arg))) {
|
||||
Py_RETURN_TRUE;
|
||||
}
|
||||
Py_RETURN_FALSE;
|
||||
}
|
||||
|
||||
// #299 - Step callback and default_behavior implementations
|
||||
PyObject* UIEntity::get_step(PyUIEntityObject* self, void* closure) {
|
||||
if (self->data->step_callback) {
|
||||
Py_INCREF(self->data->step_callback);
|
||||
return self->data->step_callback;
|
||||
}
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
int UIEntity::set_step(PyUIEntityObject* self, PyObject* value, void* closure) {
|
||||
if (value == Py_None) {
|
||||
Py_XDECREF(self->data->step_callback);
|
||||
self->data->step_callback = nullptr;
|
||||
return 0;
|
||||
}
|
||||
if (!PyCallable_Check(value)) {
|
||||
PyErr_SetString(PyExc_TypeError, "step must be callable or None");
|
||||
return -1;
|
||||
}
|
||||
Py_XDECREF(self->data->step_callback);
|
||||
Py_INCREF(value);
|
||||
self->data->step_callback = value;
|
||||
return 0;
|
||||
}
|
||||
|
||||
PyObject* UIEntity::get_default_behavior(PyUIEntityObject* self, void* closure) {
|
||||
return PyLong_FromLong(self->data->default_behavior);
|
||||
}
|
||||
|
||||
int UIEntity::set_default_behavior(PyUIEntityObject* self, PyObject* value, void* closure) {
|
||||
long val = PyLong_AsLong(value);
|
||||
if (val == -1 && PyErr_Occurred()) return -1;
|
||||
self->data->default_behavior = static_cast<int>(val);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// #295 - cell_pos property implementations
|
||||
PyObject* UIEntity::get_cell_pos(PyUIEntityObject* self, void* closure) {
|
||||
return sfVector2i_to_PyObject(self->data->cell_position);
|
||||
}
|
||||
|
||||
int UIEntity::set_cell_pos(PyUIEntityObject* self, PyObject* value, void* closure) {
|
||||
int old_x = self->data->cell_position.x;
|
||||
int old_y = self->data->cell_position.y;
|
||||
|
||||
sf::Vector2f vec = PyObject_to_sfVector2f(value);
|
||||
if (PyErr_Occurred()) return -1;
|
||||
|
||||
self->data->cell_position.x = static_cast<int>(vec.x);
|
||||
self->data->cell_position.y = static_cast<int>(vec.y);
|
||||
|
||||
// Update spatial hash
|
||||
if (self->data->grid) {
|
||||
self->data->grid->spatial_hash.updateCell(self->data, old_x, old_y);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
PyObject* UIEntity::get_cell_member(PyUIEntityObject* self, void* closure) {
|
||||
if (reinterpret_cast<intptr_t>(closure) == 0) {
|
||||
return PyLong_FromLong(self->data->cell_position.x);
|
||||
} else {
|
||||
return PyLong_FromLong(self->data->cell_position.y);
|
||||
}
|
||||
}
|
||||
|
||||
int UIEntity::set_cell_member(PyUIEntityObject* self, PyObject* value, void* closure) {
|
||||
long val = PyLong_AsLong(value);
|
||||
if (val == -1 && PyErr_Occurred()) return -1;
|
||||
|
||||
int old_x = self->data->cell_position.x;
|
||||
int old_y = self->data->cell_position.y;
|
||||
|
||||
if (reinterpret_cast<intptr_t>(closure) == 0) {
|
||||
self->data->cell_position.x = static_cast<int>(val);
|
||||
} else {
|
||||
self->data->cell_position.y = static_cast<int>(val);
|
||||
}
|
||||
|
||||
if (self->data->grid) {
|
||||
self->data->grid->spatial_hash.updateCell(self->data, old_x, old_y);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
PyMethodDef UIEntity_all_methods[] = {
|
||||
UIDRAWABLE_METHODS_BASE,
|
||||
{"animate", (PyCFunction)UIEntity::animate, METH_VARARGS | METH_KEYWORDS,
|
||||
|
|
@ -1067,6 +1248,16 @@ PyMethodDef UIEntity_all_methods[] = {
|
|||
" List of Entity objects that are within field of view.\n\n"
|
||||
"Computes FOV from this entity's position and returns all other entities\n"
|
||||
"whose positions fall within the visible area."},
|
||||
// #296 - Label methods
|
||||
{"add_label", (PyCFunction)UIEntity::py_add_label, METH_O,
|
||||
"add_label(label: str) -> None\n\n"
|
||||
"Add a label to this entity. Idempotent (adding same label twice is safe)."},
|
||||
{"remove_label", (PyCFunction)UIEntity::py_remove_label, METH_O,
|
||||
"remove_label(label: str) -> None\n\n"
|
||||
"Remove a label from this entity. No-op if label not present."},
|
||||
{"has_label", (PyCFunction)UIEntity::py_has_label, METH_O,
|
||||
"has_label(label: str) -> bool\n\n"
|
||||
"Check if this entity has the given label."},
|
||||
{NULL} // Sentinel
|
||||
};
|
||||
|
||||
|
|
@ -1080,13 +1271,21 @@ PyGetSetDef UIEntity::getsetters[] = {
|
|||
{"y", (getter)UIEntity::get_pixel_member, (setter)UIEntity::set_pixel_member,
|
||||
"Pixel Y position relative to grid. Requires entity to be attached to a grid.", (void*)1},
|
||||
|
||||
// #176 - Integer tile coordinates (logical game position)
|
||||
{"grid_pos", (getter)UIEntity::get_position, (setter)UIEntity::set_position,
|
||||
"Grid position as integer tile coordinates (Vector). The logical cell this entity occupies.", (void*)1},
|
||||
{"grid_x", (getter)UIEntity::get_grid_int_member, (setter)UIEntity::set_grid_int_member,
|
||||
"Grid X position as integer tile coordinate.", (void*)0},
|
||||
{"grid_y", (getter)UIEntity::get_grid_int_member, (setter)UIEntity::set_grid_int_member,
|
||||
"Grid Y position as integer tile coordinate.", (void*)1},
|
||||
// #295 - Integer cell position (decoupled from float draw_pos)
|
||||
{"cell_pos", (getter)UIEntity::get_cell_pos, (setter)UIEntity::set_cell_pos,
|
||||
"Integer logical cell position (Vector). Decoupled from draw_pos. "
|
||||
"Determines which cell this entity logically occupies for collision, pathfinding, etc.", NULL},
|
||||
{"cell_x", (getter)UIEntity::get_cell_member, (setter)UIEntity::set_cell_member,
|
||||
"Integer X cell coordinate.", (void*)0},
|
||||
{"cell_y", (getter)UIEntity::get_cell_member, (setter)UIEntity::set_cell_member,
|
||||
"Integer Y cell coordinate.", (void*)1},
|
||||
// grid_pos now aliases cell_pos (#295 BREAKING: no longer derives from float position)
|
||||
{"grid_pos", (getter)UIEntity::get_cell_pos, (setter)UIEntity::set_cell_pos,
|
||||
"Grid position as integer cell coordinates (Vector). Alias for cell_pos.", NULL},
|
||||
{"grid_x", (getter)UIEntity::get_cell_member, (setter)UIEntity::set_cell_member,
|
||||
"Grid X position as integer cell coordinate. Alias for cell_x.", (void*)0},
|
||||
{"grid_y", (getter)UIEntity::get_cell_member, (setter)UIEntity::set_cell_member,
|
||||
"Grid Y position as integer cell coordinate. Alias for cell_y.", (void*)1},
|
||||
|
||||
// Float tile coordinates (for smooth animation between tiles)
|
||||
{"draw_pos", (getter)UIEntity::get_position, (setter)UIEntity::set_position,
|
||||
|
|
@ -1116,6 +1315,18 @@ PyGetSetDef UIEntity::getsetters[] = {
|
|||
"X component of sprite pixel offset.", (void*)0},
|
||||
{"sprite_offset_y", (getter)UIEntity::get_sprite_offset_member, (setter)UIEntity::set_sprite_offset_member,
|
||||
"Y component of sprite pixel offset.", (void*)1},
|
||||
// #296 - Label system
|
||||
{"labels", (getter)UIEntity::get_labels, (setter)UIEntity::set_labels,
|
||||
"Set of string labels for collision/targeting (frozenset). "
|
||||
"Assign any iterable of strings to replace all labels.", NULL},
|
||||
// #299 - Step callback and default behavior
|
||||
{"step", (getter)UIEntity::get_step, (setter)UIEntity::set_step,
|
||||
"Step callback for grid.step() turn management. "
|
||||
"Called with (trigger, data) when behavior triggers fire. "
|
||||
"Set to None to clear.", NULL},
|
||||
{"default_behavior", (getter)UIEntity::get_default_behavior, (setter)UIEntity::set_default_behavior,
|
||||
"Default behavior type (int, maps to Behavior enum). "
|
||||
"Entity reverts to this after DONE trigger. Default: 0 (IDLE).", NULL},
|
||||
{NULL} /* Sentinel */
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
#include "IndexTexture.h"
|
||||
#include "Resources.h"
|
||||
#include <list>
|
||||
#include <unordered_set>
|
||||
#include <string>
|
||||
|
||||
#include "PyCallable.h"
|
||||
#include "PyTexture.h"
|
||||
|
|
@ -66,7 +68,11 @@ public:
|
|||
std::vector<UIGridPointState> gridstate;
|
||||
UISprite sprite;
|
||||
sf::Vector2f position; //(x,y) in grid coordinates; float for animation
|
||||
sf::Vector2i cell_position{0, 0}; // #295: integer logical position (decoupled from float position)
|
||||
sf::Vector2f sprite_offset; // pixel offset for oversized sprites (applied pre-zoom)
|
||||
std::unordered_set<std::string> labels; // #296: entity label system for collision/targeting
|
||||
PyObject* step_callback = nullptr; // #299: callback for grid.step() turn management
|
||||
int default_behavior = 0; // #299: BehaviorType::IDLE - behavior to revert to after DONE
|
||||
//void render(sf::Vector2f); //override final;
|
||||
|
||||
UIEntity();
|
||||
|
|
@ -80,6 +86,9 @@ public:
|
|||
pyobject = nullptr;
|
||||
Py_DECREF(tmp);
|
||||
}
|
||||
// #299: Clean up step callback
|
||||
Py_XDECREF(step_callback);
|
||||
step_callback = nullptr;
|
||||
}
|
||||
|
||||
// Visibility methods
|
||||
|
|
@ -131,6 +140,26 @@ public:
|
|||
static int set_sprite_offset(PyUIEntityObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_sprite_offset_member(PyUIEntityObject* self, void* closure);
|
||||
static int set_sprite_offset_member(PyUIEntityObject* self, PyObject* value, void* closure);
|
||||
|
||||
// #295 - cell_pos (integer logical position)
|
||||
static PyObject* get_cell_pos(PyUIEntityObject* self, void* closure);
|
||||
static int set_cell_pos(PyUIEntityObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_cell_member(PyUIEntityObject* self, void* closure);
|
||||
static int set_cell_member(PyUIEntityObject* self, PyObject* value, void* closure);
|
||||
|
||||
// #296 - Label system
|
||||
static PyObject* get_labels(PyUIEntityObject* self, void* closure);
|
||||
static int set_labels(PyUIEntityObject* self, PyObject* value, void* closure);
|
||||
|
||||
// #299 - Step callback and default behavior
|
||||
static PyObject* get_step(PyUIEntityObject* self, void* closure);
|
||||
static int set_step(PyUIEntityObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_default_behavior(PyUIEntityObject* self, void* closure);
|
||||
static int set_default_behavior(PyUIEntityObject* self, PyObject* value, void* closure);
|
||||
static PyObject* py_add_label(PyUIEntityObject* self, PyObject* arg);
|
||||
static PyObject* py_remove_label(PyUIEntityObject* self, PyObject* arg);
|
||||
static PyObject* py_has_label(PyUIEntityObject* self, PyObject* arg);
|
||||
|
||||
static PyMethodDef methods[];
|
||||
static PyGetSetDef getsetters[];
|
||||
static PyObject* repr(PyUIEntityObject* self);
|
||||
|
|
|
|||
|
|
@ -50,21 +50,23 @@ def test_entity_positions():
|
|||
if abs(entity.y - 80.0) > 0.001:
|
||||
errors.append(f"y: expected 80.0, got {entity.y}")
|
||||
|
||||
# Test 6: Setting grid_x/grid_y should update position
|
||||
# Test 6: Setting grid_x/grid_y should update cell position (#295: decoupled from pixel pos)
|
||||
entity.grid_x = 7
|
||||
entity.grid_y = 2
|
||||
if entity.grid_x != 7 or entity.grid_y != 2:
|
||||
errors.append(f"After setting grid_x/y: expected (7, 2), got ({entity.grid_x}, {entity.grid_y})")
|
||||
# Pixel should update too: (7, 2) * 16 = (112, 32)
|
||||
if abs(entity.x - 112.0) > 0.001 or abs(entity.y - 32.0) > 0.001:
|
||||
errors.append(f"After grid_x/y set, pixel pos: expected (112, 32), got ({entity.x}, {entity.y})")
|
||||
# #295: cell_pos (grid_x/y) is decoupled from pixel pos - pixel pos NOT updated
|
||||
# Pixel pos should remain at the draw_pos * tile_size (3*16=48, 5*16=80 from earlier)
|
||||
if abs(entity.x - 48.0) > 0.001 or abs(entity.y - 80.0) > 0.001:
|
||||
errors.append(f"After grid_x/y set, pixel pos should be unchanged: expected (48, 80), got ({entity.x}, {entity.y})")
|
||||
|
||||
# Test 7: Setting pos (pixels) should update grid position
|
||||
# Test 7: Setting pos (pixels) should update draw_pos but NOT grid_pos (#295)
|
||||
entity.pos = mcrfpy.Vector(64, 96) # (64, 96) / 16 = (4, 6) tiles
|
||||
if abs(entity.draw_pos.x - 4.0) > 0.001 or abs(entity.draw_pos.y - 6.0) > 0.001:
|
||||
errors.append(f"After setting pos, draw_pos: expected (4, 6), got ({entity.draw_pos.x}, {entity.draw_pos.y})")
|
||||
if entity.grid_x != 4 or entity.grid_y != 6:
|
||||
errors.append(f"After setting pos, grid_x/y: expected (4, 6), got ({entity.grid_x}, {entity.grid_y})")
|
||||
# #295: grid_pos is cell_pos, not derived from float position - should be (7, 2) from above
|
||||
if entity.grid_x != 7 or entity.grid_y != 2:
|
||||
errors.append(f"After setting pos, grid_x/y should be unchanged: expected (7, 2), got ({entity.grid_x}, {entity.grid_y})")
|
||||
|
||||
# Test 8: repr should show position info
|
||||
repr_str = repr(entity)
|
||||
|
|
|
|||
78
tests/regression/issue_295_cell_pos_test.py
Normal file
78
tests/regression/issue_295_cell_pos_test.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""Regression test for #295: Entity cell_pos integer logical position."""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
def test_cell_pos_init():
|
||||
"""cell_pos initialized from grid_pos on construction."""
|
||||
e = mcrfpy.Entity((5, 7))
|
||||
assert e.cell_x == 5, f"cell_x should be 5, got {e.cell_x}"
|
||||
assert e.cell_y == 7, f"cell_y should be 7, got {e.cell_y}"
|
||||
print("PASS: cell_pos initialized from constructor")
|
||||
|
||||
def test_cell_pos_grid_pos_alias():
|
||||
"""grid_pos is an alias for cell_pos."""
|
||||
e = mcrfpy.Entity((3, 4))
|
||||
# grid_pos should match cell_pos
|
||||
assert e.grid_pos.x == e.cell_pos.x
|
||||
assert e.grid_pos.y == e.cell_pos.y
|
||||
|
||||
# Setting grid_pos should update cell_pos
|
||||
e.grid_pos = (10, 20)
|
||||
assert e.cell_x == 10
|
||||
assert e.cell_y == 20
|
||||
print("PASS: grid_pos aliases cell_pos")
|
||||
|
||||
def test_cell_pos_independent_from_draw_pos():
|
||||
"""cell_pos does not change when draw_pos (float position) changes."""
|
||||
e = mcrfpy.Entity((5, 5))
|
||||
|
||||
# Change draw_pos (float position for rendering)
|
||||
e.draw_pos = (5.5, 5.5)
|
||||
|
||||
# cell_pos should be unchanged
|
||||
assert e.cell_x == 5, f"cell_x should still be 5 after draw_pos change, got {e.cell_x}"
|
||||
assert e.cell_y == 5, f"cell_y should still be 5 after draw_pos change, got {e.cell_y}"
|
||||
print("PASS: cell_pos independent from draw_pos")
|
||||
|
||||
def test_cell_pos_spatial_hash():
|
||||
"""GridPoint.entities uses cell_pos for matching."""
|
||||
scene = mcrfpy.Scene("test295")
|
||||
mcrfpy.current_scene = scene
|
||||
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
||||
grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320))
|
||||
scene.children.append(grid)
|
||||
|
||||
e = mcrfpy.Entity((5, 5), grid=grid)
|
||||
|
||||
# Entity should appear at cell (5, 5)
|
||||
assert len(grid.at(5, 5).entities) == 1
|
||||
|
||||
# Move cell_pos to (10, 10)
|
||||
e.cell_pos = (10, 10)
|
||||
assert len(grid.at(5, 5).entities) == 0, "Old cell should be empty"
|
||||
assert len(grid.at(10, 10).entities) == 1, "New cell should have entity"
|
||||
print("PASS: spatial hash uses cell_pos")
|
||||
|
||||
def test_cell_pos_member_access():
|
||||
"""cell_x and cell_y read/write correctly."""
|
||||
e = mcrfpy.Entity((3, 7))
|
||||
assert e.cell_x == 3
|
||||
assert e.cell_y == 7
|
||||
|
||||
e.cell_x = 15
|
||||
assert e.cell_x == 15
|
||||
assert e.cell_y == 7 # y unchanged
|
||||
|
||||
e.cell_y = 20
|
||||
assert e.cell_x == 15 # x unchanged
|
||||
assert e.cell_y == 20
|
||||
print("PASS: cell_x/cell_y member access")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_cell_pos_init()
|
||||
test_cell_pos_grid_pos_alias()
|
||||
test_cell_pos_independent_from_draw_pos()
|
||||
test_cell_pos_spatial_hash()
|
||||
test_cell_pos_member_access()
|
||||
print("All #295 tests passed")
|
||||
sys.exit(0)
|
||||
70
tests/unit/behavior_trigger_enum_test.py
Normal file
70
tests/unit/behavior_trigger_enum_test.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""Unit test for #297/#298: Behavior and Trigger enums."""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
def test_behavior_values():
|
||||
"""All Behavior enum values are accessible and have correct int values."""
|
||||
expected = {
|
||||
"IDLE": 0, "CUSTOM": 1, "NOISE4": 2, "NOISE8": 3,
|
||||
"PATH": 4, "WAYPOINT": 5, "PATROL": 6, "LOOP": 7,
|
||||
"SLEEP": 8, "SEEK": 9, "FLEE": 10,
|
||||
}
|
||||
for name, value in expected.items():
|
||||
b = getattr(mcrfpy.Behavior, name)
|
||||
assert int(b) == value, f"Behavior.{name} should be {value}, got {int(b)}"
|
||||
print("PASS: All Behavior values correct")
|
||||
|
||||
def test_trigger_values():
|
||||
"""All Trigger enum values are accessible and have correct int values."""
|
||||
expected = {"DONE": 0, "BLOCKED": 1, "TARGET": 2}
|
||||
for name, value in expected.items():
|
||||
t = getattr(mcrfpy.Trigger, name)
|
||||
assert int(t) == value, f"Trigger.{name} should be {value}, got {int(t)}"
|
||||
print("PASS: All Trigger values correct")
|
||||
|
||||
def test_string_comparison():
|
||||
"""Enums compare equal to their name strings."""
|
||||
assert mcrfpy.Behavior.IDLE == "IDLE"
|
||||
assert mcrfpy.Behavior.SEEK == "SEEK"
|
||||
assert mcrfpy.Trigger.DONE == "DONE"
|
||||
assert not (mcrfpy.Behavior.IDLE == "SEEK")
|
||||
print("PASS: String comparison works")
|
||||
|
||||
def test_inequality():
|
||||
"""__ne__ works correctly for both string and int."""
|
||||
assert mcrfpy.Behavior.IDLE != "SEEK"
|
||||
assert not (mcrfpy.Behavior.IDLE != "IDLE")
|
||||
assert mcrfpy.Trigger.DONE != 1
|
||||
assert not (mcrfpy.Trigger.DONE != 0)
|
||||
print("PASS: Inequality works")
|
||||
|
||||
def test_int_comparison():
|
||||
"""Enums compare equal to their integer values."""
|
||||
assert mcrfpy.Behavior.FLEE == 10
|
||||
assert mcrfpy.Trigger.BLOCKED == 1
|
||||
print("PASS: Int comparison works")
|
||||
|
||||
def test_repr():
|
||||
"""Repr format is ClassName.MEMBER."""
|
||||
assert repr(mcrfpy.Behavior.IDLE) == "Behavior.IDLE"
|
||||
assert repr(mcrfpy.Trigger.TARGET) == "Trigger.TARGET"
|
||||
print("PASS: Repr format correct")
|
||||
|
||||
def test_hashable():
|
||||
"""Enum values are hashable (can be used in sets/dicts)."""
|
||||
s = {mcrfpy.Behavior.IDLE, mcrfpy.Behavior.SEEK}
|
||||
assert len(s) == 2
|
||||
d = {mcrfpy.Trigger.DONE: "done", mcrfpy.Trigger.TARGET: "target"}
|
||||
assert d[mcrfpy.Trigger.DONE] == "done"
|
||||
print("PASS: Hashable")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_behavior_values()
|
||||
test_trigger_values()
|
||||
test_string_comparison()
|
||||
test_inequality()
|
||||
test_int_comparison()
|
||||
test_repr()
|
||||
test_hashable()
|
||||
print("All #297/#298 tests passed")
|
||||
sys.exit(0)
|
||||
75
tests/unit/entity_labels_test.py
Normal file
75
tests/unit/entity_labels_test.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Unit test for #296: Entity label system."""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
def test_labels_crud():
|
||||
"""Basic add/remove/has operations."""
|
||||
e = mcrfpy.Entity()
|
||||
assert len(e.labels) == 0, "New entity should have no labels"
|
||||
|
||||
e.add_label("solid")
|
||||
assert e.has_label("solid"), "Should have 'solid' after add"
|
||||
assert not e.has_label("npc"), "Should not have 'npc'"
|
||||
|
||||
e.add_label("npc")
|
||||
assert len(e.labels) == 2
|
||||
|
||||
e.remove_label("solid")
|
||||
assert not e.has_label("solid"), "Should not have 'solid' after remove"
|
||||
assert e.has_label("npc"), "'npc' should remain"
|
||||
print("PASS: labels CRUD")
|
||||
|
||||
def test_labels_frozenset():
|
||||
"""Labels getter returns frozenset."""
|
||||
e = mcrfpy.Entity()
|
||||
e.add_label("a")
|
||||
e.add_label("b")
|
||||
labels = e.labels
|
||||
assert isinstance(labels, frozenset), f"Expected frozenset, got {type(labels)}"
|
||||
assert labels == frozenset({"a", "b"})
|
||||
print("PASS: labels returns frozenset")
|
||||
|
||||
def test_labels_setter():
|
||||
"""Labels setter accepts any iterable of strings."""
|
||||
e = mcrfpy.Entity()
|
||||
e.labels = ["x", "y", "z"]
|
||||
assert e.labels == frozenset({"x", "y", "z"})
|
||||
|
||||
e.labels = {"replaced"}
|
||||
assert e.labels == frozenset({"replaced"})
|
||||
|
||||
e.labels = frozenset()
|
||||
assert len(e.labels) == 0
|
||||
print("PASS: labels setter")
|
||||
|
||||
def test_labels_constructor():
|
||||
"""Labels kwarg in constructor."""
|
||||
e = mcrfpy.Entity(labels={"solid", "npc"})
|
||||
assert e.has_label("solid")
|
||||
assert e.has_label("npc")
|
||||
assert len(e.labels) == 2
|
||||
print("PASS: labels constructor kwarg")
|
||||
|
||||
def test_labels_duplicate_add():
|
||||
"""Adding same label twice is idempotent."""
|
||||
e = mcrfpy.Entity()
|
||||
e.add_label("solid")
|
||||
e.add_label("solid")
|
||||
assert len(e.labels) == 1
|
||||
print("PASS: duplicate add is idempotent")
|
||||
|
||||
def test_labels_remove_missing():
|
||||
"""Removing non-existent label is a no-op."""
|
||||
e = mcrfpy.Entity()
|
||||
e.remove_label("nonexistent") # Should not raise
|
||||
print("PASS: remove missing label is no-op")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_labels_crud()
|
||||
test_labels_frozenset()
|
||||
test_labels_setter()
|
||||
test_labels_constructor()
|
||||
test_labels_duplicate_add()
|
||||
test_labels_remove_missing()
|
||||
print("All #296 tests passed")
|
||||
sys.exit(0)
|
||||
72
tests/unit/entity_step_callback_test.py
Normal file
72
tests/unit/entity_step_callback_test.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""Unit test for #299: Entity step() callback and default_behavior."""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
def test_step_callback_assignment():
|
||||
"""step callback can be assigned and retrieved."""
|
||||
e = mcrfpy.Entity()
|
||||
assert e.step is None, "Initial step should be None"
|
||||
|
||||
def my_step(trigger, data):
|
||||
pass
|
||||
|
||||
e.step = my_step
|
||||
assert e.step is my_step, "step should be the assigned callable"
|
||||
print("PASS: step callback assignment")
|
||||
|
||||
def test_step_callback_clear():
|
||||
"""Setting step to None clears the callback."""
|
||||
e = mcrfpy.Entity()
|
||||
e.step = lambda t, d: None
|
||||
assert e.step is not None
|
||||
|
||||
e.step = None
|
||||
assert e.step is None, "step should be None after clearing"
|
||||
print("PASS: step callback clear")
|
||||
|
||||
def test_step_callback_type_check():
|
||||
"""step rejects non-callable values."""
|
||||
e = mcrfpy.Entity()
|
||||
try:
|
||||
e.step = 42
|
||||
assert False, "Should have raised TypeError"
|
||||
except TypeError:
|
||||
pass
|
||||
print("PASS: step type check")
|
||||
|
||||
def test_default_behavior_roundtrip():
|
||||
"""default_behavior property round-trips correctly."""
|
||||
e = mcrfpy.Entity()
|
||||
assert e.default_behavior == 0, "Initial default_behavior should be 0 (IDLE)"
|
||||
|
||||
e.default_behavior = int(mcrfpy.Behavior.SEEK)
|
||||
assert e.default_behavior == 9
|
||||
|
||||
e.default_behavior = int(mcrfpy.Behavior.IDLE)
|
||||
assert e.default_behavior == 0
|
||||
print("PASS: default_behavior round-trip")
|
||||
|
||||
def test_step_callback_subclass():
|
||||
"""Subclass def step() overrides the C getter - this is expected behavior.
|
||||
Phase 3 grid.step() will check for subclass method override via PyObject_GetAttrString."""
|
||||
class Guard(mcrfpy.Entity):
|
||||
def on_step(self, trigger, data):
|
||||
self.stepped = True
|
||||
|
||||
g = Guard()
|
||||
# The step property should be None (no callback assigned)
|
||||
assert g.step is None, "step callback should be None initially"
|
||||
|
||||
# Subclass methods with different names are accessible
|
||||
assert hasattr(g, 'on_step'), "subclass should have on_step method"
|
||||
assert callable(g.on_step)
|
||||
print("PASS: subclass step method coexistence")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_step_callback_assignment()
|
||||
test_step_callback_clear()
|
||||
test_step_callback_type_check()
|
||||
test_default_behavior_roundtrip()
|
||||
test_step_callback_subclass()
|
||||
print("All #299 tests passed")
|
||||
sys.exit(0)
|
||||
Loading…
Add table
Add a link
Reference in a new issue