Initial commit

This commit is contained in:
James Campbell
2026-07-21 19:36:02 -04:00
commit d84e4627c3
39 changed files with 4386 additions and 0 deletions

136
tests/conftest.py Normal file
View File

@@ -0,0 +1,136 @@
"""Shared pytest fixtures + helpers for the Dying Phone engine tests.
Everything runs headless via SDL's dummy drivers, so no window is needed.
"""
import os
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
import pygame
import pytest
from game import settings as S
from game.level import Level
from game.game import Game
from game.player import InputState
DT = 1 / 60.0
@pytest.fixture(scope="session", autouse=True)
def _pygame():
pygame.init()
pygame.display.set_mode((64, 64)) # a display so convert_alpha() works
yield
pygame.quit()
# --- level/game factories ----------------------------------------------------
@pytest.fixture
def make_level(tmp_path):
n = [0]
def _make(yaml_text):
n[0] += 1
path = tmp_path / f"level_{n[0]}.yaml"
path.write_text(yaml_text)
return Level(str(path))
return _make
@pytest.fixture
def make_game(tmp_path):
n = [0]
def _make(yaml_text, **kw):
n[0] += 1
path = tmp_path / f"game_{n[0]}.yaml"
path.write_text(yaml_text)
# nonexistent assets dir -> placeholder rectangles (deterministic)
return Game([str(path)], str(tmp_path / "no_assets"), **kw)
return _make
# --- run-loop helpers ---------------------------------------------------------
def step(game, inp=None):
"""Advance one frame, mirroring Game.run()'s per-state dispatch."""
inp = inp or InputState()
if game.state == "playing":
game._update_play(DT, inp)
elif game.state == "dying":
game.death_timer -= DT
if game.death_timer <= 0:
game._respawn()
elif game.state == "charging":
game.charge_timer += DT
if game.charge_timer >= S.CHARGE_TIME:
game.state = "won_all" if game._final else "won_level"
elif game.state == "fading":
game._update_fade(DT)
def run(game, frames, inp_fn=None):
for i in range(frames):
step(game, inp_fn(i) if inp_fn else None)
def place(game, col, row):
"""Put the player's feet at the bottom of cell (col,row), centred."""
t = game.level.tile
game.player.fx = float(col * t + (t - game.player.w) / 2)
game.player.fy = float(row * t + (t - game.player.h))
game.player.vx = game.player.vy = 0.0
game.player._sync_rect()
def hold(**flags):
inp = InputState()
for k, v in flags.items():
setattr(inp, k, v)
return inp
# --- fakes for isolated trap unit tests --------------------------------------
class FakeLevel:
tile = 32
debug = False
width = 800
height = 480
def cell_rect(self, c, r):
return pygame.Rect(c * 32, r * 32, 32, 32)
class FakePlayer:
def __init__(self, rect):
self.rect = rect
self.w = rect.w
self.h = rect.h
self.fx = float(rect.x)
self.fy = float(rect.y)
self.vx = self.vy = 0.0
def _sync_rect(self):
self.rect.x = round(self.fx)
self.rect.y = round(self.fy)
class FakeGame:
def __init__(self, rect):
self.player = FakePlayer(rect)
def reversals(values):
"""Count direction reversals in a numeric sequence (for jitter tests)."""
rev, prev = 0, 0
for i in range(1, len(values)):
d = (values[i] > values[i - 1]) - (values[i] < values[i - 1])
if d and prev and d != prev:
rev += 1
if d:
prev = d
return rev

176
tests/test_block.py Normal file
View File

