"""Trigger conditions and the arm-delay hysteresis.""" import pygame from conftest import FakeGame, FakeLevel from game.traps import make_condition def ev(spec, px, py, w=20, h=28, dt=0.0): trap = type( "T", (), {"tile": 32, "sensor_rect": lambda self: pygame.Rect(100, 100, 32, 32)} )() return make_condition(spec).evaluate(trap, FakeGame(pygame.Rect(px, py, w, h)), dt) # trap centre = (116, 116); left100 right132 top100 bottom132 def test_always(): assert ev("always", 999, 999) is True def test_within_radius(): assert ev({"within": 2}, 106, 104) is True # ~10px away assert ev({"within": 2}, 400, 116) is False # far def test_dir_left_right(): assert ev({"dir": "left"}, 40, 105) is True # to the left assert ev({"dir": "left"}, 200, 105) is False # to the right assert ev({"dir": "right"}, 200, 105) is True def test_dir_range(): assert ev({"dir": "left", "range": 2}, 60, 105) is True # within 2 tiles left assert ev({"dir": "left", "range": 2}, 10, 105) is False # too far left def test_dir_aligned(): # above + aligned requires horizontal overlap with the trap column assert ev({"dir": "above", "aligned": True}, 108, 60) is True assert ev({"dir": "above", "aligned": True}, 108, 110) is False # not above assert ev({"dir": "above", "aligned": True}, 400, 60) is False # not aligned # without aligned, any column counts assert ev({"dir": "above"}, 400, 60) is True def test_dir_inclusive(): # trap column spans x 100..132 (centre 116). A player standing *inside* the # column (centre 116) is neither strictly left nor strictly right of it. assert ev({"dir": "left"}, 106, 105) is False # centre 116, inside -> not left assert ev({"dir": "right"}, 106, 105) is False # inside -> not right # inclusive counts the trap's own tile as being on that side assert ev({"dir": "left", "inclusive": True}, 106, 105) is True assert ev({"dir": "right", "inclusive": True}, 106, 105) is True # and vertically, too (column spans y 100..132) assert ev({"dir": "above"}, 108, 106) is False assert ev({"dir": "above", "inclusive": True}, 108, 106) is True assert ev({"dir": "below"}, 108, 106) is False assert ev({"dir": "below", "inclusive": True}, 108, 106) is True def test_all_and_any(): c = {"all": [{"within": 3}, {"dir": "above", "aligned": True}]} assert ev(c, 108, 80) is True assert ev(c, 108, 110) is False # close but not above c2 = {"any": [{"dir": "left"}, {"dir": "right"}]} assert ev(c2, 40, 105) is True assert ev(c2, 110, 40) is False # above-only satisfies neither def test_timer_cycles(): c = make_condition({"timer": {"interval": 0.3, "up_time": 0.2}}) trap = type( "T", (), {"tile": 32, "sensor_rect": lambda self: pygame.Rect(0, 0, 32, 32)} )() g = FakeGame(pygame.Rect(0, 0, 4, 4)) states = [c.evaluate(trap, g, 1 / 60) for _ in range(30)] assert states[0] is False # starts in the "off" interval assert any(states) and not all(states) # cycles on and off def test_bad_condition_raises(): import pytest with pytest.raises(ValueError): make_condition({"nope": 1}) def test_delay_hysteresis(make_level): # A spike within-1.6 with delay 0.25 arms only after staying in range. lvl = make_level(""" name: t tile_size: 32 map: | ##### #P.G# ##### traps: - type: spike at: [1, 1] trigger: { within: 5 } delay: 0.25 """) sp = lvl.traps[0] g = FakeGame(sp.base_rect.copy()) # player right on it -> in range armed = None for i in range(30): if sp.triggered(g, 1 / 60): armed = i break assert armed is not None and 13 <= armed <= 17 # ~0.25s = 15 frames # --- per-target triggers/delays (a block can move AND phase) ----------------- def _block(make_level, body): lvl = make_level( "name: t\ntile_size: 32\nmap: |\n ######\n #P..G#\n ######\ntraps:\n" + body ) return lvl.traps[0] def _cond(block, target): return type(block._trig[target]["cond"]).__name__ def test_trigger_single_condition_applies_to_all_targets(make_level): # A bare condition (not a target map) drives both motion and phase. b = _block( make_level, " - type: block\n at: [2, 1]\n trigger: { within: 2 }\n" ) assert _cond(b, "motion") == "_Within" assert _cond(b, "phase") == "_Within" def test_trigger_all_any_is_a_single_condition(make_level): # `all`/`any` are condition keywords, not target names -> one condition. b = _block( make_level, " - type: block\n at: [2, 1]\n" " trigger: { any: [ { within: 2 }, { dir: left } ] }\n", ) assert _cond(b, "motion") == "_Any" and _cond(b, "phase") == "_Any" def test_trigger_per_target_map_with_default(make_level): # `default` covers unnamed targets; a named target overrides it. b = _block( make_level, " - type: block\n at: [2, 1]\n phase: true\n" " trigger:\n default: always\n motion: { within: 5 }\n", ) assert _cond(b, "motion") == "_Within" # named assert _cond(b, "phase") == "_Always" # from default def test_trigger_unnamed_target_off_without_default(make_level): # A map naming only motion (no default) leaves phase with no trigger -> off. b = _block( make_level, " - type: block\n at: [2, 1]\n phase: true\n" " trigger: { motion: always }\n", ) assert "phase" in b._trig_never assert _cond(b, "phase") == "_Never" and _cond(b, "motion") == "_Always" def test_trigger_unknown_target_raises(make_level): import pytest with pytest.raises(ValueError): _block( make_level, " - type: block\n at: [2, 1]\n" " trigger: { motion: always, bogus: always }\n", ) def test_delay_scalar_applies_to_all_targets(make_level): b = _block(make_level, " - type: block\n at: [2, 1]\n delay: 0.2\n") assert b._trig["motion"]["delay"] == 0.2 and b._trig["phase"]["delay"] == 0.2 def test_delay_per_target_map(make_level): b = _block( make_level, " - type: block\n at: [2, 1]\n delay: { motion: 0.1, phase: 0.3 }\n", ) assert b._trig["motion"]["delay"] == 0.1 and b._trig["phase"]["delay"] == 0.3 # unnamed-without-default delay falls back to 0 (no arm delay) b2 = _block( make_level, " - type: block\n at: [2, 1]\n delay: { motion: 0.1 }\n" ) assert b2._trig["phase"]["delay"] == 0.0 def test_motion_and_phase_independent_triggers(make_level): # The user's case: a phase block that's *always* moving but only materialises # when the player is near. Motion and phase read their own triggers. lvl = make_level(""" name: t tile_size: 32 map: | ############ #..........# #P........G# ############ traps: - type: block at: [4, 1] phase: true move: [3, 0] speed: 200 fade: 0.2 trigger: motion: always phase: { within: 2 } """) b = lvl.traps[0] b.reset() far = FakeGame(pygame.Rect(1 * 32, 1 * 32, 20, 28)) # player at the far end x0 = b._rect().x for _ in range(20): b.tick(1 / 60, far) assert b._rect().x != x0 # moved (motion: always) ... assert b.alpha == 0.0 # ... but hasn't materialised (player far) near = FakeGame(pygame.Rect(b._rect().x + 2, 1 * 32, 20, 28)) for _ in range(10): b.tick(1 / 60, near) assert b.alpha > 0.0 # phases in once the player is close