Replace UIEntity gridstate with DiscreteMap perspective_map; closes #294

Per-entity FOV memory moves from std::vector<UIGridPointState> (two-bool
visible/discovered pairs) to a 3-state DiscreteMap (0=UNKNOWN, 1=DISCOVERED,
2=VISIBLE), exposed as entity.perspective_map. The invariant
visible-subset-of-discovered becomes structural (single value per cell), and
the map is a live, serializable, first-class object rather than an implicit
internal array.

Changes:
- New DiscreteMap C++ class with shared ownership; PyDiscreteMapObject now
  holds shared_ptr<DiscreteMap>. UIEntity holds the same shared_ptr.
- New mcrfpy.Perspective IntEnum (UNKNOWN/DISCOVERED/VISIBLE), modelled on
  PyInputState.
- entity.perspective_map: lazy-allocated on first access with a grid;
  setter validates size against grid and raises ValueError on mismatch;
  None clears (next access lazy-reallocates fresh).
- updateVisibility() now demotes 2->1 then promotes visible cells to 2.
- entity.at(x, y) returns grid.at(x, y) when VISIBLE, else None.
- Fog-of-war rendering in UIGridView and UIGrid reads the 3-state map.
- Removed: UIEntity::gridstate, ensureGridstate(), entity.gridstate getter,
  UIGridPointState struct + PyUIGridPointStateType.
- Obsolete tests deleted (test_gridpointstate_point,
  issue_265_gridpointstate_dangle); 4 new tests cover lazy allocation,
  identity, serialization round-trip, size validation, and the
  visible-subset-of-discovered invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-04-17 23:04:27 -04:00
commit f797120d53
24 changed files with 7998 additions and 1906 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

27
src/DiscreteMap.cpp Normal file
View file

@ -0,0 +1,27 @@
#include "DiscreteMap.h"
#include <cstring>
#include <stdexcept>
DiscreteMap::DiscreteMap(int w, int h, uint8_t fill)
: w_(w), h_(h), values_(nullptr)
{
if (w_ <= 0 || h_ <= 0) {
throw std::invalid_argument("DiscreteMap dimensions must be positive");
}
size_t total = static_cast<size_t>(w_) * static_cast<size_t>(h_);
values_ = new uint8_t[total];
std::memset(values_, fill, total);
}
DiscreteMap::~DiscreteMap()
{
delete[] values_;
}
void DiscreteMap::demoteVisible()
{
size_t total = size();
for (size_t i = 0; i < total; ++i) {
if (values_[i] == 2) values_[i] = 1;
}
}

35
src/DiscreteMap.h Normal file
View file

@ -0,0 +1,35 @@
#pragma once
#include <cstddef>
#include <cstdint>
// DiscreteMap - a dense uint8 grid owning its buffer.
//
// Shared-ownership C++ type wrapped by PyDiscreteMapObject and also held by
// UIEntity for per-entity perspective (issue #294). Dimensions are fixed at
// construction; use size() / width() / height() for bounds checks.
class DiscreteMap {
public:
DiscreteMap(int w, int h, uint8_t fill = 0);
~DiscreteMap();
DiscreteMap(const DiscreteMap&) = delete;
DiscreteMap& operator=(const DiscreteMap&) = delete;
int width() const { return w_; }
int height() const { return h_; }
size_t size() const { return static_cast<size_t>(w_) * static_cast<size_t>(h_); }
uint8_t* data() { return values_; }
const uint8_t* data() const { return values_; }
// Demote every cell with value == 2 back to 1. Used at the start of
// UIEntity::updateVisibility() -- the 3-state perspective model promotes
// freshly-visible cells from 1 (or 0) to 2, and this call handles the
// per-tick demotion of "was visible last tick" to "discovered".
void demoteVisible();
private:
int w_;
int h_;
uint8_t* values_;
};

View file