@@ -0,0 +1,176 @@
"""The unified `block` trap: modes, deadly/fake/crumble, jitter, sensing."""
import pygame
from conftest import FakeGame, FakeLevel, reversals, step, run, place, hold
from game.traps import Block, expand_spec
def block(**spec):
spec.setdefault("type", "block")
spec.setdefault("at", [16, 6])
return Block(spec, FakeLevel())
def test_stationary_is_solid():
b = block(at=[3, 3])
assert b.solid_rects() == [b._rect()]
assert b.hazard_rects() == []
def test_deadly_is_hazard_not_solid():
b = block(at=[3, 3], deadly=True)
assert b.solid_rects() == []
assert b.hazard_rects() # non-empty
def test_fake_is_drawn_but_not_solid():
b = block(at=[3, 3], fake=True)
assert b.solid_rects() == [] # you fall through
assert b.sprite == "fake_block" # looks like a real block
def test_crumble_cycle_and_emerge_kill(make_game):
g = make_game("""
name: t
tile_size: 32
map: |
######
#.P..#
#...G#
######
traps:
- type: block
at: [2, 2]
crumble: true
crumble_delay: 0.3
respawn: 0.8
""")
cb = g.level.traps[0]
states, killed, d0 = set(), False, g.deaths
for _ in range(300):
step(g)
states.add(cb.cstate)
if g.deaths > d0:
killed = True
break
assert "gone" in states # it crumbled away
assert killed # re-formed onto the standing player
def test_moving_block_that_crumbles(make_game):
g = make_game("""
name: t
tile_size: 32
map: |
##########
#........#
#........#
#.......G#
##########
traps:
- type: block
at: [2, 2]
path: [[2, 2], [7, 2]]
mode: pingpong
speed: 90
crumble: true
crumble_delay: 0.3
respawn: 1.0
""")
b = g.level.traps[0]
place(g, 2, 1) # ride the platform
moved = crumbled = False
start = b.x
for _ in range(200):
step(g)
moved |= abs(b.x - start) > 20
crumbled |= b.cstate != "solid"
if g.state != "playing":
break
assert moved and crumbled
def test_patrol_loop_visits_all_waypoints():
b = block(at=[0, 0], path=[[0, 0], [4, 0], [4, 3]], mode="loop", speed=400)
g = FakeGame(pygame.Rect(0, 0, 4, 4))
seen = set()
for _ in range(600):
b.update(1 / 60, g)
seen.add((round(b.x / 32), round(b.y / 32)))
assert {(0, 0), (4, 0), (4, 3)} <= seen
def test_slider_once_no_jitter_and_retracts():
# sense=home (default): a slider moving away can't toggle its own trigger.
b = block(at=[16, 6], move=[-3, 0], speed=260, trigger={"within": 3})
g = FakeGame(pygame.Rect(17 * 32, 6 * 32, 23, 29)) # player parked to the right
xs = [(b.update(1 / 60, g), b.x)[1] for _ in range(120)]
assert reversals(xs) == 0 and abs(xs[-1] - 13 * 32) < 2 # committed to displaced
g.player.rect.x = 30 * 32 # leave
xs = [(b.update(1 / 60, g), b.x)[1] for _ in range(120)]
assert reversals(xs) == 0 and abs(xs[-1] - 16 * 32) < 2 # retracted to base
def test_sense_current_rides_down():
# A dropper (sense=current) commits to the bottom and holds while ridden.
b = block(at=[21, 9], move=[0, 4], speed=220, sense="current",
trigger={"all": [{"within": 5}, {"dir": "above", "aligned": True}]})
# player standing on top of it
p = pygame.Rect(21 * 32 + 4, 9 * 32 - 29, 23, 29)
g = FakeGame(p)
ys = []
for _ in range(90):
b.update(1 / 60, g)
# keep the player riding the block top
p.bottom = b._rect().top
ys.append(b.y)
assert reversals(ys) == 0 and abs(ys[-1] - 13 * 32) < 2 # dropped fully, no bob
def test_carriers_reports_motion():
b = block(at=[0, 5], path=[[0, 5], [5, 5]], mode="pingpong", speed=120)
g = FakeGame(pygame.Rect(0, 0, 4, 4))
b.update(1 / 60, g)
(rect, dx, dy), = b.carriers()
assert dx != 0 and dy == 0 # moving horizontally
# --- array expansion (count / spacing) --------------------------------------
def test_expand_no_count_yields_one():
specs = list(expand_spec({"type": "block", "at": [2, 3]}))
assert specs == [{"type": "block", "at": [2, 3]}]
def test_expand_line():
ats = [s["at"] for s in expand_spec({"type": "spike", "at": [1, 1], "count": 4})]
assert ats == [[1, 1], [2, 1], [3, 1], [4, 1]]
def test_expand_grid_with_spacing():
ats = [s["at"] for s in expand_spec(
{"type": "block", "at": [0, 0], "count": [3, 2], "spacing": [2, 3]})]
assert ats == [[0, 0], [2, 0], [4, 0], [0, 3], [2, 3], [4, 3]]
# count/spacing are stripped from each expanded spec
for s in expand_spec({"type": "block", "at": [0, 0], "count": [2, 1]}):
assert "count" not in s and "spacing" not in s
def test_array_expands_in_level(make_level):
lvl = make_level("""
name: t
tile_size: 32
map: |
########
#......#
#P....G#
########
traps:
- type: block
at: [2, 1]
deadly: true
count: [4, 1]
""")
blocks = [t for t in lvl.traps if isinstance(t, Block)]
assert len(blocks) == 4
assert all(b.deadly for b in blocks)
assert sorted(b.current_rect().x for b in blocks) == [2 * 32, 3 * 32, 4 * 32, 5 * 32]

