feat: Add GridPoint.entities and GridPointState.point properties

GridPoint.entities (#114):
- Returns list of entities at this grid cell position
- Enables convenient cell-based entity queries without manual iteration
- Example: grid.at(5, 5).entities → [<Entity>, <Entity>]

GridPointState.point (#16):
- Returns GridPoint if entity has discovered this cell, None otherwise
- Respects entity's perspective: undiscovered cells return None
- Enables entity.at(x,y).point.walkable style access
- Live reference: changes to GridPoint are immediately visible

This provides a simpler solution for #16 without the complexity of
caching stale GridPoint copies. The visible/discovered flags indicate
whether the entity "should" trust the data; Python can implement
memory systems if needed.

closes #114, closes #16

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
John McCardle 2025-12-01 21:04:03 -05:00
parent a529e5eac3
commit f33e79a123
5 changed files with 367 additions and 6 deletions

View File

@ -120,9 +120,12 @@ PyObject* UIEntity::at(PyUIEntityObject* self, PyObject* o) {
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "GridPointState"); auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "GridPointState");
auto obj = (PyUIGridPointStateObject*)type->tp_alloc(type, 0); auto obj = (PyUIGridPointStateObject*)type->tp_alloc(type, 0);
Py_DECREF(type);
obj->data = &(self->data->gridstate[y * self->data->grid->grid_x + x]); obj->data = &(self->data->gridstate[y * self->data->grid->grid_x + x]);
obj->grid = self->data->grid; obj->grid = self->data->grid;
obj->entity = self->data; obj->entity = self->data;
obj->x = x; // #16 - Store position for .point property
obj->y = y;
return (PyObject*)obj; return (PyObject*)obj;
} }
@ -312,23 +315,29 @@ sf::Vector2i PyObject_to_sfVector2i(PyObject* obj) {
} }
PyObject* UIGridPointState_to_PyObject(const UIGridPointState& state) { PyObject* UIGridPointState_to_PyObject(const UIGridPointState& state) {
// Create a new GridPointState Python object // Create a new GridPointState Python object (detached - no grid/entity context)
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "GridPointState"); auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "GridPointState");
if (!type) { if (!type) {
return NULL; return NULL;
} }
auto obj = (PyUIGridPointStateObject*)type->tp_alloc(type, 0); auto obj = (PyUIGridPointStateObject*)type->tp_alloc(type, 0);
if (!obj) { if (!obj) {
Py_DECREF(type); Py_DECREF(type);
return NULL; return NULL;
} }
// Allocate new data and copy values // Allocate new data and copy values
obj->data = new UIGridPointState(); obj->data = new UIGridPointState();
obj->data->visible = state.visible; obj->data->visible = state.visible;
obj->data->discovered = state.discovered; obj->data->discovered = state.discovered;
// Initialize context fields (detached state has no grid/entity context)
obj->grid = nullptr;
obj->entity = nullptr;
obj->x = -1;
obj->y = -1;
Py_DECREF(type); Py_DECREF(type);
return (PyObject*)obj; return (PyObject*)obj;
} }

View File

