Skip to content

Commit 43ad035

Browse files
committed
test: juice checks; deterministic save-free boot, robust presses/teleports
1 parent 6e1c3af commit 43ad035

2 files changed

Lines changed: 284 additions & 25 deletions

File tree

tests/test_juice.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"""Tier 1 combat-juice tests: hit flash, sinking animation, splash, muzzle
2+
smoke, screen shake + damage flash, and the harbor bell / port music.
3+
4+
J1 enemy blinks when hit (wEnemyFlash) and slides under when sunk (wSinkT)
5+
J2 firing the cannon puffs smoke; a dying ball splashes
6+
J3 taking a hit shakes the screen and flashes the palette
7+
J4 docking rings the bell and plays the port music (was dead content)
8+
"""
9+
import sys
10+
from pathlib import Path
11+
12+
sys.path.insert(0, str(Path(__file__).resolve().parent))
13+
from test_regress import (boot, new_game, w16, set16, seed16, syms, tile,
14+
press3, teleport)
15+
16+
teleport_verified = lambda pb, mem, tx, ty: teleport(pb, tx, ty)
17+
18+
19+
def open_sea_enemy_spot(mem, s16):
20+
"""A water pixel 24 px from the ship (enemy holds at cheb <= 40)."""
21+
sx, sy = w16(mem, "wShipX"), w16(mem, "wShipY")
22+
for dx, dy in ((24, 0), (-24, 0), (0, 24), (0, -24), (24, 24), (-24, -24)):
23+
ex, ey = sx + dx, sy + dy
24+
if tile(ex >> 3, ey >> 3, s16) < 3:
25+
return ex, ey
26+
raise AssertionError("no open-sea enemy spot near spawn")
27+
28+
29+
def place_enemy(mem, ex, ey, hp):
30+
set16(mem, "wEnemyX", ex << 4)
31+
set16(mem, "wEnemyY", ey << 4)
32+
mem[syms["wEnemyHP"]] = hp
33+
mem[syms["wEnemyFireCool"]] = 75
34+
mem[syms["wIsGuardian"]] = 0
35+
mem[syms["wLosT"]] = 16
36+
mem[syms["wNoLOS"]] = 0
37+
mem[syms["wEnemyActive"]] = 1
38+
39+
40+
def place_ball_on_enemy(mem):
41+
mem[syms["wBallPX"]] = mem[syms["wBallPX"]] # keep linters honest
42+
for dst, src in (("wBallPX", "wEnemyX"), ("wBallPY", "wEnemyY")):
43+
mem[syms[dst]] = mem[syms[src]]
44+
mem[syms[dst] + 1] = mem[syms[src] + 1]
45+
mem[syms["wBallPVX"]] = 0
46+
mem[syms["wBallPVY"]] = 0
47+
mem[syms["wBallPLife"]] = 30
48+
mem[syms["wBallPActive"]] = 1
49+
50+
51+
def j1_hit_flash_and_sink():
52+
pb = boot()
53+
mem = pb.memory
54+
new_game(pb)
55+
s16 = seed16(mem)
56+
ex, ey = open_sea_enemy_spot(mem, s16)
57+
place_enemy(mem, ex, ey, 3)
58+
place_ball_on_enemy(mem)
59+
pb.tick()
60+
assert mem[syms["wEnemyHP"]] == 2, "ball didn't damage the enemy"
61+
assert mem[syms["wEnemyFlash"]] > 0, "no hit flash on a damaged enemy"
62+
# now sink her
63+
mem[syms["wEnemyHP"]] = 1
64+
place_ball_on_enemy(mem)
65+
pb.tick()
66+
assert not mem[syms["wEnemyActive"]], "enemy survived 0 HP"
67+
assert mem[syms["wSinkT"]] > 0, "sinking animation never started"
68+
for _ in range(30):
69+
pb.tick()
70+
assert mem[syms["wSinkT"]] == 0, "sinking animation never finished"
71+
pb.stop()
72+
print("J1 enemy hit flash + sinking animation: OK")
73+
74+
75+
def j2_smoke_and_splash():
76+
pb = boot()
77+
mem = pb.memory
78+
new_game(pb)
79+
pb.button_press("a") # fire ahead (open ocean: no dock)
80+
pb.tick()
81+
pb.button_release("a")
82+
pb.tick()
83+
assert mem[syms["wBallPActive"]] == 1, "cannon didn't fire"
84+
assert mem[syms["wSmokeT"]] > 0, "no muzzle smoke on firing"
85+
# let the ball die in place two frames from now
86+
mem[syms["wBallPVX"]] = 0
87+
mem[syms["wBallPVY"]] = 0
88+
mem[syms["wBallPLife"]] = 2
89+
for _ in range(4):
90+
pb.tick()
91+
assert not mem[syms["wBallPActive"]], "ball never died"
92+
assert mem[syms["wSplashT"]] > 0, "no splash where the ball fell"
93+
pb.stop()
94+
print("J2 muzzle smoke + ball splash: OK")
95+
96+
97+
def j3_shake_and_flash_on_hit():
98+
pb = boot()
99+
mem = pb.memory
100+
new_game(pb)
101+
hull0 = mem[syms["wHull"]]
102+
# enemy ball parked on the ship
103+
mem[syms["wBallEX"]] = mem[syms["wPosX"]]
104+
mem[syms["wBallEX"] + 1] = mem[syms["wPosX"] + 1]
105+
mem[syms["wBallEY"]] = mem[syms["wPosY"]]
106+
mem[syms["wBallEY"] + 1] = mem[syms["wPosY"] + 1]
107+
mem[syms["wBallEVX"]] = 0
108+
mem[syms["wBallEVY"]] = 0
109+
mem[syms["wBallELife"]] = 30
110+
mem[syms["wDmgCool"]] = 0
111+
mem[syms["wBallEActive"]] = 1
112+
pb.tick()
113+
assert mem[syms["wHull"]] == hull0 - 1, "enemy ball didn't hit"
114+
assert mem[syms["wShakeT"]] > 0, "no screen shake on hull damage"
115+
assert mem[syms["wHitFlashT"]] > 0, "no palette flash on hull damage"
116+
pb.stop()
117+
print("J3 screen shake + damage flash: OK")
118+
119+
120+
def j4_harbor_bell_and_port_music():
121+
pb = boot()
122+
mem = pb.memory
123+
new_game(pb)
124+
s16 = seed16(mem)
125+
# find any port district with a dockable beach (the boot save's seed
126+
# varies, so don't hardcode one). A beach counts only if some water
127+
# tile's first land neighbor in TryDock's N,S,W,E order IS that beach
128+
# and the beach's district is this one.
129+
from test_regress import has_port
130+
131+
def dock_from(dx, dy):
132+
for ty in range(dy * 4, dy * 4 + 4):
133+
for tx in range(dx * 4, dx * 4 + 4):
134+
if tile(tx, ty, s16) < 3:
135+
continue
136+
for ddx, ddy in ((0, 1), (0, -1), (1, 0), (-1, 0)):
137+
nx, ny = tx + ddx, ty + ddy
138+
if not (0 <= nx < 320 and 0 <= ny < 288):
139+
continue
140+
if tile(nx, ny, s16) >= 3:
141+
continue
142+
for pdx, pdy in ((0, -1), (0, 1), (-1, 0), (1, 0)):
143+
if tile(nx + pdx, ny + pdy, s16) >= 3:
144+
if (nx + pdx, ny + pdy) == (tx, ty):
145+
return (nx, ny)
146+
break
147+
return None
148+
149+
target = None
150+
for dy in range(72):
151+
for dx in range(80):
152+
if has_port(dx, dy, s16):
153+
spot = dock_from(dx, dy)
154+
if spot:
155+
target = spot
156+
break
157+
if target:
158+
break
159+
assert target, "no port district with a dockable beach in this sea"
160+
docked = False
161+
if teleport_verified(pb, mem, *target):
162+
set16(mem, "wStormT", 0) # no storm may drift us off the beach
163+
mem[syms["wEnemyActive"]] = 0
164+
pb.tick()
165+
press3(pb, "a") # dock, catching the bell mid-ring
166+
pb.tick()
167+
pb.tick()
168+
docked = mem[syms["wState"]] == 4
169+
assert docked, f"docking failed from {target}"
170+
assert mem[syms["wSfx1T"]] > 0, "harbor bell not ringing on dock"
171+
assert mem[syms["wSongID"]] == 3, \
172+
f"port music not playing (song {mem[syms['wSongID']]}, want 3)"
173+
pb.stop()
174+
print("J4 harbor bell + port music: OK")
175+
176+
177+
if __name__ == "__main__":
178+
for fn in (j1_hit_flash_and_sink, j2_smoke_and_splash,
179+
j3_shake_and_flash_on_hit, j4_harbor_bell_and_port_music):
180+
fn()
181+
print("ALL JUICE CHECKS PASSED")