176
tests/test_crush.py Normal file
View File

@@ -0,0 +1,176 @@
"""Crush death, shove/carry interactions, and the collision-resolution
regressions (corner warp, fits-under, cliff-shove)."""
from conftest import step, run, place, hold
def until_crushed(g, frames=200):
for _ in range(frames):
if g.state != "playing":
break
step(g)
if g.player.crushed:
return True
return False
def test_descend_crush(make_game):
g = make_game("""
name: t
tile_size: 32
map: |
#####
#...#
#P.G#
#####
traps:
- type: block
at: [1, 1]
move: [0, 2]
speed: 200
trigger: { within: 20 }
""")
# player stands on the floor under the descending block
assert until_crushed(g, 120)
def test_fits_under_no_false_crush(make_game):
# A block that stops with clearance must NOT crush a grounded player.
g = make_game("""
name: t
tile_size: 32
map: |
#####
#...#
#...#
#P.G#
#####
traps:
- type: block
at: [1, 1]
move: [0, 1]
speed: 160
trigger: { within: 20 }
""")
place(g, 1, 2)
for _ in range(120):
step(g)
assert not g.player.crushed
def test_shove_into_wall_crushes(make_game):
g = make_game("""
name: t
tile_size: 32
map: |
#######
#....G#
#.....#
#######
traps:
- type: block
at: [2, 2]
path: [[2, 2], [6, 2]]
mode: pingpong
speed: 200
""")
# player pinned against the right wall
place(g, 5, 2)
g.player.fx = float(6 * 32 - g.player.w)
g.player._sync_rect()
assert until_crushed(g)
def test_shove_along_not_into_floor(make_game):
# A block moving horizontally into a grounded player shoves them sideways,
# never buries them in the floor.
g = make_game("""
name: t
tile_size: 32
map: |
############
#..........#
#..........#
#.........G#
############
traps:
- type: block
at: [8, 3]
path: [[8, 3], [2, 3]]
mode: pingpong
speed: 160
""")
place(g, 4, 3) # on the floor, at the block's body level
floor_top = 4 * 32
start_x = g.player.rect.x
max_bottom = g.player.rect.bottom
for _ in range(120):
step(g)
max_bottom = max(max_bottom, g.player.rect.bottom)
if g.state != "playing":
break
assert max_bottom <= floor_top + 1 # never pushed into the floor
assert g.player.rect.x < start_x # got shoved along
def test_corner_clip_does_not_warp(make_game):
# Walking into a moving block near its corner must not fling the player
# across it (the old velocity-sign resolver bug).
g = make_game("""
name: t
tile_size: 32
map: |
#########
#.......#
#.......#
#.......#
#P.....G#
#########
traps:
- type: block
at: [5, 2]
path: [[5, 2], [2, 2]]
mode: pingpong
speed: 150
""")
place(g, 4, 3)
prev = g.player.rect.x
for _ in range(120):
step(g, hold(left=True))
assert g.player.rect.x - prev <= 28 # no sudden rightward warp
prev = g.player.rect.x
def test_cliff_shove_falls_on_first_pass(make_game):
# A block sweeping the player toward a ledge should push them off on the
# first pass (before it turns around).
g = make_game("""
name: t
tile_size: 32
map: |
###########
#.........#
#.........#
######....#
traps:
- type: block
at: [1, 2]
path: [[1, 2], [8, 2]]
mode: pingpong
speed: 150
""")
b = g.level.traps[0]
place(g, 4, 2)
reversalsN, pd, left_at = 0, 1, None
for i in range(240):
step(g)
if g.state != "playing":
break
d = 1 if b.x > b.prev[0] else (-1 if b.x < b.prev[0] else pd)
if d != pd:
reversalsN += 1
pd = d
if not g.player.on_ground and g.player.rect.bottom > 3 * 32:
left_at = reversalsN
break
assert left_at == 0 # fell before any block reversal

190
tests/test_flow.py Normal file
View File

