Files
gen1recomp/tests/game3_corner_screen_test.lua
T
2026-09-21 11:14:17 -04:00

407 lines
14 KiB
Lua

#!/usr/bin/env luajit
package.path = "./?.lua;./?/init.lua;" .. package.path
local GameVersion = require("src.core.GameVersion")
GameVersion.set("firered")
local Slots = require("src.core.game3.slot_machine")
local Ui = require("src.ui.game3.slot_machine")
local failed = 0
local function check(cond, msg)
if cond then
print("[ok] " .. msg)
else
failed = failed + 1
print("[FAIL] " .. msg)
end
end
local function eq(a, b, msg)
check(a == b, string.format("%s (%s == %s)", msg, tostring(a), tostring(b)))
end
local PRET = "../pokefirered/src/slot_machine.c"
local function slurp(path)
local f = io.open(path, "rb")
if not f then return nil end
local d = f:read("*a")
f:close()
return d
end
local src = slurp(PRET)
local Audio = require("src.core.game3.audio")
local function session(coins)
return { coins = coins or 0 }
end
local function openMachine(coins, machineIdx)
Ui.show({ machineIdx = machineIdx or 0, session = session(coins) })
return Ui.state
end
local pressed = {}
local held = {}
local input = {
wasPressed = function(_, key)
local v = pressed[key]
pressed[key] = nil
return v and true or false
end,
isDown = function(_, key) return held[key] and true or false end,
}
local function tap(key)
pressed[key] = true
Ui.handleInput(input)
pressed[key] = nil
end
local function frame()
Ui.handleInput(input)
Ui.update(1 / 60)
Audio.update(1 / 60)
end
print("[test] 1. The Clefairy animation table matches pret frame for frame")
if not src then
print("[skip] no pret checkout at " .. PRET)
else
local names = { "Neutral", "Spinning", "Payout", "Lose" }
for animId, name in ipairs(names) do
local decl = "static const union AnimCmd sAnimCmd_Clefairy_" .. name .. "[] = {"
local i = src:find(decl, 1, true)
check(i ~= nil, "pret declares sAnimCmd_Clefairy_" .. name)
if i then
local body = src:sub(i + #decl, src:find("};", i, true))
local frames = {}
for tile, dur in body:gmatch("ANIMCMD_FRAME%(%s*(%d+)%s*,%s*(%d+)%s*%)") do
frames[#frames + 1] = { frame = tonumber(tile) / 16, duration = tonumber(dur) }
end
local port = Ui.CLEFAIRY_ANIM[animId - 1]
check(port ~= nil, "the port has anim " .. (animId - 1))
if port then
eq(#port, #frames, name .. " has pret's frame count")
for k, f in ipairs(frames) do
eq(port[k] and port[k].frame, f.frame, name .. " frame " .. k)
eq(port[k] and port[k].duration, f.duration, name .. " duration " .. k)
end
end
end
end
end
print("[test] 2. Clefairy sprite geometry is pret's")
if not src then
print("[skip] no pret checkout at " .. PRET)
else
local body = src:match("static void CreateClefairySprites%(void%)%s*{(.-)\n}")
check(body ~= nil, "pret declares CreateClefairySprites")
if body then
local x1, y1 = body:match("CreateSprite%(&sSpriteTemplate_Clefairy,%s*(%d+),%s*(%d+)")
eq(Ui.CLEFAIRY_X[1], tonumber(x1), "the left Clefairy sits at pret's x")
eq(Ui.CLEFAIRY_Y, tonumber(y1), "the Clefairy row is pret's y")
local off, y2 = body:match("CreateSprite%(&sSpriteTemplate_Clefairy,%s*DISPLAY_WIDTH%s*%-%s*(%d+),%s*(%d+)")
eq(Ui.CLEFAIRY_X[2], 240 - tonumber(off), "the right Clefairy sits at pret's mirrored x")
eq(Ui.CLEFAIRY_Y, tonumber(y2), "both Clefairy sprites share a row")
end
end
print("[test] 3. The reel buttons sit under pret's tilemap slots")
if not src then
print("[skip] no pret checkout at " .. PRET)
else
local body = src:match("sReelButtonMapTileIdxs%[NUM_REELS%]%[NUM_BUTTON_TILES%] = {(.-)};")
check(body ~= nil, "pret declares sReelButtonMapTileIdxs")
if body then
local reel = 0
for rowSrc in body:gmatch("{([^{}]+)}") do
local idxs = {}
for hex in rowSrc:gmatch("0x(%x+)") do idxs[#idxs + 1] = tonumber(hex, 16) end
eq(#idxs, 4, "reel " .. reel .. " is a 2x2 button")
local top = idxs[1]
-- pokefirered/src/slot_machine.c:857
local col = top % 32
local rowTile = math.floor(top / 32)
eq(Ui.BUTTON_X[reel], col * 8, "reel " .. reel .. " button x")
eq(Ui.BUTTON_Y, rowTile * 8, "reel " .. reel .. " button y")
eq(idxs[2], top + 1, "reel " .. reel .. " button is two tiles wide")
eq(idxs[3], top + 32, "reel " .. reel .. " button is two tiles tall")
reel = reel + 1
end
eq(reel, Slots.NUM_REELS, "every reel has a button")
eq(Ui.BUTTON_SIZE, 16, "a 2x2 tile button is 16 px")
end
end
print("[test] 4. The Clefairy animation follows the machine through a whole play")
do
local st = openMachine(10, 0)
eq(Ui.clefairyAnim, Ui.ANIM_NEUTRAL, "the machine opens on the neutral pose")
-- pokefirered/src/slot_machine.c:958
tap("down")
eq(st.bet, 1, "DOWN bet one coin")
-- pokefirered/src/slot_machine.c:984
tap("a")
eq(Ui.task, "spin", "A on a placed bet starts the spin")
frame()
eq(Ui.clefairyAnim, Ui.ANIM_SPINNING, "starting the reels switches to the spinning pose")
local guard = 0
while Ui.task == "stopping" and guard < 4000 do
-- pokefirered/src/slot_machine.c:1016
if Slots.isReelSpinning(st, st.currentReel) then pressed.a = true end
frame()
guard = guard + 1
end
check(guard < 4000, "the three reels resolved")
check(Ui.task == "win" or Ui.task == "lose", "the spin scored")
if Ui.task == "lose" then
eq(Ui.clefairyAnim, Ui.ANIM_LOSE, "a losing spin faints the Clefairy")
else
eq(Ui.clefairyAnim, Ui.ANIM_PAYOUT, "a paying spin starts the Clefairy dance")
end
check(Ui.buttonPressed[0], "the A press put the first reel button down")
guard = 0
while Ui.task ~= "bet" and guard < 4000 do
frame()
guard = guard + 1
end
check(guard < 4000, "the machine returned to betting")
eq(Ui.clefairyAnim, Ui.ANIM_NEUTRAL, "the Clefairy goes back to neutral")
check(next(Ui.buttonPressed) == nil, "the reel buttons are released")
Ui.close()
end
print("[test] 5. The Clefairy anim advances on pret's frame counts")
do
openMachine(10, 0)
Ui.setClefairyAnim(Ui.ANIM_LOSE)
eq(Ui.clefairyFrame(), Ui.CLEFAIRY_ANIM[Ui.ANIM_LOSE][1].frame, "the lose anim starts on its first frame")
local dur = Ui.CLEFAIRY_ANIM[Ui.ANIM_LOSE][1].duration
for _ = 1, dur do Ui.stepClefairy() end
eq(Ui.clefairyFrame(), Ui.CLEFAIRY_ANIM[Ui.ANIM_LOSE][1].frame,
"the first frame is still up on its last tick")
Ui.stepClefairy()
eq(Ui.clefairyFrame(), Ui.CLEFAIRY_ANIM[Ui.ANIM_LOSE][2].frame, "the anim advanced")
for _ = 1, Ui.CLEFAIRY_ANIM[Ui.ANIM_LOSE][2].duration + 1 do Ui.stepClefairy() end
eq(Ui.clefairyFrame(), Ui.CLEFAIRY_ANIM[Ui.ANIM_LOSE][1].frame, "the anim jumped back to the start")
Ui.setClefairyAnim(Ui.ANIM_NEUTRAL)
for _ = 1, 200 do Ui.stepClefairy() end
eq(Ui.clefairyFrame(), 0, "a one-frame anim never advances")
Ui.close()
end
print("[test] 6. The winning line flashes on pret's cadence")
do
openMachine(10, 0)
Ui._lineFlash = 0
local on, off = 0, 0
for _ = 1, Ui.LINE_FLASH_PERIOD * 2 do
if Ui.lineFlashOn() then on = on + 1 else off = off + 1 end
Ui.update(1 / 60)
end
eq(on, Ui.LINE_FLASH_PERIOD, "the lit half is pret's eight frames")
eq(off, Ui.LINE_FLASH_PERIOD, "the dark half is pret's eight frames")
Ui.close()
end
print("[test] 7. The reel art and the Clefairy art degrade to placeholders")
do
check(Ui.iconSheet() == nil or type(Ui.iconSheet()) == "userdata",
"the reel sheet is absent or an image")
check(Ui.clefairySheet() == nil or type(Ui.clefairySheet()) == "userdata",
"the Clefairy sheet is absent or an image")
check(Ui.combosWindow() == nil or type(Ui.combosWindow()) == "userdata",
"the payout help sheet is absent or an image")
openMachine(10, 0)
local ok = pcall(Ui.draw)
check(ok, "drawing without love and without art does not error")
Ui.close()
end
print("[test] 8. DPAD_RIGHT slides the payout help panel in and DPAD_LEFT slides it out")
if not src then
print("[skip] no pret checkout at " .. PRET)
else
local loop = src:match("static void MainTask_SlotsGameLoop%(u8 taskId%)%s*{(.-)\nstatic ")
check(loop ~= nil and loop:find("JOY_NEW(DPAD_RIGHT)", 1, true) ~= nil
and loop:find("SetMainTask(MainTask_ShowHelp)", 1, true) ~= nil,
"pret opens the help panel on DPAD_RIGHT from the betting phase")
local help = src:match("static void MainTask_ShowHelp%(u8 taskId%)%s*{(.-)\nstatic ")
check(help ~= nil and help:find("JOY_NEW(DPAD_LEFT)", 1, true) ~= nil,
"pret closes it on DPAD_LEFT")
local slide = src:match("static bool8 SlotsTask_ShowHelp%(u8 %* state.-\n}") or ""
local step, width = slide:match("bg1X %+= (%d+).-bg1X >= (%d+)")
eq(Ui.HELP_SLIDE_STEP, tonumber(step), "the panel slides at pret's pixels per frame")
eq(Ui.HELP_WIDTH, tonumber(width), "the panel slides to pret's full width")
openMachine(50, 0)
eq(Ui.task, "bet", "the machine is on the betting phase")
tap("right")
eq(Ui.task, "help", "DPAD_RIGHT opened the payout help panel")
local frames = 0
while Ui.helpPhase == "in" and frames < 200 do
frame()
frames = frames + 1
end
eq(Ui.helpPhase, "shown", "the panel finished sliding in")
eq(frames, Ui.HELP_WIDTH / Ui.HELP_SLIDE_STEP, "it took pret's sixteen frames")
eq(Ui.helpX, Ui.HELP_WIDTH, "the panel covers the screen")
for _ = 1, 120 do frame() end
eq(Ui.task, "help", "the panel stays up until the player closes it")
tap("left")
frames = 0
while Ui.task == "help" and frames < 200 do
frame()
frames = frames + 1
end
eq(Ui.task, "bet", "DPAD_LEFT put the machine back on the betting phase")
eq(Ui.helpX, 0, "the panel slid back off screen")
local ok = pcall(Ui.draw)
check(ok, "drawing the help panel without art does not error")
Ui.close()
end
print("[test] 9. A paying spin plays pret's fanfare on pret's frame counts")
do
local played = {}
local realFanfare = Audio.playFanfare
Audio.playFanfare = function(id)
played[#played + 1] = id
return realFanfare(id)
end
local Rng = require("src.core.game3.rng")
Rng.SeedRng(0x51075)
local sess = { coins = 500 }
Ui.show({ machineIdx = 5, session = sess })
local st = Ui.state
local coinFrame, winFrames, jackpots = nil, 0, 0
for _ = 1, 80 do
tap("r")
frame()
local guard = 0
while Ui.task == "stopping" and guard < 2000 do
if Slots.isReelSpinning(st, st.currentReel) then pressed.a = true end
frame()
guard = guard + 1
end
if Ui.task == "win" then
winFrames = winFrames + 1
if st.slotRewardClass == Slots.PAYOUT.SEVEN then jackpots = jackpots + 1 end
local payoutAtWin = st.payout
local n = 0
while Ui.task == "win" and n < 5000 do
frame()
n = n + 1
if not coinFrame and st.payout < payoutAtWin then coinFrame = n end
end
break
end
local n = 0
while Ui.task ~= "bet" and n < 500 do frame(); n = n + 1 end
end
check(winFrames > 0, "a paying spin landed")
eq(#played, 1, "the win started exactly one fanfare")
-- pokefirered/src/slot_machine.c:1177
local expect = (jackpots > 0) and Ui.MUS_SLOTS_JACKPOT or Ui.MUS_SLOTS_WIN
eq(played[1], expect, "the fanfare is pret's song for that payout class")
-- pokefirered/src/slot_machine.c:1182
eq(coinFrame, 122, "the first coin drops 1 + 113 + 8 frames after the win")
eq(Slots.gameStat(sess, Slots.GAME_STAT_SLOT_JACKPOTS), jackpots,
"only a triple seven counts a jackpot")
Ui.close()
Audio.playFanfare = realFanfare
end
print("[test] 10. A triple seven counts the jackpot stat and plays the jackpot fanfare")
do
local played = {}
local realFanfare = Audio.playFanfare
Audio.playFanfare = function(id)
played[#played + 1] = id
return realFanfare(id)
end
local Rng = require("src.core.game3.rng")
Rng.SeedRng(0x7777)
local sess = { coins = 9000 }
Ui.show({ machineIdx = 5, session = sess })
local st = Ui.state
local hit = false
for _ = 1, 4000 do
tap("r")
frame()
-- pokefirered/src/slot_machine.c:1680
st.machineBias = Slots.PAYOUT.SEVEN
local guard = 0
while Ui.task == "stopping" and guard < 2000 do
if Slots.isReelSpinning(st, st.currentReel) then pressed.a = true end
frame()
guard = guard + 1
end
if st.slotRewardClass == Slots.PAYOUT.SEVEN then
hit = true
break
end
local n = 0
while Ui.task ~= "bet" and n < 4000 do frame(); n = n + 1 end
end
check(hit, "a biased machine eventually lands the triple seven")
if hit then
-- pokefirered/src/slot_machine.c:1041
eq(Slots.gameStat(sess, Slots.GAME_STAT_SLOT_JACKPOTS), 1, "the jackpot stat counted one")
local n = 0
while Ui.task == "win" and #played == 0 and n < 10 do frame(); n = n + 1 end
eq(played[1], Ui.MUS_SLOTS_JACKPOT, "the jackpot plays MUS_SLOTS_JACKPOT")
end
Ui.close()
Audio.playFanfare = realFanfare
end
print("[test] 11. The screen fades in on open and out on close")
do
local sess = { coins = 0 }
Ui.show({ machineIdx = 0, session = sess })
-- pokefirered/src/slot_machine.c:2087
eq(Ui._fadeY, Ui.FADE_MAX, "the machine opens black")
check(Ui.fadeActive(), "the fade in is running")
local frames = 0
while Ui.fadeActive() and frames < 100 do
frame()
frames = frames + 1
end
eq(Ui._fadeY, 0, "the fade in finished")
eq(frames, math.ceil(Ui.FADE_MAX / Ui.FADE_STEP), "pret's delta of three takes six frames")
-- pokefirered/src/slot_machine.c:1068
frame()
eq(Ui.task, "nocoins", "an empty purse ends the game")
tap("right")
eq(Ui.task, "exit", "DPAD_ANY closes the game over message")
frame()
eq(Ui.task, "fadeout", "closing starts the fade out")
frames = 0
while Ui.isOpen() and frames < 100 do
frame()
frames = frames + 1
end
check(not Ui.isOpen(), "the machine closed after the fade out")
eq(frames, math.ceil(Ui.FADE_MAX / Ui.FADE_STEP), "the fade out runs before the screen closes")
eq(Ui._fadeY, Ui.FADE_MAX, "the screen is black when it hands control back")
end
if failed > 0 then
print("[test] FAILED " .. failed)
os.exit(1)
end
print("[test] all passed")