Preserve Python subclass identity for entities in grids (reopens #266)

The Phase 3 fix for #266 removed UIEntity::self which prevented
tp_dealloc from ever running. However, this also allowed Python
subclass wrappers (GameEntity, ZoneExit, etc.) to be GC'd while
the C++ entity lived on in a grid. Later access via grid.entities
returned a base Entity wrapper, losing all subclass methods.

Fix: Add UIEntity::pyobject field that holds a strong reference to
the Python wrapper. Set in init(), cleared when the entity leaves
a grid (die(), set_grid(None), collection removal). This keeps
subclass identity alive while in a grid, but allows proper GC when
the entity is removed. Added releasePyIdentity() helper called at
all grid exit points.

Regression test exercises Liber Noster patterns: subclass hierarchy,
isinstance() checks, combat mixins, tooltip/send methods, GC
survival, die(), pop(), remove(), and stress test with 20 entities.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-03-09 00:24:26 -04:00
commit 836a0584df
4 changed files with 291 additions and 2 deletions

View file

@ -61,6 +61,7 @@ class UIEntity
{
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;
UISprite sprite;
@ -70,7 +71,17 @@ public:
UIEntity();
~UIEntity();
// Release the strong reference that preserves Python subclass identity.
// Called when entity leaves a grid (die, set_grid, collection removal).
void releasePyIdentity() {
if (pyobject) {
PyObject* tmp = pyobject;
pyobject = nullptr;
Py_DECREF(tmp);
}
}
// Visibility methods
void ensureGridstate(); // Resize gridstate to match current grid dimensions
void updateVisibility(); // Update gridstate from current FOV
@ -136,6 +147,8 @@ namespace mcrfpydef {
.tp_itemsize = 0,
.tp_dealloc = [](PyObject* obj) {
auto* self = (PyUIEntityObject*)obj;
// Clear the identity ref without DECREF - we ARE this object
if (self->data) self->data->pyobject = nullptr;
if (self->weakreflist) PyObject_ClearWeakRefs(obj);
self->data.reset();
Py_TYPE(obj)->tp_free(obj);