@@ -0,0 +1,190 @@
"""Level loading, death/respawn, counters, level-clear flow, window sizing."""
import pygame
from conftest import step, run, place, hold, DT
from game import settings as S
from game.player import InputState
SIMPLE = """
name: My Level
tile_size: 32
battery_seconds: 30
map: |
#####
#P.G#
#####
"""
def test_level_parse(make_level):
lvl = make_level(SIMPLE)
assert lvl.name == "My Level"
assert lvl.width == 5 * 32 and lvl.height == 3 * 32
assert lvl.spawn == (1 * 32, 1 * 32)
assert lvl.goal_rect.topleft == (3 * 32, 1 * 32)
assert len(lvl.solids) == 5 + 5 + 2 # top row + bottom row + side walls
def test_unknown_trap_type_is_skipped(make_level):
lvl = make_level("""
name: t
tile_size: 32
map: |
###
#P#
###
traps:
- type: not_a_real_trap
at: [1, 1]
""")
assert lvl.traps == [] # skipped, no crash
def test_death_pause_then_respawn(make_game):
g = make_game(SIMPLE)
run(g, 5)
d0 = g.deaths
g._start_death()
assert g.state == "dying" and g.deaths == d0 + 1
frames = 0
while g.state == "dying" and frames < 100:
step(g)
frames += 1
assert g.state == "playing"
assert abs(frames * DT - S.DEATH_PAUSE) < DT * 2
def test_death_counters_per_level_and_total(make_game, tmp_path):
# two-level game to test per-level reset vs session total
a = tmp_path / "a.yaml"
b = tmp_path / "b.yaml"
a.write_text(SIMPLE)
b.write_text(SIMPLE.replace("My Level", "Two"))
from game.game import Game
g = Game([str(a), str(b)], str(tmp_path / "noassets"))
def die():
g._start_death()
while g.state == "dying":
step(g)
die(); die(); die()
assert g.level_deaths == 3 and g.deaths == 3
g._advance()
assert g.level_deaths == 0 and g.deaths == 3 # per-level resets, total persists
die()
assert g.level_deaths == 1 and g.deaths == 4
g._replay()
assert g.index == 0 and g.deaths == 0 # replay resets the total
def test_reach_goal_charges_then_wins(make_game):
g = make_game(SIMPLE)
g._reach_goal()
assert g.state == "charging"
assert 0 <= g.charge_from < 0.2 # near-empty start
for _ in range(70):
step(g)
assert g.state == "won_all" # single level -> won_all
def test_fade_swaps_level_at_black(make_game, tmp_path):
a = tmp_path / "a.yaml"
b = tmp_path / "b.yaml"
a.write_text(SIMPLE)
b.write_text(SIMPLE.replace("My Level", "Two"))
from game.game import Game
g = Game([str(a), str(b)], str(tmp_path / "noassets"))
g.state = "won_level"
g._start_fade(g._advance)
out = swapped = done = 0
for i in range(80):
prev = g.index
step(g)
if g.fade_phase == "out" and g.state == "fading":
out += 1
if g.index != prev:
swapped = i
if g.state == "playing":
done = i
break
assert swapped and done and g.index == 1
assert done - swapped >= 15 # fade-in ~0.3s not skipped
def test_window_sized_once_for_tallest_level(make_game, tmp_path, monkeypatch):
a = tmp_path / "a.yaml"
b = tmp_path / "b.yaml"
a.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n") # 3 rows
b.write_text("name: B\ntile_size: 32\nmap: |\n #####\n #...#\n #P.G#\n #####\n") # 4 rows
from game.game import Game
calls = []
orig = pygame.display.set_mode
monkeypatch.setattr(pygame.display, "set_mode",
lambda size, *a, **k: calls.append(size) or orig(size, *a, **k))
g = Game([str(a), str(b)], str(tmp_path / "noassets"))
g._advance(); g._replay()
assert len(calls) == 1 # never recreated the window
# sized to the tallest level (4 rows + HUD)
from game.game import HUD_H
assert g.win_h == 4 * 32 + HUD_H
def test_battery_visual_scales_by_pct(make_level):
lvl = make_level(SIMPLE + "battery_pct: 4\n")
assert lvl.battery_pct == 4
def test_cli_debug_forces_all_levels(make_game):
g = make_game(SIMPLE, debug=True)
assert g.level.debug is True
g2 = make_game(SIMPLE)
assert g2.level.debug is False
def test_f5_hot_reload_picks_up_edits_and_counts_death(tmp_path):
from game.game import Game
p = tmp_path / "hot.yaml"
p.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n")
g = Game([str(p)], str(tmp_path / "noassets"))
assert g.level.name == "A" and len(g.level.traps) == 0
d0 = g.deaths
# edit the file on disk, then F5
p.write_text("name: B\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n"
"traps:\n - type: block\n at: [2, 1]\n deadly: true\n")
g._reload_level()
assert g.state == "dying" and g.deaths == d0 + 1 # death beat + counter
while g.state == "dying":
step(g)
assert g.level.name == "B" and len(g.level.traps) == 1 # picked up the edit
assert g.level_deaths == 1 # counted, not reset to 0
def test_f5_bad_yaml_does_not_crash(tmp_path, capsys):
from game.game import Game
p = tmp_path / "bad.yaml"
p.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n")
g = Game([str(p)], str(tmp_path / "noassets"))
p.write_text("name: A\n bad: [unclosed\n") # invalid YAML
g._reload_level()
while g.state == "dying":
step(g)
assert g.state == "playing" # fell back, no crash
assert g.level.name == "A" # kept the old level
def test_debug_grid_adds_pixels(make_level):
import pygame
from game.assets import AssetStore
lvl = make_level("name: t\ntile_size: 32\nmap: |\n #####\n #...#\n #...#\n #####\n")
a = AssetStore("noassets")
def painted(dbg):
lvl.debug = dbg
w = pygame.Surface((lvl.width, lvl.height))
w.fill((0, 0, 0))
lvl.draw(w, a)
return sum(1 for y in range(lvl.height) for x in range(lvl.width)
if w.get_at((x, y))[:3] != (0, 0, 0))
assert painted(True) > painted(False) # grid + labels add ink