tests/test_regress.py

Lines changed: 103 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,14 @@ def snap_dir(dx, dy):
113113
# ---------------------------------------------------------------- helpers
114114

115115
def boot(garbage=False):
116-
pb = PyBoy(ROM, window="null")
116+
# Boot a save-free copy of the ROM: PyBoy loads <rom>.ram next to the
117+
# ROM path and writes it back on stop(), so reusing one path makes every
118+
# later boot inherit an earlier boot's save. A fresh copy per boot keeps
119+
# every test on the deterministic default-seed boot, like CI.
120+
import shutil, tempfile
121+
path = str(Path(tempfile.mkdtemp()) / "pf.gb")
122+
shutil.copy(ROM, path)
123+
pb = PyBoy(path, window="null")
117124
pb.set_emulation_speed(0)
118125
if garbage:
119126
# simulate power-on WRAM garbage BEFORE the first tick (PyBoy zeroes
@@ -155,6 +162,38 @@ def new_game(pb, seed=None):
155162
for _ in range(30):
156163
pb.tick()
157164

165+
def wait_state(pb, state, frames=180):
166+
"""Tick until wState == state (screen rebuilds take several frames)."""
167+
mem = pb.memory
168+
for _ in range(frames):
169+
pb.tick()
170+
if mem[syms["wState"]] == state:
171+
return True
172+
return False
173+
174+
175+
def press3(pb, btn):
176+
"""Hold a button for 3 frames. A 1-tick press can land between the
177+
game's joypad reads (PyBoy writes hit mid-frame) and vanish."""
178+
pb.button_press(btn)
179+
for _ in range(3):
180+
pb.tick()
181+
pb.button_release(btn)
182+
183+
184+
def teleport(pb, tx, ty, tries=8):
185+
"""Teleport to a tile, surviving torn mid-frame position writes (the
186+
collision check can revert a torn write). Returns success."""
187+
mem = pb.memory
188+
for _ in range(tries):
189+
set16(mem, "wPosX", (tx * 8) << 4)
190+
set16(mem, "wPosY", (ty * 8) << 4)
191+
pb.tick()
192+
if (w16(mem, "wShipX") >> 3, w16(mem, "wShipY") >> 3) == (tx, ty):
193+
return True
194+
return False
195+
196+
158197
def w16(mem, n):
159198
return mem[syms[n]] | mem[syms[n] + 1] << 8
160199

