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:
John McCardle 2026-03-15 22:05:06 -04:00
commit 2f1e472245
15 changed files with 886 additions and 34 deletions

View 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)

View 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)

View 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)