153
tests/test_mounting.py Normal file
View File

@@ -0,0 +1,153 @@
"""Mounting traps on other traps."""
from conftest import step, DT
from game.traps import Block, Spike
PLATFORM_WITH_SPIKE = """
name: t
tile_size: 32
map: |
############
#..........#
#..........#
#.........G#
#P.........#
############
traps:
- type: block
at: [3, 2]
path: [[3, 2], [8, 2]]
mode: pingpong
speed: 120
sprite: patrol_block
mounts:
- type: spike
at: [0, -1]
trigger: always
direction: up
"""
def test_spike_mount_follows_platform(make_game):
g = make_game(PLATFORM_WITH_SPIKE)
plat = g.level.traps[0]
sp = plat.mounts[0]
assert isinstance(sp, Spike)
for _ in range(30):
g.level.update(DT, g)
p = plat.current_rect()
hz = sp.hazard_rects()[0]
# spike sits one tile above the platform and moves with it
assert hz.centerx == p.centerx
assert hz.bottom <= p.top + 1
def test_block_mounted_on_block(make_game):
g = make_game("""
name: t
tile_size: 32
map: |
############
#..........#
#..........#
#.........G#
#P.........#
############
traps:
- type: block
at: [3, 2]
path: [[3, 2], [8, 2]]
mode: pingpong
speed: 120
sprite: patrol_block
mounts:
- type: block
at: [-1, 0]
deadly: true
- type: block
at: [1, 0]
deadly: true
""")
plat = g.level.traps[0]
g.level.update(DT, g)
for m in plat.mounts:
assert m._mounted and m.deadly
# each deadly mount tracks the platform at its offset and is lethal
exp = (plat.current_rect().x + m._mount_off[0],
plat.current_rect().y + m._mount_off[1])
assert (m.current_rect().x, m.current_rect().y) == exp
assert m.hazard_rects()
MOBILE_MOUNT = """
name: t
tile_size: 32
map: |
################
#..............#
#..............#
#..............#
#..............#
#P............G#
################
traps:
- type: block
at: [3, 4]
path: [[3, 4], [8, 4]]
mode: pingpong
speed: 120
sprite: patrol_block
mounts:
# Rides the patrol AND runs its own upward stroke when armed.
- type: block
at: [0, -1]
move: [0, -3]
speed: 240
mode: once
trigger: { within: 40 } # player is always in range -> stays extended
mounts:
- type: spike
at: [0, -1]
trigger: always
direction: up
"""
def test_block_mount_runs_own_motion_while_riding(make_game):
g = make_game(MOBILE_MOUNT)
plat = g.level.traps[0]
mnt = plat.mounts[0]
assert isinstance(mnt, Block) and mnt._mounted
tile = g.level.tile
# At rest the mount sits one tile above the patrol, tracking its column.
assert mnt.current_rect().x == plat.current_rect().x
assert mnt.current_rect().top == plat.current_rect().top - tile
for _ in range(120):
g.level.update(DT, g)
pr, mr = plat.current_rect(), mnt.current_rect()
# Still rides along horizontally (same column offset as home)...
assert mr.x == pr.x
# ...but has run its own three-tile upward stroke above the resting spot
# (resting = 1 tile up; extended = 1 + 3 tiles up).
assert mr.top == pr.top - 4 * tile
# It reports as a solid carrier so a rider would be carried.
assert mnt.solid_rects() and mnt.carriers()
# The spike rides the lunging block, one tile above it.
spike = mnt.mounts[0]
hz = spike.hazard_rects()[0]
assert hz.centerx == mr.centerx and hz.bottom <= mr.top + 1
def test_reset_repositions_mounts(make_game):
g = make_game(PLATFORM_WITH_SPIKE)
plat = g.level.traps[0]
sp = plat.mounts[0]
for _ in range(40):
g.level.update(DT, g)
g.level.reset()
# after reset the platform is home and the mount snapped back onto it
assert sp.base_rect.centerx == plat.current_rect().centerx

