Phase 1: Safety & performance foundation for Grid/Entity overhaul

- Fix Entity3D self-reference cycle: replace raw `self` pointer with
  `pyobject` strong-ref pattern matching UIEntity (closes #266)
- TileLayer inherits Grid texture when none set, in all three attachment
  paths: constructor, add_layer(), and .grid property (closes #254)
- Add SpatialHash::queryCell() for O(1) entity-at-cell lookup; fix
  missing spatial_hash.insert() in Entity.__init__ grid= kwarg path;
  use queryCell in GridPoint.entities (closes #253)
- Add FOV dirty flag and parameter cache to skip redundant computeFOV
  calls when map unchanged and params match (closes #292)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-03-15 21:48:24 -04:00
commit 94f5f5a3fd
13 changed files with 436 additions and 47 deletions

View file

@ -0,0 +1,73 @@
"""Regression test for #253: GridPoint.entities uses spatial hash for O(1) lookup."""
import mcrfpy
import sys
def test_gridpoint_entities_basic():
"""Entities at known positions are returned correctly."""
scene = mcrfpy.Scene("test253")
mcrfpy.current_scene = scene
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320))
scene.children.append(grid)
# Place entities at specific cells
e1 = mcrfpy.Entity((5, 5), grid=grid)
e2 = mcrfpy.Entity((5, 5), grid=grid)
e3 = mcrfpy.Entity((10, 10), grid=grid)
# Query cell (5, 5) - should have 2 entities
cell_5_5 = grid.at(5, 5)
ents = cell_5_5.entities
assert len(ents) == 2, f"Expected 2 entities at (5,5), got {len(ents)}"
print("PASS: 2 entities at (5,5)")
# Query cell (10, 10) - should have 1 entity
cell_10_10 = grid.at(10, 10)
ents = cell_10_10.entities
assert len(ents) == 1, f"Expected 1 entity at (10,10), got {len(ents)}"
print("PASS: 1 entity at (10,10)")
def test_gridpoint_entities_empty():
"""Empty cells return empty list."""
scene = mcrfpy.Scene("test253b")
mcrfpy.current_scene = scene
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320))
scene.children.append(grid)
# No entities placed - empty cell should return empty list
cell = grid.at(0, 0)
ents = cell.entities
assert len(ents) == 0, f"Expected 0 entities, got {len(ents)}"
print("PASS: empty cell returns empty list")
def test_gridpoint_entities_after_move():
"""Moving an entity updates spatial hash so GridPoint.entities reflects new position."""
scene = mcrfpy.Scene("test253c")
mcrfpy.current_scene = scene
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320))
scene.children.append(grid)
e = mcrfpy.Entity((3, 3), grid=grid)
# Verify entity is at (3, 3)
assert len(grid.at(3, 3).entities) == 1, "Entity should be at (3,3)"
# Move entity to (7, 7)
e.grid_pos = (7, 7)
# Old cell should be empty, new cell should have the entity
assert len(grid.at(3, 3).entities) == 0, "Old cell should be empty after move"
assert len(grid.at(7, 7).entities) == 1, "New cell should have entity after move"
print("PASS: entity move updates spatial hash correctly")
if __name__ == "__main__":
test_gridpoint_entities_basic()
test_gridpoint_entities_empty()
test_gridpoint_entities_after_move()
print("All #253 tests passed")
sys.exit(0)

View file

@ -0,0 +1,67 @@
"""Regression test for #254: TileLayer inherits Grid texture when none set."""
import mcrfpy
import sys
def test_tilelayer_texture_inheritance():
"""TileLayer without texture should inherit grid's texture on attachment."""
scene = mcrfpy.Scene("test254")
mcrfpy.current_scene = scene
# Create grid with texture
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160))
scene.children.append(grid)
# Create TileLayer WITHOUT texture
layer_no_tex = mcrfpy.TileLayer(name="terrain", z_index=0)
grid.add_layer(layer_no_tex)
# Verify it inherited the grid's texture
assert layer_no_tex.texture is not None, "TileLayer should inherit grid texture"
print("PASS: TileLayer without texture inherits grid texture")
# Create TileLayer WITH explicit texture
tex2 = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
layer_with_tex = mcrfpy.TileLayer(name="overlay", z_index=1, texture=tex2)
grid.add_layer(layer_with_tex)
# Verify it kept its own texture
assert layer_with_tex.texture is not None, "TileLayer with texture should keep it"
print("PASS: TileLayer with explicit texture keeps its own")
def test_tilelayer_texture_via_constructor():
"""TileLayer passed in Grid constructor should also inherit texture."""
scene = mcrfpy.Scene("test254b")
mcrfpy.current_scene = scene
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
layer = mcrfpy.TileLayer(name="base", z_index=0)
grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160),
layers=[layer])
scene.children.append(grid)
assert layer.texture is not None, "TileLayer in constructor should inherit grid texture"
print("PASS: TileLayer in constructor inherits grid texture")
def test_tilelayer_texture_via_grid_property():
"""TileLayer attached via layer.grid = grid should inherit texture."""
scene = mcrfpy.Scene("test254c")
mcrfpy.current_scene = scene
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160))
scene.children.append(grid)
layer = mcrfpy.TileLayer(name="via_prop", z_index=0)
layer.grid = grid
assert layer.texture is not None, "TileLayer attached via .grid should inherit texture"
print("PASS: TileLayer via .grid property inherits grid texture")
if __name__ == "__main__":
test_tilelayer_texture_inheritance()
test_tilelayer_texture_via_constructor()
test_tilelayer_texture_via_grid_property()
print("All #254 tests passed")
sys.exit(0)

