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

@ -26,6 +26,7 @@ UIEntity::UIEntity()
}
UIEntity::~UIEntity() {
releasePyIdentity();
if (serial_number != 0) {
PythonObjectCache::getInstance().remove(serial_number);
}
@ -230,7 +231,7 @@ int UIEntity::init(PyUIEntityObject* self, PyObject* args, PyObject* kwds) {
// Initialize weak reference list
self->weakreflist = NULL;
// Register in Python object cache
// Register in Python object cache
if (self->data->serial_number == 0) {
self->data->serial_number = PythonObjectCache::getInstance().assignSerial();
PyObject* weakref = PyWeakref_NewRef((PyObject*)self, NULL);
@ -239,6 +240,13 @@ int UIEntity::init(PyUIEntityObject* self, PyObject* args, PyObject* kwds) {
Py_DECREF(weakref); // Cache owns the reference now
}
}
// Hold a strong reference to preserve Python subclass identity.
// Without this, the Python wrapper can be GC'd while the C++ entity
// lives on in a grid, and later access returns a base Entity wrapper
// that lacks subclass methods. Cleared in die() and set_grid(None).
self->data->pyobject = (PyObject*)self;
Py_INCREF(self);
// Set texture and sprite index
if (texture_ptr) {
@ -660,6 +668,9 @@ int UIEntity::set_grid(PyUIEntityObject* self, PyObject* value, void* closure)
entities->erase(it);
}
self->data->grid.reset();
// Release identity strong ref — entity left grid
self->data->releasePyIdentity();
}
return 0;
}
@ -762,6 +773,9 @@ PyObject* UIEntity::die(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored))
entities->erase(it);
// Clear the grid reference
self->data->grid.reset();
// Release identity strong ref — entity is no longer in a grid
self->data->releasePyIdentity();
}
Py_RETURN_NONE;