@ -16,6 +16,7 @@
#include "PyKey.h"
#include "PyMouseButton.h"
#include "PyInputState.h"
#include "PyPerspective.h"
#include "PyBehavior.h"
#include "PyTrigger.h"
#include "UIGridView.h"
@ -120,7 +121,7 @@ static PyObject* mcrfpy_sync_storage(PyObject* self, PyObject* args)
#ifdef __EMSCRIPTEN__
sync_storage();
#endif
// On desktop, writes go directly to disk nothing to sync
// On desktop, writes go directly to disk -- nothing to sync
Py_RETURN_NONE;
}
@ -550,8 +551,8 @@ PyObject* PyInit_mcrfpy()
/*#252: internal grid data type - UIGrid is now internal, GridView is "Grid"*/
&PyUIGridType,
/*game map & perspective data - returned by Grid.at() but not directly instantiable*/
&PyUIGridPointType, &PyUIGridPointStateType,
/*game map data - returned by Grid.at() but not directly instantiable*/
&PyUIGridPointType,
/*3D navigation grid - returned by Viewport3D.at() but not directly instantiable*/
&mcrfpydef::PyVoxelPointType,
@ -786,6 +787,12 @@ PyObject* PyInit_mcrfpy()
PyErr_Clear();
}
// Add Perspective enum class for entity perspective_map values (#294)
PyObject* perspective_class = PyPerspective::create_enum_class(m);
if (!perspective_class) {
PyErr_Clear();
}
// Add Alignment enum class for automatic child positioning
PyObject* alignment_class = PyAlignment::create_enum_class(m);
if (!alignment_class) {

View file

@ -368,6 +368,9 @@ PyObject* PyDiscreteMap::pynew(PyTypeObject* type, PyObject* args, PyObject* kwd
{
PyDiscreteMapObject* self = (PyDiscreteMapObject*)type->tp_alloc(type, 0);
if (self) {
// Placement-new the shared_ptr member; tp_alloc zeroed memory but
// shared_ptr requires proper construction before assignment.
new (&self->data) std::shared_ptr<DiscreteMap>();
self->values = nullptr;
self->w = 0;
self->h = 0;
@ -419,27 +422,24 @@ int PyDiscreteMap::init(PyDiscreteMapObject* self, PyObject* args, PyObject* kwd
return -1;
}
// Clean up any existing data
if (self->values) {
delete[] self->values;
self->values = nullptr;
}
// Reset any existing storage (re-init supported)
self->data.reset();
Py_XDECREF(self->enum_type);
self->enum_type = nullptr;
// Allocate new array
size_t total_size = static_cast<size_t>(width) * static_cast<size_t>(height);
self->values = new (std::nothrow) uint8_t[total_size];
if (!self->values) {
// Construct shared-ownership C++ storage (issue #294)
try {
self->data = std::make_shared<DiscreteMap>(
width, height, static_cast<uint8_t>(fill_value));
} catch (const std::bad_alloc&) {
PyErr_SetString(PyExc_MemoryError, "Failed to allocate DiscreteMap");
return -1;
}
self->w = width;
self->h = height;
// Fill with initial value
memset(self->values, static_cast<uint8_t>(fill_value), total_size);
// Cache non-owning views for hot-path access
self->values = self->data->data();
self->w = self->data->width();
self->h = self->data->height();
// Store enum type if provided
if (enum_obj && enum_obj != Py_None) {
@ -452,10 +452,10 @@ int PyDiscreteMap::init(PyDiscreteMapObject* self, PyObject* args, PyObject* kwd
void PyDiscreteMap::dealloc(PyDiscreteMapObject* self)
{
if (self->values) {
delete[] self->values;
self->values = nullptr;
}
// Release shared ownership; DiscreteMap destructor frees the buffer
// if this is the last owner.
self->data.~shared_ptr<DiscreteMap>();
self->values = nullptr;
Py_XDECREF(self->enum_type);
self->enum_type = nullptr;
Py_TYPE(self)->tp_free((PyObject*)self);
@ -1387,14 +1387,19 @@ PyObject* PyDiscreteMap::from_bytes(PyTypeObject* type, PyObject* args, PyObject
return nullptr;
}
obj->w = w;
obj->h = h;
obj->values = new (std::nothrow) uint8_t[expected];
if (!obj->values) {
new (&obj->data) std::shared_ptr<DiscreteMap>();
try {
obj->data = std::make_shared<DiscreteMap>(w, h, 0);
} catch (const std::bad_alloc&) {
PyBuffer_Release(&buffer);
obj->data.~shared_ptr<DiscreteMap>();
Py_DECREF(obj);
return PyErr_NoMemory();
}
obj->w = w;
obj->h = h;
obj->values = obj->data->data();
std::memcpy(obj->values, buffer.buf, expected);
PyBuffer_Release(&buffer);

View file

@ -1,17 +1,27 @@
#pragma once
#include "Common.h"
#include "Python.h"
#include "DiscreteMap.h"
#include <cstdint>
#include <memory>
// Forward declaration
class PyDiscreteMap;
// Python object structure
// Python object structure.
//
// `data` owns the underlying buffer (shared with UIEntity::perspective_map
// when this DiscreteMap is an entity's perspective). `values`/`w`/`h` are
// cached non-owning views that point into `data` for fast hot-path access --
// they are populated in init() / from_bytes() / the perspective-map getter
// on UIEntity, and remain valid for the lifetime of this Python object
// because `data`'s dimensions are immutable after construction.
typedef struct {
PyObject_HEAD
uint8_t* values; // Row-major array (width * height)
int w, h; // Dimensions (max 8192x8192)
PyObject* enum_type; // Optional Python IntEnum for value interpretation
std::shared_ptr<DiscreteMap> data; // shared-ownership C++ storage (issue #294)
uint8_t* values; // cached data->data()
int w, h; // cached data->width()/height()
PyObject* enum_type; // Optional Python IntEnum for value interpretation
} PyDiscreteMapObject;
class PyDiscreteMap

127
src/PyPerspective.cpp Normal file
View file

@ -0,0 +1,127 @@
#include "PyPerspective.h"
#include <cstring>
#include <sstream>
PyObject* PyPerspective::perspective_enum_class = nullptr;
struct PerspectiveEntry {
const char* name;
int value;
};
static const PerspectiveEntry perspective_table[] = {
{"UNKNOWN", 0},
{"DISCOVERED", 1},
{"VISIBLE", 2},
};
static const int NUM_PERSPECTIVE_ENTRIES =
sizeof(perspective_table) / sizeof(perspective_table[0]);
PyObject* PyPerspective::create_enum_class(PyObject* module) {
std::ostringstream code;
code << "from enum import IntEnum\n\n";
code << "class Perspective(IntEnum):\n";
code << " \"\"\"Enum representing an entity's knowledge of a cell.\n";
code << " \n";
code << " Values:\n";
code << " UNKNOWN: Never seen (perspective_map value 0)\n";
code << " DISCOVERED: Seen before but not currently visible (value 1)\n";
code << " VISIBLE: In current FOV (value 2)\n";
code << " \"\"\"\n";
for (int i = 0; i < NUM_PERSPECTIVE_ENTRIES; i++) {
code << " " << perspective_table[i].name
<< " = " << perspective_table[i].value << "\n";
}
code << "\n";
code << "Perspective.__hash__ = lambda self: hash(int(self))\n";
code << "Perspective.__repr__ = lambda self: f\"{type(self).__name__}.{self.name}\"\n";
code << "Perspective.__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, "Perspective");
if (!enum_class) {
PyErr_SetString(PyExc_RuntimeError, "Failed to create Perspective enum class");
Py_DECREF(globals);
Py_DECREF(locals);
return NULL;
}
Py_INCREF(enum_class);
perspective_enum_class = enum_class;
Py_INCREF(perspective_enum_class);
if (PyModule_AddObject(module, "Perspective", enum_class) < 0) {
Py_DECREF(enum_class);
Py_DECREF(globals);
Py_DECREF(locals);
perspective_enum_class = nullptr;
return NULL;
}
Py_DECREF(globals);
Py_DECREF(locals);
return enum_class;
}
int PyPerspective::from_arg(PyObject* arg, uint8_t* out_value) {
if (perspective_enum_class && PyObject_IsInstance(arg, perspective_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 > 2) {
PyErr_Format(PyExc_ValueError, "Invalid Perspective value: %ld", val);
return 0;
}
*out_value = static_cast<uint8_t>(val);
return 1;
}
if (PyLong_Check(arg)) {
long val = PyLong_AsLong(arg);
if (val == -1 && PyErr_Occurred()) return 0;
if (val < 0 || val > 2) {
PyErr_Format(PyExc_ValueError,
"Invalid Perspective value: %ld. Must be 0, 1, or 2.", val);
return 0;
}
*out_value = static_cast<uint8_t>(val);
return 1;
}
if (PyUnicode_Check(arg)) {
const char* name = PyUnicode_AsUTF8(arg);
if (!name) return 0;
for (int i = 0; i < NUM_PERSPECTIVE_ENTRIES; i++) {
if (strcmp(name, perspective_table[i].name) == 0) {
*out_value = static_cast<uint8_t>(perspective_table[i].value);
return 1;
}
}
PyErr_Format(PyExc_ValueError,
"Unknown Perspective: '%s'. Use UNKNOWN, DISCOVERED, or VISIBLE.", name);
return 0;
}
PyErr_SetString(PyExc_TypeError,
"Perspective must be mcrfpy.Perspective enum member, string, or int");
return 0;
}

33
src/PyPerspective.h Normal file
View file

@ -0,0 +1,33 @@
#pragma once
#include "Common.h"
#include "Python.h"
#include <cstdint>
// Module-level Perspective enum class (created at runtime using Python's IntEnum)
// Stored as a module attribute: mcrfpy.Perspective
//
// Values:
// UNKNOWN = 0 (never seen)
// DISCOVERED = 1 (seen before, not currently visible)
// VISIBLE = 2 (in current FOV)
//
// Used as the value space for UIEntity.perspective_map (issue #294).
class PyPerspective {
public:
// Create the Perspective enum class and add to module.
// Returns the enum class (new reference), or NULL on error.
static PyObject* create_enum_class(PyObject* module);
// Helper to extract a Perspective value from a Python arg.
// Accepts Perspective enum member, string (enum name), or int 0/1/2.
// Returns 1 on success, 0 on error (with exception set).
static int from_arg(PyObject* arg, uint8_t* out_value);
// Cached reference to the Perspective enum class for fast type checking.
static PyObject* perspective_enum_class;
static const int NUM_PERSPECTIVE_STATES = 3;
static const uint8_t UNKNOWN = 0;
static const uint8_t DISCOVERED = 1;
static const uint8_t VISIBLE = 2;
};

View file

@ -9,6 +9,8 @@
#include "PyVector.h"
#include "PythonObjectCache.h"
#include "PyFOV.h"
#include "PyDiscreteMap.h" // #294: perspective_map wrapper
#include "PyPerspective.h" // #294: VISIBLE constant
#include "Animation.h"
#include "PyAnimation.h"
#include "PyEasing.h"
@ -23,8 +25,8 @@
UIEntity::UIEntity()
: grid(nullptr), position(0.0f, 0.0f), sprite_offset(0.0f, 0.0f)
{
// Initialize sprite with safe defaults (sprite has its own safe constructor now)
// gridstate vector starts empty - will be lazily initialized when needed
// perspective_map starts null; lazily allocated on first access or
// updateVisibility() call once a grid is set (#294).
}
UIEntity::~UIEntity() {
@ -36,30 +38,24 @@ UIEntity::~UIEntity() {
// Removed UIEntity(UIGrid&) constructor - using lazy initialization instead
void UIEntity::ensureGridstate()
{
if (!grid) return;
size_t expected = static_cast<size_t>(grid->grid_w) * grid->grid_h;
if (gridstate.size() != expected) {
gridstate.resize(expected);
for (auto& state : gridstate) {
state.visible = false;
state.discovered = false;
}
}
}
void UIEntity::updateVisibility()
{
if (!grid) return;
ensureGridstate();
// First, mark all cells as not visible
for (auto& state : gridstate) {
state.visible = false;
// Lazy-allocate or resize perspective_map if grid dimensions changed.
// Dimension mismatch wipes prior state -- the entity's memory has no
// identity with a differently-sized grid, so starting fresh is correct.
size_t expected = static_cast<size_t>(grid->grid_w) * grid->grid_h;
if (!perspective_map || perspective_map->size() != expected) {
perspective_map = std::make_shared<DiscreteMap>(grid->grid_w, grid->grid_h, 0);
}
// Demote visible (2) -> discovered (1) from prior tick. The invariant
// `visible subset of discovered` is structural in the 3-state model: cells
// currently visible will be re-promoted to 2 below, and cells that
// just left FOV fall to discovered.
perspective_map->demoteVisible();
// Compute FOV from entity's cell position (#114, #295)
int x = cell_position.x;
int y = cell_position.y;
@ -67,13 +63,13 @@ void UIEntity::updateVisibility()
// Use grid's configured FOV algorithm and radius
grid->computeFOV(x, y, grid->fov_radius, true, grid->fov_algorithm);
// Update visible cells based on FOV computation
// Promote visible cells to 2 (VISIBLE). Cells going 0 -> 2 are
// freshly discovered; cells going 1 -> 2 were already discovered.
uint8_t* buf = perspective_map->data();
for (int gy = 0; gy < grid->grid_h; gy++) {
for (int gx = 0; gx < grid->grid_w; gx++) {
int idx = gy * grid->grid_w + gx;
if (grid->isInFOV(gx, gy)) {
gridstate[idx].visible = true;
gridstate[idx].discovered = true; // Once seen, always discovered
buf[gy * grid->grid_w + gx] = PyPerspective::VISIBLE;
}
}
}
@ -106,30 +102,38 @@ void UIEntity::updateVisibility()
}
PyObject* UIEntity::at(PyUIEntityObject* self, PyObject* args, PyObject* kwds) {
// #294: at(x, y) returns grid.at(x, y) when the cell is currently VISIBLE
// to this entity, None otherwise. Equivalent to:
// self.grid.at(x, y) if self.perspective_map[x, y] == Perspective.VISIBLE else None
int x, y;
if (!PyPosition_ParseInt(args, kwds, &x, &y)) {
return NULL; // Error already set by PyPosition_ParseInt
}
if (self->data->grid == NULL) {
PyErr_SetString(PyExc_ValueError, "Entity cannot access surroundings because it is not associated with a grid");
auto& entity = self->data;
if (!entity->grid) {
PyErr_SetString(PyExc_ValueError,
"Entity cannot access surroundings because it is not associated with a grid");
return NULL;
}
self->data->ensureGridstate();
// Bounds check
if (x < 0 || x >= self->data->grid->grid_w || y < 0 || y >= self->data->grid->grid_h) {
if (x < 0 || x >= entity->grid->grid_w || y < 0 || y >= entity->grid->grid_h) {
PyErr_Format(PyExc_IndexError, "Grid coordinates (%d, %d) out of bounds", x, y);
return NULL;
}
// Use type directly since GridPointState is internal-only (not exported to module)
auto type = &mcrfpydef::PyUIGridPointStateType;
auto obj = (PyUIGridPointStateObject*)type->tp_alloc(type, 0);
obj->grid = self->data->grid;
obj->entity = self->data;
obj->x = x; // #16 - Store position for .point property
// No perspective yet or cell not visible -> None
if (!entity->perspective_map) Py_RETURN_NONE;
uint8_t state = entity->perspective_map->data()[y * entity->grid->grid_w + x];
if (state != PyPerspective::VISIBLE) Py_RETURN_NONE;
// Construct a GridPoint wrapper (same pattern as grid.at(x, y)).
auto type = &mcrfpydef::PyUIGridPointType;
auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0);
if (!obj) return NULL;
obj->grid = entity->grid;
obj->x = x;
obj->y = y;
return (PyObject*)obj;
}
@ -369,41 +373,6 @@ sf::Vector2i PyObject_to_sfVector2i(PyObject* obj) {
return sf::Vector2i(static_cast<int>(vec->data.x), static_cast<int>(vec->data.y));
}
PyObject* UIGridPointState_to_PyObject(const UIGridPointState& state) {
// Return a simple namespace with visible/discovered attributes
// (detached snapshot — not backed by a live entity's gridstate)
PyObject* types_mod = PyImport_ImportModule("types");
if (!types_mod) return NULL;
PyObject* ns_type = PyObject_GetAttrString(types_mod, "SimpleNamespace");
Py_DECREF(types_mod);
if (!ns_type) return NULL;
PyObject* kwargs = Py_BuildValue("{s:O,s:O}",
"visible", state.visible ? Py_True : Py_False,
"discovered", state.discovered ? Py_True : Py_False);
if (!kwargs) { Py_DECREF(ns_type); return NULL; }
PyObject* obj = PyObject_Call(ns_type, PyTuple_New(0), kwargs);
Py_DECREF(ns_type);
Py_DECREF(kwargs);
return (PyObject*)obj;
}
PyObject* UIGridPointStateVector_to_PyList(const std::vector<UIGridPointState>& vec) {
PyObject* list = PyList_New(vec.size());
if (!list) return PyErr_NoMemory();
for (size_t i = 0; i < vec.size(); ++i) {
PyObject* obj = UIGridPointState_to_PyObject(vec[i]);
if (!obj) { // Cleanup on failure
Py_DECREF(list);
return NULL;
}
PyList_SET_ITEM(list, i, obj); // This steals a reference to obj
}
return list;
}
PyObject* UIEntity::get_position(PyUIEntityObject* self, void* closure) {
if (reinterpret_cast<intptr_t>(closure) == 0) {
return sfVector2f_to_PyObject(self->data->position);
@ -444,9 +413,71 @@ int UIEntity::set_position(PyUIEntityObject* self, PyObject* value, void* closur
return 0;
}
PyObject* UIEntity::get_gridstate(PyUIEntityObject* self, void* closure) {
// Assuming a function to convert std::vector<UIGridPointState> to PyObject* list
return UIGridPointStateVector_to_PyList(self->data->gridstate);
// #294: perspective_map property. Returns a live DiscreteMap reference
// (not a snapshot); lazy-allocates on first access when a grid is set.
PyObject* UIEntity::get_perspective_map(PyUIEntityObject* self, void* closure) {
auto& entity = self->data;
if (!entity->grid) Py_RETURN_NONE;
if (!entity->perspective_map) {
entity->perspective_map = std::make_shared<DiscreteMap>(
entity->grid->grid_w, entity->grid->grid_h, 0);
}
// Wrap in PyDiscreteMapObject sharing the same shared_ptr.
auto type = &mcrfpydef::PyDiscreteMapType;
auto obj = (PyDiscreteMapObject*)type->tp_alloc(type, 0);
if (!obj) return NULL;
new (&obj->data) std::shared_ptr<DiscreteMap>(entity->perspective_map);
obj->values = obj->data->data();
obj->w = obj->data->width();
obj->h = obj->data->height();
obj->enum_type = PyPerspective::perspective_enum_class;
if (obj->enum_type) Py_INCREF(obj->enum_type);
return (PyObject*)obj;
}
// #294: Assign a DiscreteMap as the entity's perspective. The incoming map
// must match the grid's current dimensions; otherwise ValueError is raised.
// Assigning None clears the perspective (it will be lazy-reallocated on next
// access or updateVisibility()).
int UIEntity::set_perspective_map(PyUIEntityObject* self, PyObject* value, void* closure) {
auto& entity = self->data;
if (value == NULL || value == Py_None) {
entity->perspective_map.reset();
return 0;
}
if (!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyDiscreteMapType)) {
PyErr_SetString(PyExc_TypeError,
"perspective_map must be a DiscreteMap or None");
return -1;
}
if (!entity->grid) {
PyErr_SetString(PyExc_ValueError,
"Cannot assign perspective_map: entity has no grid");
return -1;
}
auto* incoming = (PyDiscreteMapObject*)value;
if (!incoming->data) {
PyErr_SetString(PyExc_ValueError,
"perspective_map DiscreteMap is not initialized");
return -1;
}
if (incoming->data->width() != entity->grid->grid_w ||
incoming->data->height() != entity->grid->grid_h) {
PyErr_Format(PyExc_ValueError,
"DiscreteMap size (%d, %d) does not match grid size (%d, %d)",
incoming->data->width(), incoming->data->height(),
entity->grid->grid_w, entity->grid->grid_h);
return -1;
}
entity->perspective_map = incoming->data; // share ownership
return 0;
}
int UIEntity::set_spritenumber(PyUIEntityObject* self, PyObject* value, void* closure) {
@ -754,7 +785,7 @@ int UIEntity::set_grid(PyUIEntityObject* self, PyObject* value, void* closure)
}
self->data->grid.reset();
// Release identity strong ref entity left grid
// Release identity strong ref -- entity left grid
self->data->releasePyIdentity();
}
return 0;
@ -796,9 +827,11 @@ int UIEntity::set_grid(PyUIEntityObject* self, PyObject* value, void* closure)
new_grid->entities->push_back(self->data);
self->data->grid = new_grid;
new_grid->spatial_hash.insert(self->data); // #274
// Resize gridstate to match new grid dimensions
self->data->ensureGridstate();
// #294: perspective_map is lazy -- the next updateVisibility() call
// (or first `entity.perspective_map` access) allocates sized to the
// new grid. We deliberately do NOT preserve or clear the old map:
// game code that wants per-grid memory should save/restore via
// to_bytes/from_bytes and assign before calling updateVisibility.
}
return 0;
@ -1023,7 +1056,7 @@ PyObject* UIEntity::die(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored))
// Clear the grid reference
self->data->grid.reset();
// Release identity strong ref entity is no longer in a grid
// Release identity strong ref -- entity is no longer in a grid
self->data->releasePyIdentity();
}
@ -1246,17 +1279,20 @@ PyObject* UIEntity::visible_entities(PyUIEntityObject* self, PyObject* args, PyO
PyMethodDef UIEntity::methods[] = {
{"at", (PyCFunction)UIEntity::at, METH_VARARGS | METH_KEYWORDS,
"at(x, y) or at(pos) -> GridPointState\n\n"
"Get the grid point state at the specified position.\n\n"
"at(x, y) or at(pos) -> GridPoint | None\n\n"
"Return the GridPoint at (x, y) if currently VISIBLE to this entity's\n"
"perspective_map, otherwise None. Equivalent to:\n"
" grid.at(x, y) if perspective_map[x, y] == Perspective.VISIBLE else None\n\n"
"To inspect discovered-but-not-visible cells, read entity.perspective_map[x, y]\n"
"directly and use grid.at(x, y) for cell data.\n\n"
"Args:\n"
" x, y: Grid coordinates as two integers, OR\n"
" pos: Grid coordinates as tuple, list, or Vector\n\n"
"Returns:\n"
" GridPointState for the entity's view of that grid cell.\n\n"
"Example:\n"
" state = entity.at(5, 3)\n"
" state = entity.at((5, 3))\n"
" state = entity.at(pos=(5, 3))"},
" point = entity.at(5, 3)\n"
" if point is not None and point.walkable: ...\n"
" point = entity.at((5, 3))\n"
" point = entity.at(pos=(5, 3))"},
{"index", (PyCFunction)UIEntity::index, METH_NOARGS, "Return the index of this entity in its grid's entity collection"},
{"die", (PyCFunction)UIEntity::die, METH_NOARGS,
"Remove this entity from its grid.\n\n"
@ -1290,8 +1326,9 @@ PyMethodDef UIEntity::methods[] = {
"update_visibility() -> None\n\n"
"Update entity's visibility state based on current FOV.\n\n"
"Recomputes which cells are visible from the entity's position and updates\n"
"the entity's gridstate to track explored areas. This is called automatically\n"
"when the entity moves if it has a grid with perspective set."},
"the entity's perspective_map (see entity.perspective_map and mcrfpy.Perspective).\n"
"This is called automatically when the entity moves if it has a grid with\n"
"perspective set."},
{"visible_entities", (PyCFunction)UIEntity::visible_entities, METH_VARARGS | METH_KEYWORDS,
"visible_entities(fov=None, radius=None) -> list[Entity]\n\n"
"Get list of other entities visible from this entity's position.\n\n"
@ -1618,17 +1655,20 @@ PyMethodDef UIEntity_all_methods[] = {
"Use list target with loop=True for repeating sprite frame animations.")
)},
{"at", (PyCFunction)UIEntity::at, METH_VARARGS | METH_KEYWORDS,
"at(x, y) or at(pos) -> GridPointState\n\n"
"Get the grid point state at the specified position.\n\n"
"at(x, y) or at(pos) -> GridPoint | None\n\n"
"Return the GridPoint at (x, y) if currently VISIBLE to this entity's\n"
"perspective_map, otherwise None. Equivalent to:\n"
" grid.at(x, y) if perspective_map[x, y] == Perspective.VISIBLE else None\n\n"
"To inspect discovered-but-not-visible cells, read entity.perspective_map[x, y]\n"
"directly and use grid.at(x, y) for cell data.\n\n"
"Args:\n"
" x, y: Grid coordinates as two integers, OR\n"
" pos: Grid coordinates as tuple, list, or Vector\n\n"
"Returns:\n"
" GridPointState for the entity's view of that grid cell.\n\n"
"Example:\n"
" state = entity.at(5, 3)\n"
" state = entity.at((5, 3))\n"
" state = entity.at(pos=(5, 3))"},
" point = entity.at(5, 3)\n"
" if point is not None and point.walkable: ...\n"
" point = entity.at((5, 3))\n"
" point = entity.at(pos=(5, 3))"},
{"index", (PyCFunction)UIEntity::index, METH_NOARGS, "Return the index of this entity in its grid's entity collection"},
{"die", (PyCFunction)UIEntity::die, METH_NOARGS,
"Remove this entity from its grid.\n\n"
@ -1662,8 +1702,9 @@ PyMethodDef UIEntity_all_methods[] = {
"update_visibility() -> None\n\n"
"Update entity's visibility state based on current FOV.\n\n"
"Recomputes which cells are visible from the entity's position and updates\n"
"the entity's gridstate to track explored areas. This is called automatically\n"
"when the entity moves if it has a grid with perspective set."},
"the entity's perspective_map (see entity.perspective_map and mcrfpy.Perspective).\n"
"This is called automatically when the entity moves if it has a grid with\n"
"perspective set."},
{"visible_entities", (PyCFunction)UIEntity::visible_entities, METH_VARARGS | METH_KEYWORDS,
"visible_entities(fov=None, radius=None) -> list[Entity]\n\n"
"Get list of other entities visible from this entity's position.\n\n"
@ -1726,7 +1767,16 @@ PyGetSetDef UIEntity::getsetters[] = {
{"draw_pos", (getter)UIEntity::get_position, (setter)UIEntity::set_position,
"Fractional tile position for rendering (Vector). Use for smooth animation between grid cells.", (void*)0},
{"gridstate", (getter)UIEntity::get_gridstate, NULL, "Grid point states for the entity", NULL},
{"perspective_map", (getter)UIEntity::get_perspective_map, (setter)UIEntity::set_perspective_map,
"Per-entity FOV memory (DiscreteMap, read-write). 3-state values per cell: "
"0 = unknown (never seen), 1 = discovered (seen before, not currently visible), "
"2 = visible (in current FOV). Use mcrfpy.Perspective enum for clarity. "
"Lazy-allocated on first access once the entity has a grid; returns None otherwise. "
"The returned DiscreteMap is a live reference -- mutations are visible to subsequent "
"updateVisibility() calls. Assigning a DiscreteMap replaces the entity's memory; "
"the new map's size must match the grid's size or ValueError is raised. "
"Assign None to clear (will be lazy-reallocated on next access).",
NULL},
{"grid", (getter)UIEntity::get_grid, (setter)UIEntity::set_grid,
"Grid this entity belongs to. "
"Get: Returns the Grid or None. "

View file

@ -19,6 +19,8 @@
#include "UIBase.h"
#include "UISprite.h"
#include "EntityBehavior.h"
#include "DiscreteMap.h"
#include <memory>
class UIGrid;
@ -57,8 +59,6 @@ class UIGrid;
// helper methods with no namespace requirement
PyObject* sfVector2f_to_PyObject(sf::Vector2f vector);
sf::Vector2f PyObject_to_sfVector2f(PyObject* obj);
PyObject* UIGridPointState_to_PyObject(const UIGridPointState& state);
PyObject* UIGridPointStateVector_to_PyList(const std::vector<UIGridPointState>& vec);
class UIEntity : public std::enable_shared_from_this<UIEntity>
{
@ -66,7 +66,12 @@ public:
uint64_t serial_number = 0; // For Python object cache
PyObject* pyobject = nullptr; // Strong ref: preserves Python subclass identity while in grid
std::shared_ptr<UIGrid> grid;
std::vector<UIGridPointState> gridstate;
// Per-entity perspective memory (#294): 3-state DiscreteMap --
// 0 = unknown, 1 = discovered, 2 = visible. Lazily allocated on first
// access to entity.perspective_map (when a grid is set) and whenever
// updateVisibility() runs with a grid whose dimensions differ from the
// current map. See PyPerspective for the Python-side enum.
std::shared_ptr<DiscreteMap> perspective_map;
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)
@ -122,8 +127,7 @@ public:
}
// Visibility methods
void ensureGridstate(); // Resize gridstate to match current grid dimensions
void updateVisibility(); // Update gridstate from current FOV
void updateVisibility(); // Update perspective_map from current FOV (#294)
// Property system for animations
bool setProperty(const std::string& name, float value);
@ -151,7 +155,8 @@ public:
static PyObject* get_position(PyUIEntityObject* self, void* closure);
static int set_position(PyUIEntityObject* self, PyObject* value, void* closure);
static PyObject* get_gridstate(PyUIEntityObject* self, void* closure);
static PyObject* get_perspective_map(PyUIEntityObject* self, void* closure);
static int set_perspective_map(PyUIEntityObject* self, PyObject* value, void* closure);
static PyObject* get_spritenumber(PyUIEntityObject* self, void* closure);
static int set_spritenumber(PyUIEntityObject* self, PyObject* value, void* closure);
static PyObject* get_float_member(PyUIEntityObject* self, void* closure);
@ -259,7 +264,7 @@ namespace mcrfpydef {
" grid_pos (Vector): Integer tile coordinates (logical game position)\n"
" grid_x, grid_y (int): Integer tile coordinate components\n"
" draw_pos (Vector): Fractional tile position for smooth animation\n"
" gridstate (GridPointState): Visibility state for grid points\n"
" perspective_map (DiscreteMap | None): 3-state per-entity FOV memory\n"
" sprite_index (int): Current sprite index\n"
" visible (bool): Visibility state\n"
" opacity (float): Opacity value\n"

View file

@ -188,7 +188,7 @@ int UIEntityCollection::setitem(PyUIEntityCollectionObject* self, Py_ssize_t ind
// Replace the element and set grid reference
*it = entity->data;
entity->data->grid = self->grid;
entity->data->ensureGridstate();
// #294: perspective_map is lazy; next update_visibility() sizes it.
// Add to spatial hash
if (self->grid) {
@ -494,7 +494,7 @@ int UIEntityCollection::ass_subscript(PyUIEntityCollectionObject* self, PyObject
for (const auto& entity : new_items) {
self->data->insert(insert_pos, entity);
entity->grid = self->grid;
entity->ensureGridstate();
// #294: perspective_map is lazy; sized on next update_visibility().
if (self->grid) {
self->grid->spatial_hash.insert(entity);
}
@ -521,7 +521,7 @@ int UIEntityCollection::ass_subscript(PyUIEntityCollectionObject* self, PyObject
*cur_it = new_items[new_idx++];
(*cur_it)->grid = self->grid;
(*cur_it)->ensureGridstate();
// #294: perspective_map is lazy; sized on next update_visibility().
if (self->grid) {
self->grid->spatial_hash.insert(*cur_it);
@ -582,8 +582,7 @@ PyObject* UIEntityCollection::append(PyUIEntityCollectionObject* self, PyObject*
}
}
// Ensure gridstate matches current grid dimensions
entity->data->ensureGridstate();
// #294: perspective_map is lazy; sized on next update_visibility().
Py_RETURN_NONE;
}
@ -682,8 +681,7 @@ PyObject* UIEntityCollection::extend(PyUIEntityCollectionObject* self, PyObject*
self->grid->spatial_hash.insert(entity->data);
}
// Ensure gridstate matches current grid dimensions
entity->data->ensureGridstate();
// #294: perspective_map is lazy; sized on next update_visibility().
Py_DECREF(entity); // Release the reference we held during validation
}
@ -737,7 +735,7 @@ PyObject* UIEntityCollection::pop(PyUIEntityCollectionObject* self, PyObject* ar
if (entity->serial_number != 0) {
PyObject* cached = PythonObjectCache::getInstance().lookup(entity->serial_number);
if (cached) {
// Release identity ref entity is leaving the grid
// Release identity ref -- entity is leaving the grid
// The caller now holds a strong ref via 'cached'
entity->releasePyIdentity();
return cached;
@ -810,8 +808,7 @@ PyObject* UIEntityCollection::insert(PyUIEntityCollectionObject* self, PyObject*
self->grid->spatial_hash.insert(entity->data);
}
// Ensure gridstate matches current grid dimensions
entity->data->ensureGridstate();
// #294: perspective_map is lazy; sized on next update_visibility().
Py_RETURN_NONE;
}

View file

@ -289,8 +289,11 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target)
sf::RectangleShape overlay;
overlay.setSize(sf::Vector2f(cell_width * zoom, cell_height * zoom));
if (entity) {
// Valid entity - use its gridstate for visibility
if (entity && entity->perspective_map) {
// #294: perspective_map values -- 0=unknown, 1=discovered, 2=visible.
const uint8_t* pm = entity->perspective_map->data();
const int pm_w = entity->perspective_map->width();
const int pm_h = entity->perspective_map->height();
for (int x = (left_edge - 1 >= 0 ? left_edge - 1 : 0);
x < x_limit;
x+=1)
@ -301,30 +304,23 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target)
{
// Skip out-of-bounds cells
if (x < 0 || x >= grid_w || y < 0 || y >= grid_h) continue;
if (x >= pm_w || y >= pm_h) continue;
auto pixel_pos = sf::Vector2f(
(x*cell_width - left_spritepixels) * zoom,
(y*cell_height - top_spritepixels) * zoom );
// Get visibility state from entity's perspective
int idx = y * grid_w + x;
if (idx >= 0 && idx < static_cast<int>(entity->gridstate.size())) {
const auto& state = entity->gridstate[idx];
overlay.setPosition(pixel_pos);
// Three overlay colors as specified:
if (!state.discovered) {
// Never seen - black
overlay.setFillColor(sf::Color(0, 0, 0, 255));
activeTexture->draw(overlay);
} else if (!state.visible) {
// Discovered but not currently visible - dark gray
overlay.setFillColor(sf::Color(32, 32, 40, 192));
activeTexture->draw(overlay);
}
// If visible and discovered, no overlay (fully visible)
uint8_t state = pm[y * pm_w + x];
overlay.setPosition(pixel_pos);
if (state == 0) {
overlay.setFillColor(sf::Color(0, 0, 0, 255));
activeTexture->draw(overlay);
} else if (state == 1) {
overlay.setFillColor(sf::Color(32, 32, 40, 192));
activeTexture->draw(overlay);
}
// state == 2: visible -- no overlay
}
}
} else {

View file

@ -157,94 +157,8 @@ PyObject* UIGridPoint::repr(PyUIGridPointObject* self) {
return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace");
}
// Helper to safely get the GridPointState data from coordinates
static UIGridPointState* getGridPointStateData(PyUIGridPointStateObject* self) {
if (!self->entity || !self->entity->grid) return nullptr;
int idx = self->y * self->entity->grid->grid_w + self->x;
if (idx < 0 || idx >= static_cast<int>(self->entity->gridstate.size())) return nullptr;
return &self->entity->gridstate[idx];
}
PyObject* UIGridPointState::get_bool_member(PyUIGridPointStateObject* self, void* closure) {
auto* data = getGridPointStateData(self);
if (!data) {
PyErr_SetString(PyExc_RuntimeError, "GridPointState data is no longer valid");
return NULL;
}
if (reinterpret_cast<intptr_t>(closure) == 0) { // visible
return PyBool_FromLong(data->visible);
} else { // discovered
return PyBool_FromLong(data->discovered);
}
}
int UIGridPointState::set_bool_member(PyUIGridPointStateObject* self, PyObject* value, void* closure) {
auto* data = getGridPointStateData(self);
if (!data) {
PyErr_SetString(PyExc_RuntimeError, "GridPointState data is no longer valid");
return -1;
}
if (!PyBool_Check(value)) {
PyErr_SetString(PyExc_TypeError, "Value must be a boolean");
return -1;
}
int truthValue = PyObject_IsTrue(value);
if (truthValue < 0) {
return -1;
}
if (reinterpret_cast<intptr_t>(closure) == 0) { // visible
data->visible = truthValue;
} else { // discovered
data->discovered = truthValue;
}
return 0;
}
// #16 - Get GridPoint at this position (None if not discovered)
PyObject* UIGridPointState::get_point(PyUIGridPointStateObject* self, void* closure) {
// Return None if entity hasn't discovered this cell
auto* data = getGridPointStateData(self);
if (!data || !data->discovered) {
Py_RETURN_NONE;
}
if (!self->grid) {
PyErr_SetString(PyExc_RuntimeError, "GridPointState has no parent grid");
return NULL;
}
// Return the GridPoint at this position (use type directly since it's internal-only)
auto type = &mcrfpydef::PyUIGridPointType;
auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0);
if (!obj) return NULL;
obj->grid = self->grid;
obj->x = self->x;
obj->y = self->y;
return (PyObject*)obj;
}
PyGetSetDef UIGridPointState::getsetters[] = {
{"visible", (getter)UIGridPointState::get_bool_member, (setter)UIGridPointState::set_bool_member, "Is the GridPointState visible", (void*)0},
{"discovered", (getter)UIGridPointState::get_bool_member, (setter)UIGridPointState::set_bool_member, "Has the GridPointState been discovered", (void*)1},
{"point", (getter)UIGridPointState::get_point, NULL, "GridPoint at this position (None if not discovered)", NULL},
{NULL} /* Sentinel */
};
PyObject* UIGridPointState::repr(PyUIGridPointStateObject* self) {
std::ostringstream ss;
auto* gps = getGridPointStateData(self);
if (!gps) ss << "<GridPointState (invalid internal object)>";
else {
ss << "<GridPointState (visible=" << (gps->visible ? "True" : "False") << ", discovered=" << (gps->discovered ? "True" : "False") <<
")>";
}
std::string repr_str = ss.str();
return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace");
}
// UIGridPointState removed in #294. Per-entity visibility memory now lives on
// UIEntity::perspective_map as a DiscreteMap. See mcrfpy.Perspective enum.
// #150 - Dynamic attribute access for named layers
PyObject* UIGridPoint::getattro(PyUIGridPointObject* self, PyObject* name) {

View file

@ -19,7 +19,6 @@ class UIGrid;
class GridData;
class UIEntity;
class UIGridPoint;
class UIGridPointState;
typedef struct {
PyObject_HEAD
@ -27,13 +26,6 @@ typedef struct {
int x, y; // Grid coordinates - compute data pointer on access
} PyUIGridPointObject;
typedef struct {
PyObject_HEAD
std::shared_ptr<UIGrid> grid;
std::shared_ptr<UIEntity> entity;
int x, y; // Position in grid - compute state on access
} PyUIGridPointStateObject;
// UIGridPoint - grid cell data for pathfinding and layer access
// #150 - Layer-related properties (color, tilesprite, etc.) removed; now handled by layers
class UIGridPoint
@ -61,20 +53,9 @@ public:
static int setattro(PyUIGridPointObject* self, PyObject* name, PyObject* value);
};
// UIGridPointState - entity-specific info for each cell
class UIGridPointState
{
public:
bool visible, discovered;
static PyObject* get_bool_member(PyUIGridPointStateObject* self, void* closure);
static int set_bool_member(PyUIGridPointStateObject* self, PyObject* value, void* closure);
static PyGetSetDef getsetters[];
static PyObject* repr(PyUIGridPointStateObject* self);
// #16 - point property: access to GridPoint (None if not discovered)
static PyObject* get_point(PyUIGridPointStateObject* self, void* closure);
};
// UIGridPointState / PyUIGridPointStateType removed in #294.
// Per-entity visibility memory now lives on UIEntity::perspective_map as a
// DiscreteMap (3-state: 0=unknown, 1=discovered, 2=visible). See mcrfpy.Perspective.
namespace mcrfpydef {
// #189 - Use inline instead of static to ensure single instance across translation units
@ -89,7 +70,7 @@ namespace mcrfpydef {
.tp_setattro = (setattrofunc)UIGridPoint::setattro,
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR(
"GridPoint a single cell in a Grid.\n\n"
"GridPoint -- a single cell in a Grid.\n\n"
"Obtained via grid.at(x, y). Cannot be constructed directly.\n\n"
"Properties:\n"
" walkable (bool): Whether entities can traverse this cell.\n"
@ -102,24 +83,4 @@ namespace mcrfpydef {
//.tp_init = (initproc)PyUIGridPoint_init, // TODO Define the init function
.tp_new = NULL, // Prevent instantiation from Python - Issue #12
};
// #189 - Use inline instead of static to ensure single instance across translation units
inline PyTypeObject PyUIGridPointStateType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.GridPointState",
.tp_basicsize = sizeof(PyUIGridPointStateObject),
.tp_itemsize = 0,
.tp_repr = (reprfunc)UIGridPointState::repr,
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR(
"GridPointState — per-entity visibility state for a grid cell.\n\n"
"Obtained via entity.gridstate. Cannot be constructed directly.\n\n"
"Properties:\n"
" visible (bool): Whether this cell is currently in the entity's FOV.\n"
" discovered (bool): Whether this cell has ever been seen.\n"
" point (GridPoint, read-only): The underlying GridPoint, or None.\n"
),
.tp_getset = UIGridPointState::getsetters,
.tp_new = NULL, // Prevent instantiation from Python - Issue #12
};
}

View file

@ -112,7 +112,7 @@ void UIGridView::center_camera(float tile_x, float tile_y)
}
// =========================================================================
// Render adapted from UIGrid::render()
// Render -- adapted from UIGrid::render()
// =========================================================================
void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target)
{
@ -225,25 +225,28 @@ void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target)
sf::RectangleShape overlay;
overlay.setSize(sf::Vector2f(cell_width * zoom, cell_height * zoom));
if (entity) {
if (entity && entity->perspective_map) {
// #294: perspective_map values: 0=unknown, 1=discovered, 2=visible.
const uint8_t* pm = entity->perspective_map->data();
const int pm_w = entity->perspective_map->width();
const int pm_h = entity->perspective_map->height();
for (int x = std::max(0, (int)(left_edge - 1)); x < x_limit; x++) {
for (int y = std::max(0, (int)(top_edge - 1)); y < y_limit; y++) {
if (x < 0 || x >= grid_data->grid_w || y < 0 || y >= grid_data->grid_h) continue;
if (x >= pm_w || y >= pm_h) continue;
auto pixel_pos = sf::Vector2f(
(x*cell_width - left_spritepixels) * zoom,
(y*cell_height - top_spritepixels) * zoom);
int idx = y * grid_data->grid_w + x;
if (idx >= 0 && idx < static_cast<int>(entity->gridstate.size())) {
const auto& state = entity->gridstate[idx];
overlay.setPosition(pixel_pos);
if (!state.discovered) {
overlay.setFillColor(sf::Color(0, 0, 0, 255));
activeTexture->draw(overlay);
} else if (!state.visible) {
overlay.setFillColor(sf::Color(32, 32, 40, 192));
activeTexture->draw(overlay);
}
uint8_t state = pm[y * pm_w + x];
overlay.setPosition(pixel_pos);
if (state == 0) {
overlay.setFillColor(sf::Color(0, 0, 0, 255));
activeTexture->draw(overlay);
} else if (state == 1) {
overlay.setFillColor(sf::Color(32, 32, 40, 192));
activeTexture->draw(overlay);
}
// state == 2: visible -- no overlay
}
}
} else {

File diff suppressed because it is too large Load diff

View file

@ -1,71 +0,0 @@
"""Regression test: GridPointState references must not dangle after grid resize.
Issue #265: GridPointState objects held Python references to internal vectors
that could be invalidated when the underlying grid data was reallocated.
Fix: GridPointState now copies data or holds safe references that survive
grid modifications.
"""
import mcrfpy
import sys
PASS = True
def check(name, condition):
global PASS
if not condition:
print(f"FAIL: {name}")
PASS = False
else:
print(f" ok: {name}")
# Test 1: Access GridPointState after entity transfer
grid = mcrfpy.Grid(grid_size=(10, 10))
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=grid)
entity.update_visibility()
state = entity.at(3, 3)
check("GridPointState accessible", state is not None)
# Transfer entity to a different grid (invalidates old gridstate)
grid2 = mcrfpy.Grid(grid_size=(20, 20))
entity.grid = grid2
entity.update_visibility()
# Old state reference should not crash
# (In the buggy version, accessing state after transfer would read freed memory)
try:
# Access the new gridstate
new_state = entity.at(15, 15)
check("GridPointState valid after transfer", new_state is not None)
except Exception as e:
check(f"GridPointState valid after transfer (exception: {e})", False)
# Test 2: Multiple entities with GridPointState references
entities = []
for i in range(5):
e = mcrfpy.Entity(grid_pos=(i, i), grid=grid)
entities.append(e)
for e in entities:
e.update_visibility()
states = [e.at(0, 0) for e in entities]
check("Multiple GridPointState refs created", len(states) == 5)
# Remove all entities (should not crash)
for e in entities:
e.grid = None
check("Entities removed safely", len(grid.entities) == 0)
# Test 3: GridPoint reference stability
gp = grid.at(5, 5)
check("GridPoint accessible", gp is not None)
check("GridPoint walkable", gp.walkable == True or gp.walkable == False)
if PASS:
print("PASS")
sys.exit(0)
else:
sys.exit(1)

View file

@ -0,0 +1,104 @@
"""
Regression for #294: entity.perspective_map replaces the old gridstate array.
Verifies:
- visible subset discovered invariant holds after every updateVisibility()
(no cell is VISIBLE without also being, at minimum, DISCOVERED when the
entity next looks away)
- Grid-transition round-trip: save perspective on grid A, restore on
same-sized grid B, FOV continues correctly without wiping prior knowledge.
"""
import mcrfpy
import sys
def fail(msg):
print(f"FAIL: {msg}")
sys.exit(1)
def check_invariant(pm):
"""Every value in the map is 0, 1, or 2 (structural visible<=discovered)."""
for y in range(pm.size[1]):
for x in range(pm.size[0]):
v = int(pm[x, y])
if v not in (0, 1, 2):
fail(f"pm[{x},{y}] = {v}, must be 0/1/2 (structural invariant)")
def main():
scene = mcrfpy.Scene("issue_294_regression")
mcrfpy.current_scene = scene
# Scenario 1: invariant after repeated movement + updateVisibility.
grid_a = mcrfpy.Grid(grid_size=(15, 10))
scene.children.append(grid_a)
e = mcrfpy.Entity(grid_pos=(7, 5))
grid_a.entities.append(e)
for target in [(7, 5), (3, 3), (12, 8), (1, 1), (14, 9), (7, 5)]:
e.grid_pos = target
e.update_visibility()
check_invariant(e.perspective_map)
# Scenario 2: a previously-seen cell that moves out of FOV is DISCOVERED
# (not UNKNOWN) — the hallmark of the 3-state model.
e.grid_pos = (7, 5)
e.update_visibility()
pm = e.perspective_map
# Collect cells VISIBLE at origin
origin_visible = [(x, y) for y in range(10) for x in range(15)
if int(pm[x, y]) == 2]
if not origin_visible:
fail("expected at least one VISIBLE cell at origin position")
# Move far away so fewer of the origin cells are in FOV
e.grid_pos = (14, 9)
e.update_visibility()
for (x, y) in origin_visible:
v = int(pm[x, y])
if v == 0:
fail(f"cell ({x},{y}) was VISIBLE then moved out of FOV — "
f"should be DISCOVERED (1), not UNKNOWN (0)")
# Scenario 3: serialize on grid A, restore on grid B (same size), FOV continues.
e.grid_pos = (7, 5)
e.update_visibility()
saved_bytes = e.perspective_map.to_bytes()
grid_b = mcrfpy.Grid(grid_size=(15, 10))
scene.children.append(grid_b)
e.grid = grid_b # remove from A, add to B
e.grid_pos = (7, 5)
# On the new grid, perspective_map lazy-allocates fresh (all zeros).
# Assign the saved bytes to restore.
restored = mcrfpy.DiscreteMap.from_bytes(saved_bytes, (15, 10))
e.perspective_map = restored
pm_b = e.perspective_map
# Prior DISCOVERED/VISIBLE cells should still be there.
seen_any = False
for y in range(10):
for x in range(15):
if int(pm_b[x, y]) >= 1:
seen_any = True
break
if seen_any:
break
if not seen_any:
fail("after restore, at least one cell should be DISCOVERED (>=1)")
# update_visibility on grid B should promote cells in new FOV to VISIBLE
# but preserve DISCOVERED cells elsewhere.
e.update_visibility()
check_invariant(pm_b)
if int(pm_b[7, 5]) != 2:
fail("entity's cell on grid B should be VISIBLE after update_visibility")
print("PASS")
sys.exit(0)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,84 @@
"""
Tests for mcrfpy.Perspective enum and entity.at() new semantics (#294).
"""
import mcrfpy
import sys
def fail(msg):
print(f"FAIL: {msg}")
sys.exit(1)
def main():
# Enum members
p = mcrfpy.Perspective
if int(p.UNKNOWN) != 0:
fail("Perspective.UNKNOWN should be 0")
if int(p.DISCOVERED) != 1:
fail("Perspective.DISCOVERED should be 1")
if int(p.VISIBLE) != 2:
fail("Perspective.VISIBLE should be 2")
# IntEnum: equality with ints both ways
if p.VISIBLE != 2 or 2 != p.VISIBLE:
fail("IntEnum equality failed")
# Repr uses the enum name
if repr(p.DISCOVERED) != "Perspective.DISCOVERED":
fail(f"unexpected repr: {repr(p.DISCOVERED)}")
# entity.at() semantics: VISIBLE -> GridPoint, else None.
scene = mcrfpy.Scene("perspective_enum")
mcrfpy.current_scene = scene
grid = mcrfpy.Grid(grid_size=(7, 5))
scene.children.append(grid)
e = mcrfpy.Entity(grid_pos=(3, 2))
grid.entities.append(e)
# Without update_visibility, perspective_map is all-zero.
if e.at(3, 2) is not None:
fail("before update_visibility, at() should return None (cells are UNKNOWN)")
e.update_visibility()
p_here = e.at(3, 2)
if p_here is None:
fail("after update_visibility, at(own cell) should return a GridPoint")
if not hasattr(p_here, "walkable"):
fail("at() return should be a GridPoint (has .walkable)")
# Manually promote a cell to DISCOVERED only, confirm at() still returns None.
pm = e.perspective_map
# Find a cell currently UNKNOWN, set to DISCOVERED, confirm at() -> None.
for y in range(5):
for x in range(7):
if int(pm[x, y]) == 0:
pm[x, y] = 1 # DISCOVERED
if e.at(x, y) is not None:
fail(f"DISCOVERED-only cell ({x},{y}): at() should return None")
pm[x, y] = 2 # VISIBLE
if e.at(x, y) is None:
fail(f"VISIBLE cell ({x},{y}): at() should return a GridPoint")
break
else:
continue
break
# Accept enum as Perspective value assignment via subscript
pm[0, 0] = mcrfpy.Perspective.VISIBLE
if int(pm[0, 0]) != 2:
fail("DiscreteMap subscript should accept Perspective enum member")
# Out-of-bounds at() raises IndexError
try:
e.at(100, 100)
fail("out-of-bounds at() should raise IndexError")
except IndexError:
pass
print("PASS")
sys.exit(0)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,87 @@
"""
Tests for entity.perspective_map serialization round-trip and size validation (#294).
Covers:
- to_bytes() / from_bytes() preserves all three perspective states
- Assigning a size-mismatched DiscreteMap raises ValueError
- Assigning None clears (and getter lazy-reallocates fresh)
- Assigning a matching DiscreteMap replaces the entity's memory
"""
import mcrfpy
import sys
def fail(msg):
print(f"FAIL: {msg}")
sys.exit(1)
def main():
scene = mcrfpy.Scene("serialization_test")
mcrfpy.current_scene = scene
grid = mcrfpy.Grid(grid_size=(8, 6))
scene.children.append(grid)
e = mcrfpy.Entity(grid_pos=(2, 2))
grid.entities.append(e)
pm = e.perspective_map
# Place each of the three states at known positions.
pm[0, 0] = 0 # UNKNOWN
pm[1, 1] = 1 # DISCOVERED
pm[2, 2] = 2 # VISIBLE
# Round-trip via bytes.
raw = pm.to_bytes()
if len(raw) != 8 * 6:
fail(f"to_bytes length should be {8*6}, got {len(raw)}")
restored = mcrfpy.DiscreteMap.from_bytes(raw, (8, 6))
if int(restored[0, 0]) != 0:
fail(f"restored[0,0] should be 0, got {restored[0, 0]}")
if int(restored[1, 1]) != 1:
fail(f"restored[1,1] should be 1, got {restored[1, 1]}")
if int(restored[2, 2]) != 2:
fail(f"restored[2,2] should be 2, got {restored[2, 2]}")
# Assign restored back to the entity.
e.perspective_map = restored
after = e.perspective_map
if int(after[1, 1]) != 1 or int(after[2, 2]) != 2:
fail("entity.perspective_map = restored should replace memory")
# Size mismatch raises ValueError.
wrong = mcrfpy.DiscreteMap((4, 4))
try:
e.perspective_map = wrong
fail("size-mismatched assignment should raise ValueError")
except ValueError as err:
# message should mention size/grid/match
msg = str(err).lower()
if "size" not in msg and "match" not in msg:
fail(f"ValueError message should mention size mismatch, got: {err}")
# None clears.
e.perspective_map = None
fresh = e.perspective_map # lazy-reallocates
if fresh is None:
fail("perspective_map should be lazy-reallocated after = None")
for y in range(6):
for x in range(8):
if int(fresh[x, y]) != 0:
fail(f"after clear, fresh[{x},{y}] should be 0, got {fresh[x, y]}")
# Type check: non-DiscreteMap raises TypeError.
try:
e.perspective_map = [0, 1, 2]
fail("non-DiscreteMap assignment should raise TypeError")
except TypeError:
pass
print("PASS")
sys.exit(0)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,112 @@
"""
Tests for Entity.perspective_map (#294).
Covers:
- Lazy allocation (None when no grid; allocated on first access with a grid)
- Identity: multiple accesses share the same underlying DiscreteMap
- Three-state values: 0=UNKNOWN, 1=DISCOVERED, 2=VISIBLE
- perspective_map[x, y] returns Perspective enum members
- visible subset discovered invariant after updateVisibility
- entity.at() returns GridPoint when VISIBLE, None otherwise
"""
import mcrfpy
import sys
def fail(msg):
print(f"FAIL: {msg}")
sys.exit(1)
def main():
scene = mcrfpy.Scene("perspective_map_test")
mcrfpy.current_scene = scene
# Entity without a grid -> perspective_map is None
orphan = mcrfpy.Entity(grid_pos=(0, 0))
if orphan.perspective_map is not None:
fail("entity without grid should have perspective_map == None")
# Attach to a grid; perspective_map lazy-allocates on first access
grid = mcrfpy.Grid(grid_size=(12, 9))
scene.children.append(grid)
e = mcrfpy.Entity(grid_pos=(4, 3))
grid.entities.append(e)
pm = e.perspective_map
if pm is None:
fail("perspective_map should be allocated once entity has a grid")
if pm.size != (12, 9):
fail(f"perspective_map size should match grid size, got {pm.size}")
# Initial state: all UNKNOWN
for y in range(9):
for x in range(12):
if int(pm[x, y]) != 0:
fail(f"initial pm[{x},{y}] should be 0, got {pm[x, y]}")
# Identity: a second access wraps the same shared DiscreteMap.
# Mutating through one is visible through the other.
pm2 = e.perspective_map
pm2[1, 1] = 2
if int(pm[1, 1]) != 2:
fail("perspective_map accesses should share underlying buffer")
# Values returned as Perspective enum members (IntEnum).
pm[2, 2] = 1
val = pm[2, 2]
if val != mcrfpy.Perspective.DISCOVERED:
fail(f"pm[2,2] should equal Perspective.DISCOVERED, got {val!r}")
if int(val) != 1:
fail(f"IntEnum comparison failed: int(pm[2,2]) = {int(val)}")
# updateVisibility yields VISIBLE at entity's cell, UNKNOWN far away
# (beyond FOV radius). We first wipe whatever leaked in.
pm.fill(0)
e.update_visibility()
at_entity = pm[4, 3]
if at_entity != mcrfpy.Perspective.VISIBLE:
fail(f"entity's cell should be VISIBLE after update_visibility, got {at_entity}")
# Invariant: any VISIBLE cell remains at least DISCOVERED after the entity
# moves away and we re-update.
visible_before = set()
for y in range(9):
for x in range(12):
if int(pm[x, y]) == 2:
visible_before.add((x, y))
e.grid_pos = (0, 0) # move far corner
e.update_visibility()
for (x, y) in visible_before:
v = int(pm[x, y])
if v < 1:
fail(f"cell ({x},{y}) was VISIBLE, should now be at least DISCOVERED (>=1), got {v}")
# entity.at(): VISIBLE -> GridPoint; else None.
p = e.at(0, 0)
if p is None:
fail("entity.at(own cell) should return GridPoint (VISIBLE)")
if not hasattr(p, "walkable"):
fail("entity.at(visible) should return GridPoint with .walkable")
# Find an UNKNOWN cell and verify at() returns None.
found = False
for y in range(9):
for x in range(12):
if int(pm[x, y]) == 0:
if e.at(x, y) is not None:
fail(f"entity.at({x},{y}) on UNKNOWN cell should return None")
found = True
break
if found:
break
# (Not fatal if no UNKNOWN cell exists — small grid + large FOV radius.)
print("PASS")
sys.exit(0)
if __name__ == "__main__":
main()

View file

@ -1,167 +0,0 @@
#!/usr/bin/env python3
"""
Test GridPointState.point property (#16)
=========================================
Tests the GridPointState.point property that provides access to the
GridPoint data from an entity's perspective.
Key behavior:
- Returns None if the cell has not been discovered by the entity
- Returns the GridPoint (live reference) if discovered
- Works with the FOV/visibility system
"""
import mcrfpy
import sys
def run_tests():
"""Run GridPointState.point tests"""
print("=== GridPointState.point Tests ===\n")
# Test 1: Undiscovered cell returns None
print("Test 1: Undiscovered cell returns None")
grid = mcrfpy.Grid(pos=(0, 0), size=(640, 400), grid_size=(40, 25))
# Set up grid
for y in range(25):
for x in range(40):
pt = grid.at(x, y)
pt.walkable = True
pt.transparent = True
# Create entity
entity = mcrfpy.Entity((5, 5))
grid.entities.append(entity)
# Before update_visibility, nothing is discovered
state = entity.at((10, 10))
assert state.point is None, f"Expected None for undiscovered cell, got {state.point}"
print(" Undiscovered cell returns None")
print()
# Test 2: Discovered cell returns GridPoint
print("Test 2: Discovered cell returns GridPoint")
grid.fov = mcrfpy.FOV.SHADOW
grid.fov_radius = 8
entity.update_visibility()
# Entity's own position should be discovered
state_own = entity.at((5, 5))
assert state_own.discovered == True, "Entity's position should be discovered"
assert state_own.point is not None, "Discovered cell should return GridPoint"
print(f" Entity position: discovered={state_own.discovered}, point={state_own.point}")
print()
# Test 3: GridPoint access through state
print("Test 3: GridPoint properties accessible through state.point")
# Make a specific cell have known properties
grid.at(6, 5).walkable = False
grid.at(6, 5).transparent = True
state_adj = entity.at((6, 5))
assert state_adj.point is not None, "Adjacent cell should be discovered"
assert state_adj.point.walkable == False, f"Expected walkable=False, got {state_adj.point.walkable}"
assert state_adj.point.transparent == True, f"Expected transparent=True, got {state_adj.point.transparent}"
print(f" Adjacent cell (6,5): walkable={state_adj.point.walkable}, transparent={state_adj.point.transparent}")
print()
# Test 4: Far cells remain undiscovered
print("Test 4: Cells outside FOV radius remain undiscovered")
# Cell far from entity (outside radius 8)
state_far = entity.at((30, 20))
assert state_far.discovered == False, "Far cell should not be discovered"
assert state_far.point is None, "Far cell point should be None"
print(f" Far cell (30,20): discovered={state_far.discovered}, point={state_far.point}")
print()
# Test 5: Discovered but not visible cells
print("Test 5: Discovered but not currently visible cells")
# Move entity to discover new area
entity.x = 6
entity.y = 5
entity.update_visibility()
# Move back - old visible cells should now be discovered but not visible
entity.x = 5
entity.y = 5
entity.update_visibility()
# A cell that was visible when at (6,5) but might not be visible from (5,5)
# Actually with radius 8, most nearby cells will still be visible
# Let's check a cell that's on the edge
state_edge = entity.at((12, 5)) # 7 cells away, should be visible with radius 8
if state_edge.discovered:
assert state_edge.point is not None, "Discovered cell should have point access"
print(f" Edge cell (12,5): discovered={state_edge.discovered}, visible={state_edge.visible}")
print(f" point.walkable={state_edge.point.walkable}")
print()
# Test 6: GridPoint.entities through state.point
print("Test 6: GridPoint.entities accessible through state.point")
# Add another entity at a visible position
e2 = mcrfpy.Entity((7, 5))
grid.entities.append(e2)
state_with_entity = entity.at((7, 5))
assert state_with_entity.point is not None, "Cell should be discovered"
entities_at_cell = state_with_entity.point.entities
assert len(entities_at_cell) == 1, f"Expected 1 entity, got {len(entities_at_cell)}"
print(f" Cell (7,5) has {len(entities_at_cell)} entity via state.point.entities")
print()
# Test 7: Live reference - changes to GridPoint are reflected
print("Test 7: state.point is a live reference")
# Get state before change
state_live = entity.at((8, 5))
original_walkable = state_live.point.walkable
# Change the actual GridPoint
grid.at(8, 5).walkable = not original_walkable
# Check that state.point reflects the change
new_walkable = state_live.point.walkable
assert new_walkable != original_walkable, "state.point should reflect GridPoint changes"
print(f" Changed walkable from {original_walkable} to {new_walkable} - reflected in state.point")
print()
# Test 8: Wall blocking visibility
print("Test 8: Walls block visibility correctly")
# Create a wall
grid.at(15, 5).transparent = False
grid.at(15, 5).walkable = False
entity.update_visibility()
# Cell behind wall should not be visible (and possibly not discovered)
state_behind = entity.at((20, 5)) # Behind wall at x=15
print(f" Cell behind wall (20,5): visible={state_behind.visible}, discovered={state_behind.discovered}")
if not state_behind.discovered:
assert state_behind.point is None, "Undiscovered cell behind wall should have point=None"
print(" Correctly returns None for undiscovered cell behind wall")
print()
print("=== All GridPointState.point Tests Passed! ===")
return True
# Main execution
if __name__ == "__main__":
try:
if run_tests():
print("\nPASS")
sys.exit(0)
else:
print("\nFAIL")
sys.exit(1)
except Exception as e:
print(f"\nFAIL: {e}")
import traceback
traceback.print_exc()
sys.exit(1)