View file

@ -0,0 +1,118 @@
"""Regression test for #292: Deduplicate FOV computation via dirty flag."""
import mcrfpy
import sys
def test_fov_basic_correctness():
"""FOV computation still works correctly with dirty flag."""
scene = mcrfpy.Scene("test292")
mcrfpy.current_scene = scene
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320))
scene.children.append(grid)
# Make all cells transparent and walkable
for y in range(20):
for x in range(20):
pt = grid.at(x, y)
pt.walkable = True
pt.transparent = True
# Compute FOV from center
grid.compute_fov((10, 10), radius=5)
# Center should be visible
assert grid.is_in_fov((10, 10)), "Center should be in FOV"
# Nearby cell should be visible
assert grid.is_in_fov((10, 11)), "Adjacent cell should be in FOV"
# Far cell should NOT be visible
assert not grid.is_in_fov((0, 0)), "Far cell should not be in FOV"
print("PASS: FOV basic correctness")
def test_fov_duplicate_call():
"""Calling computeFOV twice with same params should still give correct results."""
scene = mcrfpy.Scene("test292b")
mcrfpy.current_scene = scene
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320))
scene.children.append(grid)
for y in range(20):
for x in range(20):
pt = grid.at(x, y)
pt.walkable = True
pt.transparent = True
# Compute FOV twice with same params (second should be skipped internally)
grid.compute_fov((10, 10), radius=5)
result1 = grid.is_in_fov((10, 11))
grid.compute_fov((10, 10), radius=5)
result2 = grid.is_in_fov((10, 11))
assert result1 == result2, "Duplicate FOV call should give same result"
print("PASS: Duplicate FOV call gives same result")
def test_fov_updates_after_map_change():
"""FOV should recompute after a cell's walkable/transparent changes."""
scene = mcrfpy.Scene("test292c")
mcrfpy.current_scene = scene
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320))
scene.children.append(grid)
for y in range(20):
for x in range(20):
pt = grid.at(x, y)
pt.walkable = True
pt.transparent = True
# Compute FOV - cell (10, 12) should be visible
grid.compute_fov((10, 10), radius=5)
assert grid.is_in_fov((10, 12)), "Cell should be visible initially"
# Block line of sight by making (10, 11) opaque
grid.at(10, 11).transparent = False
# Recompute FOV with same params - dirty flag should force recomputation
grid.compute_fov((10, 10), radius=5)
assert not grid.is_in_fov((10, 12)), "Cell behind wall should not be visible after map change"
print("PASS: FOV updates correctly after map change")
def test_fov_different_params_recompute():
"""FOV should recompute when params change even if map hasn't."""
scene = mcrfpy.Scene("test292d")
mcrfpy.current_scene = scene
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320))
scene.children.append(grid)
for y in range(20):
for x in range(20):
pt = grid.at(x, y)
pt.walkable = True
pt.transparent = True
# Compute from (10, 10) with radius 3
grid.compute_fov((10, 10), radius=3)
visible_3 = grid.is_in_fov((10, 14))
# Compute from (10, 10) with radius 5 - different params should recompute
grid.compute_fov((10, 10), radius=5)
visible_5 = grid.is_in_fov((10, 14))
# Radius 3 shouldn't see (10, 14), but radius 5 should
assert not visible_3, "(10,14) should not be visible with radius 3"
assert visible_5, "(10,14) should be visible with radius 5"
print("PASS: Different FOV params force recomputation")
if __name__ == "__main__":
test_fov_basic_correctness()
test_fov_duplicate_call()
test_fov_updates_after_map_change()
test_fov_different_params_recompute()
print("All #292 tests passed")
sys.exit(0)