Phase 2: Entity data model extensions for behavior system
- Add Behavior enum (IDLE..FLEE, 11 values) and Trigger enum (DONE, BLOCKED, TARGET) as runtime IntEnum classes (closes #297, closes #298) - Add entity label system: labels property (frozenset), add_label(), remove_label(), has_label(), constructor kwarg (closes #296) - Add cell_pos integer logical position decoupled from float draw_pos; grid_pos now aliases cell_pos; SpatialHash::updateCell() for cell-based bucket management; FOV/visibility uses cell_position (closes #295) - Add step callback and default_behavior properties to Entity for grid.step() turn management (closes #299) - Update updateVisibility, visible_entities, ColorLayer::updatePerspective to use cell_position instead of float position BREAKING: grid_pos no longer derives from float x/y position. Use cell_pos/grid_pos for logical position, draw_pos for render position. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
94f5f5a3fd
commit
2f1e472245
15 changed files with 886 additions and 34 deletions
|
|
@ -50,21 +50,23 @@ def test_entity_positions():
|
|||
if abs(entity.y - 80.0) > 0.001:
|
||||
errors.append(f"y: expected 80.0, got {entity.y}")
|
||||
|
||||
# Test 6: Setting grid_x/grid_y should update position
|
||||
# Test 6: Setting grid_x/grid_y should update cell position (#295: decoupled from pixel pos)
|
||||
entity.grid_x = 7
|
||||
entity.grid_y = 2
|
||||
if entity.grid_x != 7 or entity.grid_y != 2:
|
||||
errors.append(f"After setting grid_x/y: expected (7, 2), got ({entity.grid_x}, {entity.grid_y})")
|
||||
# Pixel should update too: (7, 2) * 16 = (112, 32)
|
||||
if abs(entity.x - 112.0) > 0.001 or abs(entity.y - 32.0) > 0.001:
|
||||
errors.append(f"After grid_x/y set, pixel pos: expected (112, 32), got ({entity.x}, {entity.y})")
|
||||
# #295: cell_pos (grid_x/y) is decoupled from pixel pos - pixel pos NOT updated
|
||||
# Pixel pos should remain at the draw_pos * tile_size (3*16=48, 5*16=80 from earlier)
|
||||
if abs(entity.x - 48.0) > 0.001 or abs(entity.y - 80.0) > 0.001:
|
||||
errors.append(f"After grid_x/y set, pixel pos should be unchanged: expected (48, 80), got ({entity.x}, {entity.y})")
|
||||
|
||||
# Test 7: Setting pos (pixels) should update grid position
|
||||
# Test 7: Setting pos (pixels) should update draw_pos but NOT grid_pos (#295)
|
||||
entity.pos = mcrfpy.Vector(64, 96) # (64, 96) / 16 = (4, 6) tiles
|
||||
if abs(entity.draw_pos.x - 4.0) > 0.001 or abs(entity.draw_pos.y - 6.0) > 0.001:
|
||||
errors.append(f"After setting pos, draw_pos: expected (4, 6), got ({entity.draw_pos.x}, {entity.draw_pos.y})")
|
||||
if entity.grid_x != 4 or entity.grid_y != 6:
|
||||
errors.append(f"After setting pos, grid_x/y: expected (4, 6), got ({entity.grid_x}, {entity.grid_y})")
|
||||
# #295: grid_pos is cell_pos, not derived from float position - should be (7, 2) from above
|
||||
if entity.grid_x != 7 or entity.grid_y != 2:
|
||||
errors.append(f"After setting pos, grid_x/y should be unchanged: expected (7, 2), got ({entity.grid_x}, {entity.grid_y})")
|
||||
|
||||
# Test 8: repr should show position info
|
||||
repr_str = repr(entity)
|
||||
|
|
|
|||
78
tests/regression/issue_295_cell_pos_test.py
Normal file
78
tests/regression/issue_295_cell_pos_test.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""Regression test for #295: Entity cell_pos integer logical position."""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
def test_cell_pos_init():
|
||||
"""cell_pos initialized from grid_pos on construction."""
|
||||
e = mcrfpy.Entity((5, 7))
|
||||
assert e.cell_x == 5, f"cell_x should be 5, got {e.cell_x}"
|
||||
assert e.cell_y == 7, f"cell_y should be 7, got {e.cell_y}"
|
||||
print("PASS: cell_pos initialized from constructor")
|
||||
|
||||
def test_cell_pos_grid_pos_alias():
|
||||
"""grid_pos is an alias for cell_pos."""
|
||||
e = mcrfpy.Entity((3, 4))
|
||||
# grid_pos should match cell_pos
|
||||
assert e.grid_pos.x == e.cell_pos.x
|
||||
assert e.grid_pos.y == e.cell_pos.y
|
||||
|
||||
# Setting grid_pos should update cell_pos
|
||||
e.grid_pos = (10, 20)
|
||||
assert e.cell_x == 10
|
||||
assert e.cell_y == 20
|
||||
print("PASS: grid_pos aliases cell_pos")
|
||||
|
||||
def test_cell_pos_independent_from_draw_pos():
|
||||
"""cell_pos does not change when draw_pos (float position) changes."""
|
||||
e = mcrfpy.Entity((5, 5))
|
||||
|
||||
# Change draw_pos (float position for rendering)
|
||||
e.draw_pos = (5.5, 5.5)
|
||||
|
||||
# cell_pos should be unchanged
|
||||
assert e.cell_x == 5, f"cell_x should still be 5 after draw_pos change, got {e.cell_x}"
|
||||
assert e.cell_y == 5, f"cell_y should still be 5 after draw_pos change, got {e.cell_y}"
|
||||
print("PASS: cell_pos independent from draw_pos")
|
||||
|
||||
def test_cell_pos_spatial_hash():
|
||||
"""GridPoint.entities uses cell_pos for matching."""
|
||||
scene = mcrfpy.Scene("test295")
|
||||
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((5, 5), grid=grid)
|
||||
|
||||
# Entity should appear at cell (5, 5)
|
||||
assert len(grid.at(5, 5).entities) == 1
|
||||
|
||||
# Move cell_pos to (10, 10)
|
||||
e.cell_pos = (10, 10)
|
||||
assert len(grid.at(5, 5).entities) == 0, "Old cell should be empty"
|
||||
assert len(grid.at(10, 10).entities) == 1, "New cell should have entity"
|
||||
print("PASS: spatial hash uses cell_pos")
|
||||
|
||||
def test_cell_pos_member_access():
|
||||
"""cell_x and cell_y read/write correctly."""
|
||||
e = mcrfpy.Entity((3, 7))
|
||||
assert e.cell_x == 3
|
||||
assert e.cell_y == 7
|
||||
|
||||
e.cell_x = 15
|
||||
assert e.cell_x == 15
|
||||
assert e.cell_y == 7 # y unchanged
|
||||
|
||||
e.cell_y = 20
|
||||
assert e.cell_x == 15 # x unchanged
|
||||
assert e.cell_y == 20
|
||||
print("PASS: cell_x/cell_y member access")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_cell_pos_init()
|
||||
test_cell_pos_grid_pos_alias()
|
||||
test_cell_pos_independent_from_draw_pos()
|
||||
test_cell_pos_spatial_hash()
|
||||
test_cell_pos_member_access()
|
||||
print("All #295 tests passed")
|
||||
sys.exit(0)
|
||||
70
tests/unit/behavior_trigger_enum_test.py
Normal file
70
tests/unit/behavior_trigger_enum_test.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""Unit test for #297/#298: Behavior and Trigger enums."""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
def test_behavior_values():
|
||||
"""All Behavior enum values are accessible and have correct int values."""
|
||||
expected = {
|
||||
"IDLE": 0, "CUSTOM": 1, "NOISE4": 2, "NOISE8": 3,
|
||||
"PATH": 4, "WAYPOINT": 5, "PATROL": 6, "LOOP": 7,
|
||||
"SLEEP": 8, "SEEK": 9, "FLEE": 10,
|
||||
}
|
||||
for name, value in expected.items():
|
||||
b = getattr(mcrfpy.Behavior, name)
|
||||
assert int(b) == value, f"Behavior.{name} should be {value}, got {int(b)}"
|
||||
print("PASS: All Behavior values correct")
|
||||
|
||||
def test_trigger_values():
|
||||
"""All Trigger enum values are accessible and have correct int values."""
|
||||
expected = {"DONE": 0, "BLOCKED": 1, "TARGET": 2}
|
||||
for name, value in expected.items():
|
||||
t = getattr(mcrfpy.Trigger, name)
|
||||
assert int(t) == value, f"Trigger.{name} should be {value}, got {int(t)}"
|
||||
print("PASS: All Trigger values correct")
|
||||
|
||||
def test_string_comparison():
|
||||
"""Enums compare equal to their name strings."""
|
||||
assert mcrfpy.Behavior.IDLE == "IDLE"
|
||||
assert mcrfpy.Behavior.SEEK == "SEEK"
|
||||
assert mcrfpy.Trigger.DONE == "DONE"
|
||||
assert not (mcrfpy.Behavior.IDLE == "SEEK")
|
||||
print("PASS: String comparison works")
|
||||
|
||||
def test_inequality():
|
||||
"""__ne__ works correctly for both string and int."""
|
||||
assert mcrfpy.Behavior.IDLE != "SEEK"
|
||||
assert not (mcrfpy.Behavior.IDLE != "IDLE")
|
||||
assert mcrfpy.Trigger.DONE != 1
|
||||
assert not (mcrfpy.Trigger.DONE != 0)
|
||||
print("PASS: Inequality works")
|
||||
|
||||
def test_int_comparison():
|
||||
"""Enums compare equal to their integer values."""
|
||||
assert mcrfpy.Behavior.FLEE == 10
|
||||
assert mcrfpy.Trigger.BLOCKED == 1
|
||||
print("PASS: Int comparison works")
|
||||
|
||||
def test_repr():
|
||||
"""Repr format is ClassName.MEMBER."""
|
||||
assert repr(mcrfpy.Behavior.IDLE) == "Behavior.IDLE"
|
||||
assert repr(mcrfpy.Trigger.TARGET) == "Trigger.TARGET"
|
||||
print("PASS: Repr format correct")
|
||||
|
||||
def test_hashable():
|
||||
"""Enum values are hashable (can be used in sets/dicts)."""
|
||||
s = {mcrfpy.Behavior.IDLE, mcrfpy.Behavior.SEEK}
|
||||
assert len(s) == 2
|
||||
d = {mcrfpy.Trigger.DONE: "done", mcrfpy.Trigger.TARGET: "target"}
|
||||
assert d[mcrfpy.Trigger.DONE] == "done"
|
||||
print("PASS: Hashable")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_behavior_values()
|
||||
test_trigger_values()
|
||||
test_string_comparison()
|
||||
test_inequality()
|
||||
test_int_comparison()
|
||||
test_repr()
|
||||
test_hashable()
|
||||
print("All #297/#298 tests passed")
|
||||
sys.exit(0)
|
||||
75
tests/unit/entity_labels_test.py
Normal file
75
tests/unit/entity_labels_test.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Unit test for #296: Entity label system."""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
def test_labels_crud():
|
||||
"""Basic add/remove/has operations."""
|
||||
e = mcrfpy.Entity()
|
||||
assert len(e.labels) == 0, "New entity should have no labels"
|
||||
|
||||
e.add_label("solid")
|
||||
assert e.has_label("solid"), "Should have 'solid' after add"
|
||||
assert not e.has_label("npc"), "Should not have 'npc'"
|
||||
|
||||
e.add_label("npc")
|
||||
assert len(e.labels) == 2
|
||||
|
||||
e.remove_label("solid")
|
||||
assert not e.has_label("solid"), "Should not have 'solid' after remove"
|
||||
assert e.has_label("npc"), "'npc' should remain"
|
||||
print("PASS: labels CRUD")
|
||||
|
||||
def test_labels_frozenset():
|
||||
"""Labels getter returns frozenset."""
|
||||
e = mcrfpy.Entity()
|
||||
e.add_label("a")
|
||||
e.add_label("b")
|
||||
labels = e.labels
|
||||
assert isinstance(labels, frozenset), f"Expected frozenset, got {type(labels)}"
|
||||
assert labels == frozenset({"a", "b"})
|
||||
print("PASS: labels returns frozenset")
|
||||
|
||||
def test_labels_setter():
|
||||
"""Labels setter accepts any iterable of strings."""
|
||||
e = mcrfpy.Entity()
|
||||
e.labels = ["x", "y", "z"]
|
||||
assert e.labels == frozenset({"x", "y", "z"})
|
||||
|
||||
e.labels = {"replaced"}
|
||||
assert e.labels == frozenset({"replaced"})
|
||||
|
||||
e.labels = frozenset()
|
||||
assert len(e.labels) == 0
|
||||
print("PASS: labels setter")
|
||||
|
||||
def test_labels_constructor():
|
||||
"""Labels kwarg in constructor."""
|
||||
e = mcrfpy.Entity(labels={"solid", "npc"})
|
||||
assert e.has_label("solid")
|
||||
assert e.has_label("npc")
|
||||
assert len(e.labels) == 2
|
||||
print("PASS: labels constructor kwarg")
|
||||
|
||||
def test_labels_duplicate_add():
|
||||
"""Adding same label twice is idempotent."""
|
||||
e = mcrfpy.Entity()
|
||||
e.add_label("solid")
|
||||
e.add_label("solid")
|
||||
assert len(e.labels) == 1
|
||||
print("PASS: duplicate add is idempotent")
|
||||
|
||||
def test_labels_remove_missing():
|
||||
"""Removing non-existent label is a no-op."""
|
||||
e = mcrfpy.Entity()
|
||||
e.remove_label("nonexistent") # Should not raise
|
||||
print("PASS: remove missing label is no-op")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_labels_crud()
|
||||
test_labels_frozenset()
|
||||
test_labels_setter()
|
||||
test_labels_constructor()
|
||||
test_labels_duplicate_add()
|
||||
test_labels_remove_missing()
|
||||
print("All #296 tests passed")
|
||||
sys.exit(0)
|
||||
72
tests/unit/entity_step_callback_test.py
Normal file
72
tests/unit/entity_step_callback_test.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""Unit test for #299: Entity step() callback and default_behavior."""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
def test_step_callback_assignment():
|
||||
"""step callback can be assigned and retrieved."""
|
||||
e = mcrfpy.Entity()
|
||||
assert e.step is None, "Initial step should be None"
|
||||
|
||||
def my_step(trigger, data):
|
||||
pass
|
||||
|
||||
e.step = my_step
|
||||
assert e.step is my_step, "step should be the assigned callable"
|
||||
print("PASS: step callback assignment")
|
||||
|
||||
def test_step_callback_clear():
|
||||
"""Setting step to None clears the callback."""
|
||||
e = mcrfpy.Entity()
|
||||
e.step = lambda t, d: None
|
||||
assert e.step is not None
|
||||
|
||||
e.step = None
|
||||
assert e.step is None, "step should be None after clearing"
|
||||
print("PASS: step callback clear")
|
||||
|
||||
def test_step_callback_type_check():
|
||||
"""step rejects non-callable values."""
|
||||
e = mcrfpy.Entity()
|
||||
try:
|
||||
e.step = 42
|
||||
assert False, "Should have raised TypeError"
|
||||
except TypeError:
|
||||
pass
|
||||
print("PASS: step type check")
|
||||
|
||||
def test_default_behavior_roundtrip():
|
||||
"""default_behavior property round-trips correctly."""
|
||||
e = mcrfpy.Entity()
|
||||
assert e.default_behavior == 0, "Initial default_behavior should be 0 (IDLE)"
|
||||
|
||||
e.default_behavior = int(mcrfpy.Behavior.SEEK)
|
||||
assert e.default_behavior == 9
|
||||
|
||||
e.default_behavior = int(mcrfpy.Behavior.IDLE)
|
||||
assert e.default_behavior == 0
|
||||
print("PASS: default_behavior round-trip")
|
||||
|
||||
def test_step_callback_subclass():
|
||||
"""Subclass def step() overrides the C getter - this is expected behavior.
|
||||
Phase 3 grid.step() will check for subclass method override via PyObject_GetAttrString."""
|
||||
class Guard(mcrfpy.Entity):
|
||||
def on_step(self, trigger, data):
|
||||
self.stepped = True
|
||||
|
||||
g = Guard()
|
||||
# The step property should be None (no callback assigned)
|
||||
assert g.step is None, "step callback should be None initially"
|
||||
|
||||
# Subclass methods with different names are accessible
|
||||
assert hasattr(g, 'on_step'), "subclass should have on_step method"
|
||||
assert callable(g.on_step)
|
||||
print("PASS: subclass step method coexistence")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_step_callback_assignment()
|
||||
test_step_callback_clear()
|
||||
test_step_callback_type_check()
|
||||
test_default_behavior_roundtrip()
|
||||
test_step_callback_subclass()
|
||||
print("All #299 tests passed")
|
||||
sys.exit(0)
|
||||
Loading…
Add table
Add a link
Reference in a new issue