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

@ -156,6 +156,7 @@ int UIEntityCollection::setitem(PyUIEntityCollectionObject* self, Py_ssize_t ind
if (self->grid) {
self->grid->spatial_hash.remove(*it);
}
(*it)->releasePyIdentity();
(*it)->grid = nullptr;
list->erase(it);
return 0;
@ -181,6 +182,7 @@ int UIEntityCollection::setitem(PyUIEntityCollectionObject* self, Py_ssize_t ind
}
// Clear grid reference from the old entity
(*it)->releasePyIdentity();
(*it)->grid = nullptr;
// Replace the element and set grid reference
@ -409,6 +411,7 @@ int UIEntityCollection::ass_subscript(PyUIEntityCollectionObject* self, PyObject
if (self->grid) {
self->grid->spatial_hash.remove(*it);
}
(*it)->releasePyIdentity();
(*it)->grid = nullptr;
}
self->data->erase(start_it, stop_it);
@ -426,6 +429,7 @@ int UIEntityCollection::ass_subscript(PyUIEntityCollectionObject* self, PyObject
if (self->grid) {
self->grid->spatial_hash.remove(*it);
}
(*it)->releasePyIdentity();
(*it)->grid = nullptr;
self->data->erase(it);
}
@ -479,6 +483,7 @@ int UIEntityCollection::ass_subscript(PyUIEntityCollectionObject* self, PyObject
if (self->grid) {
self->grid->spatial_hash.remove(*it);
}
(*it)->releasePyIdentity();
(*it)->grid = nullptr;
}
@ -610,6 +615,7 @@ PyObject* UIEntityCollection::remove(PyUIEntityCollectionObject* self, PyObject*
if (self->grid) {
self->grid->spatial_hash.remove(*it);
}
(*it)->releasePyIdentity();
(*it)->grid = nullptr;
list->erase(it);
Py_RETURN_NONE;
@ -727,6 +733,20 @@ PyObject* UIEntityCollection::pop(PyUIEntityCollectionObject* self, PyObject* ar
entity->grid = nullptr;
list->erase(it);
// Return cached Python object if available (preserves subclass identity)
if (entity->serial_number != 0) {
PyObject* cached = PythonObjectCache::getInstance().lookup(entity->serial_number);
if (cached) {
// Release identity ref — entity is leaving the grid
// The caller now holds a strong ref via 'cached'
entity->releasePyIdentity();
return cached;
}
}
// Release identity ref (no cached object to return)
entity->releasePyIdentity();
// Create Python object for the entity
PyTypeObject* entity_type = &mcrfpydef::PyUIEntityType;