151
tests/test_physics.py Normal file
View File

@@ -0,0 +1,151 @@
"""Player movement & collision."""
from conftest import step, run, place, hold
from game import settings as S
FLAT = """
name: t
tile_size: 32
map: |
##########
#........#
#........#
#........#
#P......G#
##########
"""
def test_falls_and_lands_on_floor(make_game):
g = make_game(FLAT)
place(g, 4, 1) # up in the air
run(g, 60)
assert g.player.on_ground
assert g.player.rect.bottom == 5 * 32 # floor is row 5's top (y=160)
def test_terminal_velocity(make_game):
g = make_game(FLAT)
place(g, 4, 1)
for _ in range(200):
step(g)
assert g.player.vy <= S.MAX_FALL + 1
def test_jump_gains_height_then_returns(make_game):
g = make_game(FLAT)
run(g, 30) # settle on floor
ground = g.player.rect.bottom
peak = ground
for i in range(60):
step(g, hold(jump_pressed=(i == 0), jump_held=True))
peak = min(peak, g.player.rect.bottom)
assert peak < ground - 32 # rose at least a tile
def test_variable_jump_height(make_game):
# Full-hold jump should out-climb a 1-frame tap.
def peak(hold_frames):
g = make_game(FLAT)
run(g, 30)
ground = g.player.rect.bottom
hi = ground
for i in range(60):
held = i < hold_frames
step(g, hold(jump_pressed=(i == 0), jump_held=held))
hi = min(hi, g.player.rect.bottom)
return ground - hi
assert peak(60) > peak(1) + 8
def test_coyote_time_allows_jump_after_leaving_ledge(make_game):
# Walk off a ledge, then jump within the coyote window -> should rise.
g = make_game("""
name: t
tile_size: 32
map: |
#######
#.....#
#.....#
##....#
#P#...#
#######
""")
place(g, 1, 3) # standing on the little step at [1,3] top
# walk right off the step for a couple frames, then jump
run(g, 4, lambda i: hold(right=True))
y_before = g.player.rect.bottom
step(g, hold(right=True, jump_pressed=True, jump_held=True))
step(g, hold(right=True, jump_held=True))
assert g.player.vy < 0 # a jump actually started
def test_walls_stop_horizontal_movement(make_game):
g = make_game(FLAT)
place(g, 1, 4)
run(g, 120, lambda i: hold(right=True))
assert g.player.rect.right <= 9 * 32 # right wall inner edge (x=288)
run(g, 120, lambda i: hold(left=True))
assert g.player.rect.left >= 1 * 32 # left wall inner edge (x=32)
def test_oneway_platform_land_from_above_pass_from_below(make_game):
g = make_game("""
name: t
tile_size: 32
map: |
#######
#.....#
#.....#
#--...#
#.....#
#P...G#
#######
""")
# Fall onto the one-way from above -> lands on it.
place(g, 1, 1) # open air above the one-way (row 3)
run(g, 60)
assert g.player.on_ground and g.player.rect.bottom == 3 * 32
# From below, jumping up passes through it (doesn't block the head).
g2 = make_game("""
name: t
tile_size: 32
map: |
#######
#.....#
#--...#
#.....#
#P...G#
#######
""")
place(g2, 1, 4) # on the floor, below the one-way (row 2)
run(g2, 5) # settle so on_ground is set before jumping
passed = False
for i in range(40):
step(g2, hold(jump_pressed=(i == 0), jump_held=(i < 12)))
if g2.player.rect.top < 2 * 32: # rose above the one-way row
passed = True
assert passed
def test_drop_through_oneway(make_game):
g = make_game("""
name: t
tile_size: 32
map: |
#######
#.....#
#.....#
#--...#
#.....#
#....G#
#######
""")
place(g, 1, 2) # standing on the one-way at row 3
run(g, 20)
assert g.player.on_ground
# press down + jump to drop through
for i in range(30):
step(g, hold(down=True, jump_pressed=(i == 0)))
assert g.player.rect.top > 3 * 32 # fell below the one-way