@@ -415,6 +454,35 @@ def r8_final_wave_returned():
415454

416455
# ------------------------------------------------------ R9/R10: tavern rumors
417456

457+
def find_dockable_port(s16):
458+
"""A port district with a beach the game will actually accept: some
459+
water tile whose FIRST land neighbor in TryDock's N,S,W,E order is a
460+
beach inside this district. Seed-independent (unlike the old
461+
hardcoded (10,34), which is not a port district under DEADBEEF)."""
462+
for dy in range(72):
463+
for dx in range(80):
464+
if not has_port(dx, dy, s16):
465+
continue
466+
for ty in range(dy * 4, dy * 4 + 4):
467+
for tx in range(dx * 4, dx * 4 + 4):
468+
if tile(tx, ty, s16) < 3:
469+
continue
470+
for ddx, ddy in ((0, 1), (0, -1), (1, 0), (-1, 0)):
471+
nx, ny = tx + ddx, ty + ddy
472+
if not (0 <= nx < 320 and 0 <= ny < 288):
473+
continue
474+
if tile(nx, ny, s16) >= 3:
475+
continue
476+
for pdx, pdy in ((0, -1), (0, 1), (-1, 0), (1, 0)):
477+
if not (0 <= nx + pdx < 320 and 0 <= ny + pdy < 288):
478+
continue
479+
if tile(nx + pdx, ny + pdy, s16) >= 3:
480+
if (nx + pdx, ny + pdy) == (tx, ty):
481+
return (dx, dy)
482+
break
483+
raise AssertionError("no dockable port district in this sea")
484+
485+
418486
def dock_at_district(pb, s16, dx, dy):
419487
"""Teleport next to a beach in port district (dx,dy) and dock."""
420488
mem = pb.memory
@@ -424,13 +492,21 @@ def dock_at_district(pb, s16, dx, dy):
424492
continue
425493
for ddx, ddy in ((0, 1), (0, -1), (1, 0), (-1, 0)):
426494
nx, ny = tx + ddx, ty + ddy
495+
if not (0 <= nx < 320 and 0 <= ny < 288):
496+
continue
427497
if tile(nx, ny, s16) >= 3:
428498
continue
429499
set16(mem, "wPosX", (nx * 8) << 4)
430500
set16(mem, "wPosY", (ny * 8) << 4)
431501
for _ in range(5):
432502
pb.tick()
433-
press(pb, "a", 30)
503+
# a 1-tick press can land between joypad reads: hold for 3
504+
pb.button_press("a")
505+
for _ in range(3):
506+
pb.tick()
507+
pb.button_release("a")
508+
for _ in range(30):
509+
pb.tick()
434510
if mem[syms["wState"]] == 4:
435511
return
436512
raise AssertionError(f"no dockable beach in district ({dx},{dy})")
@@ -440,7 +516,7 @@ def r9_r10_tavern():
440516
mem = pb.memory
441517
new_game(pb)
442518
s16 = seed16(mem)
443-
dock_at_district(pb, s16, 10, 34) # the port test_m3 uses
519+
dock_at_district(pb, s16, *find_dockable_port(s16))
444520
press(pb, "down", 2)
445521
press(pb, "down", 2)
446522
press(pb, "a", 30) # TAVERN
@@ -506,7 +582,7 @@ def r11_r12_menu_and_gold():
506582
mem = pb.memory
507583
new_game(pb)
508584
s16 = seed16(mem)
509-
dock_at_district(pb, s16, 10, 34)
585+
dock_at_district(pb, s16, *find_dockable_port(s16))
510586
press(pb, "up", 5)
511587
assert mem[syms["wPortMenu"]] == 5, \
512588
f"UP from top item -> {mem[syms['wPortMenu']]}, want 5"
@@ -558,13 +634,14 @@ def r14_dig_no_cannon():
558634
if spot:
559635
break
560636
assert spot, "no beach-adjacent water in isle 0's cell"
561-
set16(mem, "wPosX", (spot[0] * 8) << 4)
562-
set16(mem, "wPosY", (spot[1] * 8) << 4)
563-
for _ in range(5):
637+
assert teleport(pb, *spot), "teleport never stuck"
638+
for _ in range(4):
564639
pb.tick()
565640
assert (mem[syms["wShipCX"]], mem[syms["wShipCY"]]) == (ix, iy)
566641
mem[syms["wGuardMask"]] = 1 # isle 0's guardian already sunk
567-
press(pb, "a", 30) # dig up the fragment
642+
press3(pb, "a") # dig up the fragment
643+
for _ in range(30):
644+
pb.tick()
568645
assert mem[syms["wState"]] == 5, f"state {mem[syms['wState']]}, want DIG"
569646
assert not mem[syms["wBallPActive"]], "digging fired a cannonball"
570647
pb.stop()
@@ -573,24 +650,20 @@ def r14_dig_no_cannon():
573650
# ---------------------- R15: SaveGame sets wHasSave (continue without reset)
574651

