Phase 3: Behavior system with grid.step() turn manager
- Add EntityBehavior struct with 11 behavior types: IDLE, CUSTOM, NOISE4/8, PATH, WAYPOINT, PATROL, LOOP, SLEEP, SEEK, FLEE. Each returns BehaviorOutput (MOVED/DONE/BLOCKED/NO_ACTION) without modifying entity position directly (closes #300) - Add grid.step(n=1, turn_order=None) turn manager: groups entities by turn_order, executes behaviors, fires triggers (TARGET/DONE/BLOCKED), updates cell_position and spatial hash. Snapshot-based iteration for callback safety (closes #301) - Entity properties: behavior_type (read-only), turn_order, move_speed, target_label, sight_radius. Method: set_behavior(type, waypoints, turns, path) - Update ColorLayer::updatePerspective to use cell_position Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2f1e472245
commit
700c21ce96
8 changed files with 1016 additions and 0 deletions
155
tests/integration/grid_step_test.py
Normal file
155
tests/integration/grid_step_test.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
"""Integration test for #301: grid.step() turn manager."""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
def make_grid(w=20, h=20):
|
||||
"""Create a walkable grid with walls on borders."""
|
||||
scene = mcrfpy.Scene("test301")
|
||||
mcrfpy.current_scene = scene
|
||||
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
||||
grid = mcrfpy.Grid(grid_size=(w, h), texture=tex, pos=(0, 0), size=(320, 320))
|
||||
scene.children.append(grid)
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
pt = grid.at(x, y)
|
||||
if x == 0 or x == w-1 or y == 0 or y == h-1:
|
||||
pt.walkable = False
|
||||
pt.transparent = False
|
||||
else:
|
||||
pt.walkable = True
|
||||
pt.transparent = True
|
||||
return grid
|
||||
|
||||
def test_step_basic():
|
||||
"""grid.step() executes without error."""
|
||||
grid = make_grid()
|
||||
e = mcrfpy.Entity((5, 5), grid=grid)
|
||||
e.set_behavior(int(mcrfpy.Behavior.NOISE4))
|
||||
grid.step() # Should not crash
|
||||
print("PASS: grid.step() basic execution")
|
||||
|
||||
def test_step_noise_movement():
|
||||
"""NOISE4 entity moves to adjacent cell after step."""
|
||||
grid = make_grid()
|
||||
e = mcrfpy.Entity((10, 10), grid=grid)
|
||||
e.set_behavior(int(mcrfpy.Behavior.NOISE4))
|
||||
e.move_speed = 0 # Instant movement
|
||||
|
||||
old_x, old_y = e.cell_x, e.cell_y
|
||||
grid.step()
|
||||
new_x, new_y = e.cell_x, e.cell_y
|
||||
|
||||
# Should have moved to an adjacent cell (or stayed if all blocked, unlikely in open grid)
|
||||
dx = abs(new_x - old_x)
|
||||
dy = abs(new_y - old_y)
|
||||
assert dx + dy <= 1, f"NOISE4 should move at most 1 cell cardinal, moved ({dx}, {dy})"
|
||||
assert dx + dy == 1, f"NOISE4 should move exactly 1 cell in open grid, moved ({dx}, {dy})"
|
||||
print("PASS: NOISE4 moves to adjacent cell")
|
||||
|
||||
def test_step_idle_no_move():
|
||||
"""IDLE entity does not move."""
|
||||
grid = make_grid()
|
||||
e = mcrfpy.Entity((10, 10), grid=grid)
|
||||
# Default behavior is IDLE
|
||||
grid.step()
|
||||
assert e.cell_x == 10 and e.cell_y == 10, "IDLE entity should not move"
|
||||
print("PASS: IDLE entity stays put")
|
||||
|
||||
def test_step_turn_order():
|
||||
"""Entities process in turn_order order."""
|
||||
grid = make_grid()
|
||||
order_log = []
|
||||
|
||||
e1 = mcrfpy.Entity((5, 5), grid=grid)
|
||||
e1.turn_order = 2
|
||||
e1.set_behavior(int(mcrfpy.Behavior.CUSTOM))
|
||||
e1.step = lambda t, d: order_log.append(2)
|
||||
|
||||
e2 = mcrfpy.Entity((7, 7), grid=grid)
|
||||
e2.turn_order = 1
|
||||
e2.set_behavior(int(mcrfpy.Behavior.CUSTOM))
|
||||
e2.step = lambda t, d: order_log.append(1)
|
||||
|
||||
# CUSTOM behavior fires NO_ACTION, so step callback won't fire via triggers
|
||||
# But we can verify turn_order sorting via a different approach
|
||||
# Let's use SLEEP with turns=1 which triggers DONE
|
||||
e1.set_behavior(int(mcrfpy.Behavior.SLEEP), turns=1)
|
||||
e1.step = lambda t, d: order_log.append(2)
|
||||
e2.set_behavior(int(mcrfpy.Behavior.SLEEP), turns=1)
|
||||
e2.step = lambda t, d: order_log.append(1)
|
||||
|
||||
grid.step()
|
||||
assert order_log == [1, 2], f"Expected [1, 2] turn order, got {order_log}"
|
||||
print("PASS: turn_order sorting")
|
||||
|
||||
def test_step_turn_order_zero_skip():
|
||||
"""turn_order=0 entities are skipped."""
|
||||
grid = make_grid()
|
||||
e = mcrfpy.Entity((10, 10), grid=grid)
|
||||
e.turn_order = 0
|
||||
e.set_behavior(int(mcrfpy.Behavior.NOISE4))
|
||||
e.move_speed = 0
|
||||
|
||||
grid.step()
|
||||
assert e.cell_x == 10 and e.cell_y == 10, "turn_order=0 entity should be skipped"
|
||||
print("PASS: turn_order=0 skipped")
|
||||
|
||||
def test_step_done_trigger():
|
||||
"""SLEEP behavior fires DONE trigger when turns exhausted."""
|
||||
grid = make_grid()
|
||||
triggered = []
|
||||
|
||||
e = mcrfpy.Entity((5, 5), grid=grid)
|
||||
e.set_behavior(int(mcrfpy.Behavior.SLEEP), turns=2)
|
||||
e.step = lambda t, d: triggered.append(int(t))
|
||||
|
||||
grid.step() # Sleep turns: 2 -> 1
|
||||
assert len(triggered) == 0, "Should not trigger DONE after first step"
|
||||
|
||||
grid.step() # Sleep turns: 1 -> 0 -> DONE
|
||||
assert len(triggered) == 1, f"Should trigger DONE, got {len(triggered)} triggers"
|
||||
assert triggered[0] == int(mcrfpy.Trigger.DONE), f"Should be DONE trigger, got {triggered[0]}"
|
||||
print("PASS: SLEEP DONE trigger")
|
||||
|
||||
def test_step_n_rounds():
|
||||
"""grid.step(n=3) executes 3 rounds."""
|
||||
grid = make_grid()
|
||||
e = mcrfpy.Entity((10, 10), grid=grid)
|
||||
e.set_behavior(int(mcrfpy.Behavior.NOISE4))
|
||||
e.move_speed = 0
|
||||
|
||||
grid.step(n=3)
|
||||
# After 3 steps of NOISE4, entity should have moved
|
||||
# Can't predict exact position due to randomness
|
||||
print("PASS: grid.step(n=3) executes without error")
|
||||
|
||||
def test_step_turn_order_filter():
|
||||
"""grid.step(turn_order=1) only processes entities with that turn_order."""
|
||||
grid = make_grid()
|
||||
e1 = mcrfpy.Entity((5, 5), grid=grid)
|
||||
e1.turn_order = 1
|
||||
e1.set_behavior(int(mcrfpy.Behavior.NOISE4))
|
||||
e1.move_speed = 0
|
||||
|
||||
e2 = mcrfpy.Entity((10, 10), grid=grid)
|
||||
e2.turn_order = 2
|
||||
e2.set_behavior(int(mcrfpy.Behavior.NOISE4))
|
||||
e2.move_speed = 0
|
||||
|
||||
grid.step(turn_order=1)
|
||||
# e1 should have moved, e2 should not
|
||||
assert not (e1.cell_x == 5 and e1.cell_y == 5), "turn_order=1 entity should have moved"
|
||||
assert e2.cell_x == 10 and e2.cell_y == 10, "turn_order=2 entity should not have moved"
|
||||
print("PASS: turn_order filter")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_step_basic()
|
||||
test_step_noise_movement()
|
||||
test_step_idle_no_move()
|
||||
test_step_turn_order()
|
||||
test_step_turn_order_zero_skip()
|
||||
test_step_done_trigger()
|
||||
test_step_n_rounds()
|
||||
test_step_turn_order_filter()
|
||||
print("All #301 tests passed")
|
||||
sys.exit(0)
|
||||
104
tests/unit/entity_behavior_test.py
Normal file
104
tests/unit/entity_behavior_test.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""Unit test for #300: EntityBehavior struct and behavior primitives."""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
def make_grid():
|
||||
"""Create a simple 20x20 walkable grid."""
|
||||
scene = mcrfpy.Scene("test300")
|
||||
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):
|
||||
grid.at(x, y).walkable = True
|
||||
grid.at(x, y).transparent = True
|
||||
return grid
|
||||
|
||||
def test_behavior_properties():
|
||||
"""Behavior-related properties exist and have correct defaults."""
|
||||
e = mcrfpy.Entity()
|
||||
assert e.behavior_type == 0, f"Default behavior_type should be 0 (IDLE), got {e.behavior_type}"
|
||||
assert e.turn_order == 1, f"Default turn_order should be 1, got {e.turn_order}"
|
||||
assert abs(e.move_speed - 0.15) < 0.01, f"Default move_speed should be 0.15, got {e.move_speed}"
|
||||
assert e.target_label is None, f"Default target_label should be None, got {e.target_label}"
|
||||
assert e.sight_radius == 10, f"Default sight_radius should be 10, got {e.sight_radius}"
|
||||
print("PASS: behavior property defaults")
|
||||
|
||||
def test_behavior_property_setters():
|
||||
"""Behavior properties can be set."""
|
||||
e = mcrfpy.Entity()
|
||||
e.turn_order = 5
|
||||
assert e.turn_order == 5
|
||||
|
||||
e.move_speed = 0.3
|
||||
assert abs(e.move_speed - 0.3) < 0.01
|
||||
|
||||
e.target_label = "player"
|
||||
assert e.target_label == "player"
|
||||
|
||||
e.target_label = None
|
||||
assert e.target_label is None
|
||||
|
||||
e.sight_radius = 15
|
||||
assert e.sight_radius == 15
|
||||
print("PASS: behavior property setters")
|
||||
|
||||
def test_set_behavior_noise():
|
||||
"""set_behavior with NOISE4 type."""
|
||||
e = mcrfpy.Entity()
|
||||
e.set_behavior(int(mcrfpy.Behavior.NOISE4))
|
||||
assert e.behavior_type == int(mcrfpy.Behavior.NOISE4)
|
||||
print("PASS: set_behavior NOISE4")
|
||||
|
||||
def test_set_behavior_path():
|
||||
"""set_behavior with PATH type and pre-computed path."""
|
||||
e = mcrfpy.Entity()
|
||||
path = [(1, 0), (2, 0), (3, 0)]
|
||||
e.set_behavior(int(mcrfpy.Behavior.PATH), path=path)
|
||||
assert e.behavior_type == int(mcrfpy.Behavior.PATH)
|
||||
print("PASS: set_behavior PATH")
|
||||
|
||||
def test_set_behavior_patrol():
|
||||
"""set_behavior with PATROL type and waypoints."""
|
||||
e = mcrfpy.Entity()
|
||||
waypoints = [(5, 5), (10, 5), (10, 10), (5, 10)]
|
||||
e.set_behavior(int(mcrfpy.Behavior.PATROL), waypoints=waypoints)
|
||||
assert e.behavior_type == int(mcrfpy.Behavior.PATROL)
|
||||
print("PASS: set_behavior PATROL")
|
||||
|
||||
def test_set_behavior_sleep():
|
||||
"""set_behavior with SLEEP type and turns."""
|
||||
e = mcrfpy.Entity()
|
||||
e.set_behavior(int(mcrfpy.Behavior.SLEEP), turns=5)
|
||||
assert e.behavior_type == int(mcrfpy.Behavior.SLEEP)
|
||||
print("PASS: set_behavior SLEEP")
|
||||
|
||||
def test_set_behavior_reset():
|
||||
"""set_behavior resets previous behavior state."""
|
||||
e = mcrfpy.Entity()
|
||||
e.set_behavior(int(mcrfpy.Behavior.PATROL), waypoints=[(1,1), (5,5)])
|
||||
assert e.behavior_type == int(mcrfpy.Behavior.PATROL)
|
||||
|
||||
e.set_behavior(int(mcrfpy.Behavior.IDLE))
|
||||
assert e.behavior_type == int(mcrfpy.Behavior.IDLE)
|
||||
print("PASS: set_behavior reset")
|
||||
|
||||
def test_turn_order_zero_skip():
|
||||
"""turn_order=0 should mean entity is skipped."""
|
||||
e = mcrfpy.Entity()
|
||||
e.turn_order = 0
|
||||
assert e.turn_order == 0
|
||||
print("PASS: turn_order=0")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_behavior_properties()
|
||||
test_behavior_property_setters()
|
||||
test_set_behavior_noise()
|
||||
test_set_behavior_path()
|
||||
test_set_behavior_patrol()
|
||||
test_set_behavior_sleep()
|
||||
test_set_behavior_reset()
|
||||
test_turn_order_zero_skip()
|
||||
print("All #300 tests passed")
|
||||
sys.exit(0)
|
||||
Loading…
Add table
Add a link
Reference in a new issue