339
tests/test_traps.py Normal file
View File

@@ -0,0 +1,339 @@
"""Spike, arrow_shooter, warp, phase_block + the invisible flag & block arrays."""
import hashlib
import pygame
from conftest import (FakeGame, step, run, place, hold, DT)
from game.assets import AssetStore
from game.traps import Spike, ArrowShooter, Warp, PhaseBlock, Block
# --- spike -------------------------------------------------------------------
def test_spike_active_by_trigger(make_level):
lvl = make_level("""
name: t
tile_size: 32
map: |
#####
#P.G#
#####
traps:
- type: spike
at: [1, 1]
trigger: always
direction: up
""")
sp = lvl.traps[0]
sp.update(DT, FakeGame(pygame.Rect(0, 0, 4, 4)))
assert sp.active and sp.hazard_rects()
def test_spike_hazard_is_half_tile_on_the_direction_edge(make_level):
lvl = make_level("""
name: t
tile_size: 32
map: |
#####
#P.G#
#####
traps:
- type: spike
at: [1, 1]
trigger: always
direction: down
""")
sp = lvl.traps[0]
sp.update(DT, FakeGame(pygame.Rect(0, 0, 4, 4)))
hz = sp.hazard_rects()[0]
# down spike occupies the TOP half of its cell
assert hz.height == 16 and hz.top == sp.base_rect.top
def test_spike_sprite_rotates_per_direction(tmp_path):
# A deliberately asymmetric sprite so each rotation is distinct.
surf = pygame.Surface((8, 8), pygame.SRCALPHA)
surf.fill((255, 0, 0, 255), (0, 0, 8, 2)) # red bar along the top only
adir = tmp_path / "assets"
adir.mkdir()
pygame.image.save(surf, str(adir / "spike.png"))
a = AssetStore(str(adir))
up = a.get("spike", 32, 16, 0)
down = a.get("spike", 32, 16, 180)
left = a.get("spike", 16, 32, 90)
def h(s):
return hashlib.md5(pygame.image.tostring(s, "RGBA")).hexdigest()
assert h(up) != h(down) # rotation actually happened
assert left.get_size() == (16, 32) and up.get_size() == (32, 16)
# --- arrow shooter -----------------------------------------------------------
def test_arrow_shooter_fires_on_interval(make_level):
lvl = make_level("""
name: t
tile_size: 32
map: |
##########
#.......G#
#P.......#
##########
traps:
- type: arrow_shooter
at: [7, 1]
direction: left
speed: 200
interval: 0.5
""")
sh = lvl.traps[0]
g = FakeGame(pygame.Rect(0, 0, 4, 4))
for _ in range(40): # ~0.66s -> at least one shot
sh.update(DT, g)
assert sh.arrows # spawned arrows
assert sh.hazard_rects() # arrows are hazards
def test_arrow_shooter_trigger_gates_firing(make_level):
lvl = make_level("""
name: t
tile_size: 32
map: |
##########
#.......G#
#P.......#
##########
traps:
- type: arrow_shooter
at: [7, 1]
direction: left
interval: 0.2
trigger: { within: 1 }
""")
sh = lvl.traps[0]
far = FakeGame(pygame.Rect(0, 0, 4, 4)) # nowhere near
for _ in range(60):
sh.update(DT, far)
assert not sh.arrows # never fired while player out of range
# --- invisible wall, now an array of invisible blocks ------------------------
def test_invisible_block_array_is_solid_and_invisible(make_level):
lvl = make_level("""
name: t
tile_size: 32
map: |
######
#....#
#....#
#P..G#
######
traps:
- type: block
at: [3, 1]
invisible: true
count: [1, 2]
""")
blocks = [t for t in lvl.traps if isinstance(t, Block)]
assert len(blocks) == 2 # one per cell
assert all(b.invisible and b.solid_rects() for b in blocks)
ys = sorted(b.current_rect().top for b in blocks)
assert ys == [1 * 32, 2 * 32] # stacked vertically
# --- warp --------------------------------------------------------------------
def test_warp_teleports_and_rearms(make_game):
g = make_game("""
name: t
tile_size: 32
map: |
##########
#.......G#
#P.......#
##########
traps:
- type: warp
at: [4, 2]
to: [7, 1]
""")
place(g, 2, 2)
warped = False
for _ in range(120):
step(g, hold(right=True))
if g.player.rect.x >= 7 * 32 - 4:
warped = True
break
assert warped
# --- phase block -------------------------------------------------------------
def test_phase_block_intangible_until_triggered(make_game):
g = make_game("""
name: t
tile_size: 32
map: |
##########
#........#
#........#
#P......G#
##########
traps:
- type: phase_block
at: [4, 3]
fade: 0.2
trigger: { within: 2 }
""")
pb = g.level.traps[0]
place(g, 1, 3)
step(g)
assert not pb.solid and pb.alpha == 0 # dormant far away
run(g, 40, lambda i: hold(right=True))
assert pb.solid and pb.alpha > 0 # phased in solid on approach
def test_phase_block_kills_if_inside_when_it_forms(make_game):
g = make_game("""
name: t
tile_size: 32
map: |
########
#......#
#.....G#
########
traps:
- type: phase_block
at: [3, 2]
fade: 0.2
trigger: { within: 3 }
""")
pb = g.level.traps[0]
place(g, 3, 2) # standing right where it will form
killed, d0 = False, g.deaths
for _ in range(10):
step(g)
if g.deaths > d0:
killed = True
break
assert killed
def test_phase_block_nudges_player_clipping_edge(make_game):
# Only clipping the edge of a forming phase block -> shoved clear, not killed.
g = make_game("""
name: t
tile_size: 32
map: |
#########
#.......#
#......G#
#########
traps:
- type: phase_block
at: [4, 1]
fade: 0.2
trigger: { within: 5 }
""")
pb = g.level.traps[0]
b = pb.base_rect
# Straddle the block's left edge: mostly outside, just clipping into it.
g.player.fx = float(b.left - g.player.w + 5) # 5px of overlap
g.player.fy = float(b.top + 2)
g.player.vx = g.player.vy = 0.0
g.player._sync_rect()
d0 = g.deaths
step(g)
# survived, and pushed out to the left so it no longer overlaps the cell
assert g.deaths == d0
assert g.player.rect.right <= b.left
assert pb.solid and not pb.emerge_kill
def test_phase_block_kills_when_shove_would_squish(make_game):
# Clipping the edge but backed by a wall on the escape side -> lethal.
g = make_game("""
name: t
tile_size: 32
map: |
########
#......#
#.....G#
########
traps:
- type: phase_block
at: [1, 1]
fade: 0.2
trigger: { within: 5 }
""")
pb = g.level.traps[0]
b = pb.base_rect
# Clip the block's left edge, but the level wall (col 0) is right there, so
# the leftward shove has nowhere to go — squished.
g.player.fx = float(b.left - g.player.w + 5)
g.player.fy = float(b.top + 2)
g.player.vx = g.player.vy = 0.0
g.player._sync_rect()
killed, d0 = False, g.deaths
for _ in range(6):
step(g)
if g.deaths > d0:
killed = True
break
assert killed
def test_phase_block_snaps_visible_on_death(make_game):
# Dying while a phase block is forming should snap it fully visible for the
# frozen death tableau.
g = make_game("""
name: t
tile_size: 32
map: |
########
#......#
#.....G#
########
traps:
- type: phase_block
at: [3, 2]
fade: 0.5
trigger: { within: 3 }
""")
pb = g.level.traps[0]
place(g, 5, 2) # near enough to trigger, not on the cell
for _ in range(6): # let it partially fade in
step(g)
assert 0 < pb.alpha < 1
g._start_death() # die from something
assert pb.alpha == 1.0 and pb.solid # snapped fully visible for the freeze
# --- generic invisible flag --------------------------------------------------
def test_invisible_flag_on_any_trap(make_level):
lvl = make_level("""
name: t
tile_size: 32
map: |
#####
#...#
#P.G#
#####
traps:
- type: spike
at: [2, 1]
trigger: always
direction: up
invisible: true
""")
a = AssetStore("no_assets_dir")
sp = lvl.traps[0]
g = FakeGame(pygame.Rect(999, 999, 4, 4))
def painted(debug):
lvl.debug = debug
sp.tick(DT, g)
w = pygame.Surface((lvl.width, lvl.height))
w.fill((0, 0, 0))
sp.render(w, a)
return any(w.get_at((x, y))[:3] != (0, 0, 0)
for y in range(32, 96) for x in range(32, 96))
assert painted(False) is False # invisible in play
assert painted(True) is True # revealed in debug
assert sp.hazard_rects() # still deadly either way

91
tests/test_triggers.py Normal file
View File

@@ -0,0 +1,91 @@
"""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_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