575652
def r15_save_sets_has_save():
576-
import shutil, tempfile
577-
# a ROM path with no .ram next to it: guaranteed no-save boot
578-
tmp = Path(tempfile.mkdtemp()) / "pf_nosave.gb"
579-
shutil.copy(ROM, tmp)
580-
pb = PyBoy(str(tmp), window="null")
581-
pb.set_emulation_speed(0)
582-
for _ in range(150):
583-
pb.tick()
653+
pb = boot() # save-free boot (see boot())
584654
mem = pb.memory
585655
assert mem[syms["wHasSave"]] == 0, "fresh cart reported a save"
586656
new_game(pb)
587657
s16 = seed16(mem)
588-
dock_at_district(pb, s16, 10, 34) # autosaves on dock
658+
dock_at_district(pb, s16, *find_dockable_port(s16)) # autosaves on dock
589659
assert mem[syms["wHasSave"]] == 1, "wHasSave not set after autosave"
590-
press(pb, "b", 30) # set sail
591-
press(pb, "b", 5) # arm quit confirm
592-
press(pb, "b", 30) # confirm: quit to the editor
593-
assert mem[syms["wState"]] == 0, "did not return to the editor"
660+
press3(pb, "b") # set sail
661+
assert wait_state(pb, 2), "never set sail"
662+
press3(pb, "b") # arm quit confirm
663+
for _ in range(5): # B must be seen released...
664+
pb.tick()
665+
press3(pb, "b") # ...before the confirming edge
666+
assert wait_state(pb, 0), "did not return to the editor"
594667
hint = read_text(mem, 0x9800 + 5 * 32 + 5, 11)
595668
assert hint == "A NEW GAME", f"editor hint {hint!r}, want 'A NEW GAME'"
596669
pb.stop()
@@ -612,16 +685,21 @@ def f1_quit_confirm():
612685
pb = boot()
613686
mem = pb.memory
614687
new_game(pb)
615-
press(pb, "b", 5)
688+
press3(pb, "b")
689+
for _ in range(5):
690+
pb.tick()
616691
assert mem[syms["wState"]] == 2, "single B press quit without confirm"
617692
assert mem[syms["wQuitCfm"]] > 0, "confirm window not armed"
618693
for _ in range(200): # let the window expire
619694
pb.tick()
620695
assert mem[syms["wQuitCfm"]] == 0, "confirm window never expired"
621696
assert mem[syms["wState"]] == 2, "expired confirm still quit"
622-
press(pb, "b", 5) # re-arm
623-
press(pb, "b", 10) # confirm
624-
assert mem[syms["wState"]] == 0, "second B press did not quit"
697+
press3(pb, "b") # re-arm
698+
for _ in range(5):
699+
pb.tick()
700+
assert mem[syms["wQuitCfm"]] > 0, "confirm window not re-armed"
701+
press3(pb, "b") # confirm
702+
assert wait_state(pb, 0), "second B press did not quit"
625703
pb.stop()
626704
print("F1 B-at-sea quit confirm: OK")
627705

0 commit comments

Comments
 (0)