McRogueFace/tests/regression/issue_266_subclass_identity_test.py
John McCardle 836a0584df 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>
2026-03-09 00:24:26 -04:00

242 lines
7.3 KiB
Python

"""Regression test: Python subclass identity preservation in grid.entities.
Tests that Entity subclasses retain their Python type and custom methods
when accessed via grid.entities iteration, indexing, and pop(). This
pattern is critical for games like Liber Noster (7DRL 2026) where
subclasses like GameEntity, ZoneExit, Combatant add custom behavior.
Related issues: #266 (self-reference cycle), #275 (tp_dealloc)
"""
import mcrfpy
import gc
import sys
PASS = 0
FAIL = 0
def test(name, condition):
global PASS, FAIL
if condition:
PASS += 1
print(f" PASS: {name}")
else:
FAIL += 1
print(f" FAIL: {name}")
# --- Define subclass hierarchy mimicking Liber Noster ---
class GameEntity(mcrfpy.Entity):
"""Base game entity with custom methods."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.entity_name = kwargs.get("name", "unnamed")
self.description = "A game entity"
def tooltip(self):
return f"{self.entity_name}: {self.description}"
class ZoneExit(GameEntity):
"""Teleportation entity."""
def __init__(self, target_zone="unknown", **kwargs):
super().__init__(**kwargs)
self.target_zone = target_zone
self.description = f"Exit to {target_zone}"
def send(self, target_entity):
return f"Sending {target_entity} to {self.target_zone}"
class AnimatedEntity(GameEntity):
"""Entity with animation state."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.moving = False
self.facing_dir = 0
def anim(self, direction):
self.facing_dir = direction
return f"Animating {self.entity_name} dir={direction}"
class Combatant:
"""Mixin class for combat (not an Entity subclass)."""
def __init_combatant__(self, hp=10, atk=3, dfn=1):
self.hp = hp
self.atk = atk
self.dfn = dfn
self.alive = True
def bump(self, target):
damage = max(0, self.atk - target.dfn)
target.hp -= damage
if target.hp <= 0:
target.alive = False
return {"damage": damage, "defeated": not target.alive}
class Enemy(AnimatedEntity, Combatant):
"""Combines animation and combat."""
def __init__(self, hp=10, atk=3, dfn=1, **kwargs):
AnimatedEntity.__init__(self, **kwargs)
self.__init_combatant__(hp=hp, atk=atk, dfn=dfn)
# --- Tests ---
print("Testing Entity subclass identity preservation...")
# Create a grid and scene
scene = mcrfpy.Scene("test_identity")
grid = mcrfpy.Grid(grid_size=(20, 20), pos=(0, 0), size=(400, 400))
scene.children.append(grid)
mcrfpy.current_scene = scene
# Test 1: Basic subclass creation and grid addition
zexit = ZoneExit(target_zone="dungeon", grid_pos=(5, 5))
grid.entities.append(zexit)
test("ZoneExit added to grid", len(grid.entities) == 1)
# Test 2: Access via index preserves type
e = grid.entities[0]
test("grid.entities[0] returns ZoneExit type", type(e).__name__ == "ZoneExit")
test("grid.entities[0] has send() method", hasattr(e, "send"))
test("grid.entities[0].send() works", e.send("player") == "Sending player to dungeon")
test("grid.entities[0].tooltip() works", "dungeon" in e.tooltip())
del e
# Test 3: Access via iteration preserves type
for e in grid.entities:
test("iteration returns ZoneExit type", type(e).__name__ == "ZoneExit")
test("iteration has send() method", hasattr(e, "send"))
test("iteration has target_zone attr", hasattr(e, "target_zone"))
# Test 4: Drop the original Python reference, force GC
del zexit
gc.collect()
gc.collect()
# This is the critical test: after GC, the subclass should survive
e = grid.entities[0]
test("after GC: type preserved", type(e).__name__ == "ZoneExit")
test("after GC: send() works", e.send("hero") == "Sending hero to dungeon")
test("after GC: target_zone preserved", e.target_zone == "dungeon")
test("after GC: tooltip() works", "dungeon" in e.tooltip())
del e
# Test 5: Multiple subclass types in same grid
enemy1 = Enemy(hp=20, atk=5, dfn=2, name="goblin", grid_pos=(3, 3))
enemy2 = Enemy(hp=15, atk=4, dfn=1, name="skeleton", grid_pos=(7, 7))
anim = AnimatedEntity(name="npc", grid_pos=(10, 10))
grid.entities.append(enemy1)
grid.entities.append(enemy2)
grid.entities.append(anim)
# Drop Python refs, force GC
del enemy1, enemy2, anim
gc.collect()
gc.collect()
test("multiple types: 4 entities in grid", len(grid.entities) == 4)
# Check each entity's type via iteration
types_found = []
for e in grid.entities:
types_found.append(type(e).__name__)
test("type list correct", types_found == ["ZoneExit", "Enemy", "Enemy", "AnimatedEntity"])
# Test 6: isinstance checks (Liber Noster pattern: find Combatants)
combatants = []
for e in grid.entities:
if isinstance(e, Combatant) and e.alive:
combatants.append(e)
test("isinstance(Combatant) finds 2 enemies", len(combatants) == 2)
test("combatant has bump()", hasattr(combatants[0], "bump"))
test("combatant has hp", hasattr(combatants[0], "hp"))
# Test 7: Combat between entities retrieved from grid
attacker = combatants[0]
defender = combatants[1]
result = attacker.bump(defender)
test("combat returns result dict", "damage" in result)
test("combat deals damage", defender.hp < 15)
del attacker, defender, combatants
# Test 8: hasattr checks (Liber Noster pattern: tooltip, pickup)
gc.collect()
for e in grid.entities:
if hasattr(e, "tooltip"):
tip = e.tooltip()
test(f"tooltip() on {type(e).__name__}", tip is not None)
break # Just test one
# Test 9: die() releases identity, allows GC
pre_count = len(grid.entities)
e = grid.entities[0] # ZoneExit
test("before die: is ZoneExit", type(e).__name__ == "ZoneExit")
e.die()
test("after die: entity count decreased", len(grid.entities) == pre_count - 1)
del e
gc.collect()
# Test 10: Remaining entities still have correct types
types_after_die = [type(e).__name__ for e in grid.entities]
test("types after die()", types_after_die == ["Enemy", "Enemy", "AnimatedEntity"])
# Test 11: pop() preserves identity
e = grid.entities.pop(0)
test("pop() returns Enemy", type(e).__name__ == "Enemy")
test("pop() entity has combat attrs", hasattr(e, "hp"))
del e
# Test 12: Entity created with grid= kwarg
zexit2 = ZoneExit(target_zone="tower", grid_pos=(1, 1), grid=grid)
del zexit2
gc.collect()
gc.collect()
e = grid.entities[-1]
test("grid= kwarg: type preserved after GC", type(e).__name__ == "ZoneExit")
test("grid= kwarg: send() works", "tower" in e.send("x"))
del e
# Test 13: remove() releases identity
enemy_ref = None
for e in grid.entities:
if isinstance(e, Enemy):
enemy_ref = e
break
test("found Enemy for remove test", enemy_ref is not None)
if enemy_ref:
grid.entities.remove(enemy_ref)
del enemy_ref
gc.collect()
# Test 14: Stress test - create many entities, drop refs, verify all survive
for i in range(20):
ent = ZoneExit(target_zone=f"zone_{i}", grid_pos=(i % 20, i // 20))
grid.entities.append(ent)
# Don't hold any Python reference
del ent
gc.collect()
gc.collect()
zone_exits_found = 0
for e in grid.entities:
if isinstance(e, ZoneExit) and hasattr(e, "target_zone"):
zone_exits_found += 1
test("stress: all 21 ZoneExits preserved", zone_exits_found == 21)
# Summary
print(f"\n{'='*50}")
total = PASS + FAIL
if FAIL == 0:
print(f"PASS: all {PASS} subclass identity tests passed")
sys.exit(0)
else:
print(f"FAIL: {FAIL}/{total} tests failed")
sys.exit(1)