@ -1,5 +1,6 @@
#include "UIGridPoint.h" #include "UIGridPoint.h"
#include "UIGrid.h" #include "UIGrid.h"
#include "UIEntity.h" // #114 - for GridPoint.entities
#include "GridLayers.h" // #150 - for GridLayerType, ColorLayer, TileLayer #include "GridLayers.h" // #150 - for GridLayerType, ColorLayer, TileLayer
#include <cstring> // #150 - for strcmp #include <cstring> // #150 - for strcmp
@ -90,9 +91,52 @@ int UIGridPoint::set_bool_member(PyUIGridPointObject* self, PyObject* value, voi
// #150 - Removed get_int_member/set_int_member - now handled by layers // #150 - Removed get_int_member/set_int_member - now handled by layers
// #114 - Get list of entities at this grid cell
PyObject* UIGridPoint::get_entities(PyUIGridPointObject* self, void* closure) {
if (!self->grid) {
PyErr_SetString(PyExc_RuntimeError, "GridPoint has no parent grid");
return NULL;
}
int target_x = self->data->grid_x;
int target_y = self->data->grid_y;
PyObject* list = PyList_New(0);
if (!list) return NULL;
// Iterate through grid's entities and find those at this position
for (auto& entity : *(self->grid->entities)) {
if (static_cast<int>(entity->position.x) == target_x &&
static_cast<int>(entity->position.y) == target_y) {
// Create Python Entity object for this entity
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity");
if (!type) {
Py_DECREF(list);
return NULL;
}
auto obj = (PyUIEntityObject*)type->tp_alloc(type, 0);
Py_DECREF(type);
if (!obj) {
Py_DECREF(list);
return NULL;
}
obj->data = entity;
if (PyList_Append(list, (PyObject*)obj) < 0) {
Py_DECREF(obj);
Py_DECREF(list);
return NULL;
}
Py_DECREF(obj); // List now owns the reference
}
}
return list;
}
PyGetSetDef UIGridPoint::getsetters[] = { PyGetSetDef UIGridPoint::getsetters[] = {
{"walkable", (getter)UIGridPoint::get_bool_member, (setter)UIGridPoint::set_bool_member, "Is the GridPoint walkable", (void*)0}, {"walkable", (getter)UIGridPoint::get_bool_member, (setter)UIGridPoint::set_bool_member, "Is the GridPoint walkable", (void*)0},
{"transparent", (getter)UIGridPoint::get_bool_member, (setter)UIGridPoint::set_bool_member, "Is the GridPoint transparent", (void*)1}, {"transparent", (getter)UIGridPoint::get_bool_member, (setter)UIGridPoint::set_bool_member, "Is the GridPoint transparent", (void*)1},
{"entities", (getter)UIGridPoint::get_entities, NULL, "List of entities at this grid cell (read-only)", NULL},
{NULL} /* Sentinel */ {NULL} /* Sentinel */
}; };
@ -137,9 +181,43 @@ int UIGridPointState::set_bool_member(PyUIGridPointStateObject* self, PyObject*
return 0; 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
if (!self->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
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "GridPoint");
if (!type) return NULL;
auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0);
Py_DECREF(type);
if (!obj) return NULL;
// Get the GridPoint from the grid
int idx = self->y * self->grid->grid_x + self->x;
if (idx < 0 || idx >= static_cast<int>(self->grid->points.size())) {
Py_DECREF(obj);
PyErr_SetString(PyExc_IndexError, "GridPointState position out of bounds");
return NULL;
}
obj->data = &(self->grid->points[idx]);
obj->grid = self->grid;
return (PyObject*)obj;
}
PyGetSetDef UIGridPointState::getsetters[] = { PyGetSetDef UIGridPointState::getsetters[] = {
{"visible", (getter)UIGridPointState::get_bool_member, (setter)UIGridPointState::set_bool_member, "Is the GridPointState visible", (void*)0}, {"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}, {"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 */ {NULL} /* Sentinel */
}; };
@ -232,7 +310,7 @@ int UIGridPoint::setattro(PyUIGridPointObject* self, PyObject* name, PyObject* v
sf::Color color = PyObject_to_sfColor(value); sf::Color color = PyObject_to_sfColor(value);
if (PyErr_Occurred()) return -1; if (PyErr_Occurred()) return -1;
color_layer->at(x, y) = color; color_layer->at(x, y) = color;
color_layer->markDirty(); color_layer->markDirty(x, y); // Mark only the affected chunk
return 0; return 0;
} else if (layer->type == GridLayerType::Tile) { } else if (layer->type == GridLayerType::Tile) {
auto tile_layer = std::static_pointer_cast<TileLayer>(layer); auto tile_layer = std::static_pointer_cast<TileLayer>(layer);
@ -241,7 +319,7 @@ int UIGridPoint::setattro(PyUIGridPointObject* self, PyObject* name, PyObject* v
return -1; return -1;
} }
tile_layer->at(x, y) = PyLong_AsLong(value); tile_layer->at(x, y) = PyLong_AsLong(value);
tile_layer->markDirty(); tile_layer->markDirty(x, y); // Mark only the affected chunk
return 0; return 0;
} }

View File

@ -31,6 +31,7 @@ typedef struct {
UIGridPointState* data; UIGridPointState* data;
std::shared_ptr<UIGrid> grid; std::shared_ptr<UIGrid> grid;
std::shared_ptr<UIEntity> entity; std::shared_ptr<UIEntity> entity;
int x, y; // Position in grid (needed for .point property)
} PyUIGridPointStateObject; } PyUIGridPointStateObject;
// UIGridPoint - grid cell data for pathfinding and layer access // UIGridPoint - grid cell data for pathfinding and layer access
@ -49,6 +50,9 @@ public:
static PyObject* get_bool_member(PyUIGridPointObject* self, void* closure); static PyObject* get_bool_member(PyUIGridPointObject* self, void* closure);
static PyObject* repr(PyUIGridPointObject* self); static PyObject* repr(PyUIGridPointObject* self);
// #114 - entities property: list of entities at this cell
static PyObject* get_entities(PyUIGridPointObject* self, void* closure);
// #150 - Dynamic property access for named layers // #150 - Dynamic property access for named layers
static PyObject* getattro(PyUIGridPointObject* self, PyObject* name); static PyObject* getattro(PyUIGridPointObject* self, PyObject* name);
static int setattro(PyUIGridPointObject* self, PyObject* name, PyObject* value); static int setattro(PyUIGridPointObject* self, PyObject* name, PyObject* value);
@ -64,6 +68,9 @@ public:
static int set_bool_member(PyUIGridPointStateObject* self, PyObject* value, void* closure); static int set_bool_member(PyUIGridPointStateObject* self, PyObject* value, void* closure);
static PyGetSetDef getsetters[]; static PyGetSetDef getsetters[];
static PyObject* repr(PyUIGridPointStateObject* self); static PyObject* repr(PyUIGridPointStateObject* self);
// #16 - point property: access to GridPoint (None if not discovered)
static PyObject* get_point(PyUIGridPointStateObject* self, void* closure);
}; };
namespace mcrfpydef { namespace mcrfpydef {

View File

@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""
Test GridPoint.entities property (#114)
========================================
Tests the GridPoint.entities property that returns a list of entities
at that grid cell position.
"""
import mcrfpy
import sys
def run_tests():
"""Run GridPoint.entities tests"""
print("=== GridPoint.entities Tests ===\n")
# Test 1: Basic entity listing
print("Test 1: Basic entity listing")
grid = mcrfpy.Grid(pos=(0, 0), size=(640, 400), grid_size=(40, 25))
# Add entities at various positions
e1 = mcrfpy.Entity((5, 5))
e2 = mcrfpy.Entity((5, 5)) # Same position as e1
e3 = mcrfpy.Entity((10, 10))
grid.entities.append(e1)
grid.entities.append(e2)
grid.entities.append(e3)
# Check entities at (5, 5)
pt = grid.at(5, 5)
entities_at_5_5 = pt.entities
assert len(entities_at_5_5) == 2, f"Expected 2 entities at (5,5), got {len(entities_at_5_5)}"
print(f" Found {len(entities_at_5_5)} entities at (5, 5)")
# Check entities at (10, 10)
pt2 = grid.at(10, 10)
entities_at_10_10 = pt2.entities
assert len(entities_at_10_10) == 1, f"Expected 1 entity at (10,10), got {len(entities_at_10_10)}"
print(f" Found {len(entities_at_10_10)} entity at (10, 10)")
# Check empty cell
pt3 = grid.at(0, 0)
entities_at_0_0 = pt3.entities
assert len(entities_at_0_0) == 0, f"Expected 0 entities at (0,0), got {len(entities_at_0_0)}"
print(f" Found {len(entities_at_0_0)} entities at (0, 0)")
print()
# Test 2: Entity references are valid
print("Test 2: Entity references are valid")
for e in pt.entities:
assert e.x == 5.0, f"Entity x should be 5.0, got {e.x}"
assert e.y == 5.0, f"Entity y should be 5.0, got {e.y}"
print(" All entity references have correct positions")
print()
# Test 3: Entity movement updates listing
print("Test 3: Entity movement updates listing")
e1.x = 20
e1.y = 20
# Old position should have one fewer entity
entities_at_5_5_after = grid.at(5, 5).entities
assert len(entities_at_5_5_after) == 1, f"Expected 1 entity at (5,5) after move, got {len(entities_at_5_5_after)}"
print(f" After moving e1: {len(entities_at_5_5_after)} entity at (5, 5)")
# New position should have the moved entity
entities_at_20_20 = grid.at(20, 20).entities
assert len(entities_at_20_20) == 1, f"Expected 1 entity at (20,20), got {len(entities_at_20_20)}"
print(f" After moving e1: {len(entities_at_20_20)} entity at (20, 20)")
print()
# Test 4: Multiple grids are independent
print("Test 4: Multiple grids are independent")
grid2 = mcrfpy.Grid(pos=(0, 0), size=(640, 400), grid_size=(40, 25))
e4 = mcrfpy.Entity((5, 5))
grid2.entities.append(e4)
# Original grid should not see grid2's entity
assert len(grid.at(5, 5).entities) == 1, "Original grid should still have 1 entity at (5,5)"
assert len(grid2.at(5, 5).entities) == 1, "Second grid should have 1 entity at (5,5)"
print(" Grids maintain independent entity lists")
print()
print("=== All GridPoint.entities 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)

View File

@ -0,0 +1,167 @@
#!/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)