Files
gen1recomp/tests/emerald_bike_test.lua
T
bryanthaboi 690f0cd9a1 TOUCHDOWN
2026-09-27 16:07:31 -04:00

355 lines
16 KiB
Lua

package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local GameVersion = require("src.core.GameVersion")
GameVersion.set("emerald")
require("src.import.gba.versions").select("emerald")
require("src.core.game3.se_ids").select("emerald")
require("src.core.game3.song_ids").select("emerald")
local Dataset = require("src.core.game3.dataset")
local cache = Dataset.cache()
if not cache:read("data/generated/gba/native/manifest.lua") or not cache:read("data/generated/gba/scripts/events.lua") then
print("emerald_bike_test: skipped (no Emerald cache for identity "
.. tostring(os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d") .. ")")
os.exit(0)
end
Dataset.mountExtractRoots()
local game = { data = {} }
Dataset.hydrate(game)
local Collision = require("src.core.game3.collision")
local Objects = require("src.core.game3.objects")
local Player = require("src.core.game3.player")
local Field = require("src.core.game3.field")
local Space = require("src.core.game3.scripting.space")
local Runtime = require("src.core.game3.runtime")
local ForcedMovement = require("src.core.game3.forced_movement")
local Ctx = require("src.core.game3.scripting.ctx")
local Flags = require("src.core.game3.scripting.flags")
local Constants = require("src.core.game3.constants")
local Bike = require("src.core.game3.bike")
local ItemUse = require("src.core.game3.item_use")
local Bag = require("src.core.game3.bag")
local MB = require("src.core.game3.mb")
local C = Constants.of("emerald")
local audioLog = {}
package.loaded["src.core.game3.audio"] = setmetatable({
playSe = function(id) audioLog[#audioLog + 1] = { se = id } end,
setSavedSong = function(id) audioLog[#audioLog + 1] = { saved = id } end,
changeMusicTo = function(id) audioLog[#audioLog + 1] = { song = id } end,
bikeMusic = function(on) audioLog[#audioLog + 1] = { bikeMusic = on } end,
stopSurfMusic = function() end,
}, { __index = function() return function() end end })
local session = { version = "emerald", map = nil, flags = {}, vars = {}, party = {}, name = "BRENDAN", bag = {} }
game.session = session
Runtime.session = session
Runtime._game = game
Field._game, Field._session, Field.running, Field.locked = game, session, true, false
local function load(mapId)
session.map = mapId
Field.clearMetatiles()
Space.activate(nil, mapId, game, nil)
Collision.bindMap(game, mapId, game.data.maps[mapId])
Objects.loadMap(game, mapId, game.data.maps[mapId])
require("src.core.game3.map").current = mapId
Ctx.resetStepCallback()
end
local function fakeInput(held, pressed)
held = held or {}
pressed = pressed or held
return {
isDown = function(_, b) return held[b] == true end,
wasPressed = function(_, b) return pressed[b] == true end,
}
end
local function ticks(n, input)
for _ = 1, n do Player.update(game, input) end
end
local function framesOfStep(input)
local startX, startY = Player.cellX, Player.cellY
Player.update(game, input)
if not Player.moving then return nil end
local frames = Player.stepFrames
for _ = 1, 64 do
Player.update(game, input)
if Player.cellX ~= startX or Player.cellY ~= startY then break end
end
return frames
end
local function mount(kind, x, y, facing)
Player.reset(x, y, facing)
Player.biking = false
Player.bikeType = nil
local rse = Bike.rse(session)
rse.getOnOff(kind, session)
return rse
end
check(Bike.rse(session) ~= nil, "Emerald has the machAcroBike bike module")
check(Bike.rse({ version = "firered" }) == nil, "FireRed keeps its own bicycle")
local function clearRun(x0, x1, y0, y1, len)
for y = y0, y1 do
for x = x0, x1 do
local free = true
for i = 0, len do
if not Collision.canEnter(game, x, y - i, {}) then free = false break end
end
if free then return x, y end
end
end
end
print("[test] Mach bike speed tiers on Route 110")
load("EM_ROUTE110")
local runX, runY = clearRun(3, 36, 10, 60, 10)
check(runX ~= nil, "Route 110 has a clear ten-cell run north (" .. tostring(runX) .. "," .. tostring(runY) .. ")")
local rse = mount("mach", runX, runY, "up")
eq(Player.biking, true, "GetOnOffBike puts the player on the bike")
eq(Player.bikeType, "mach", "Mach Bike transition flag")
eq(session.bikeType, "mach", "session remembers the bike")
local cycling = C:require("songs", "MUS_CYCLING")
local sawCycling = false
for _, e in ipairs(audioLog) do if e.song == cycling then sawCycling = true end end
check(sawCycling, "mounting plays MUS_CYCLING (" .. cycling .. ")")
local up = fakeInput({ up = true })
local tiers = {}
for i = 1, 5 do tiers[i] = framesOfStep(up) end
eq(tiers[1], 16, "first Mach step walks (PlayerWalkNormal)")
eq(tiers[2], 8, "second Mach step is PlayerWalkFast")
eq(tiers[3], 4, "third Mach step is PlayerWalkFaster")
eq(tiers[5], 4, "the Mach bike holds MOVE_SPEED_FASTER")
eq(rse.playerSpeed(), rse.SPEED.FASTEST, "GetPlayerSpeed reads PLAYER_SPEED_FASTEST at full speed")
local y0 = Player.cellY
ticks(1, fakeInput())
check(Player.moving and Player.targetY == y0 - 1, "releasing the D-pad coasts one more cell (TrySlowDown)")
local coasted = 0
for _ = 1, 60 do
local before = Player.cellY
Player.update(game, fakeInput())
if Player.cellY ~= before then coasted = coasted + 1 end
end
check(coasted >= 2 and coasted <= 4, "the Mach bike rolls to a stop (" .. coasted .. " cells)")
eq(rse.state.speed, 0, "and ends standing")
print("[test] muddy slope (Granite Cave B1F)")
load("EM_GRANITE_CAVE_B1F")
check(Collision.isMuddySlope(Collision.behavior(9, 10)) and Collision.isMuddySlope(Collision.behavior(9, 9)),
"Granite Cave B1F has the slope at (9,9)-(9,10)")
check(ForcedMovement.isForcedMovementTile(MB.require("MUDDY_SLOPE")), "muddy slope is a forced-movement tile")
check(ForcedMovement.isForcedMovementTile(MB.require("CRACKED_FLOOR")), "cracked floor is a forced-movement tile")
check(not ForcedMovement.isForcedMovementTile(MB.require("SPIN_RIGHT")), "FR spin tiles are not Emerald forced movement")
Player.reset(9, 11, "up")
Player.biking, Player.bikeType = false, nil
ticks(1, up)
ticks(40, up)
check(Player.cellY >= 10, "on foot the slope pushes the player back down (y=" .. Player.cellY .. ")")
local reachedTop = false
mount("mach", 9, 13, "up")
for _ = 1, 120 do
Player.update(game, up)
if Player.cellY <= 8 then reachedTop = true break end
end
check(reachedTop, "the Mach bike at speed climbs the slope (y=" .. Player.cellY .. ")")
mount("mach", 9, 11, "up")
for _ = 1, 80 do Player.update(game, up) end
check(Player.cellY >= 10, "from a standing start the Mach bike slides back (y=" .. Player.cellY .. ")")
mount("acro", 9, 11, "up")
for _ = 1, 80 do Player.update(game, up) end
check(Player.cellY >= 10, "the Acro bike never reaches PLAYER_SPEED_FASTEST (y=" .. Player.cellY .. ")")
print("[test] rails (Route 119)")
load("EM_ROUTE119")
check(Collision.isHorizontalRail(Collision.behavior(8, 5)), "Route 119 (8,5) is a horizontal rail")
check(Collision.isIsolatedHorizontalRail(Collision.behavior(10, 6)), "(10,6) is an isolated horizontal rail")
check(Collision.isHorizontalRail(Collision.behavior(10, 7)), "(10,7) is a horizontal rail")
Player.reset(7, 5, "right")
Player.biking, Player.bikeType = false, nil
local r, why = Player.tryMove("right", game, false)
check(r == "blocked" and why == "acro", "on foot the rail blocks (CheckAcroBikeCollision)")
rse = mount("acro", 7, 5, "right")
local right = fakeInput({ right = true })
eq(framesOfStep(right), 6, "the Acro bike rides onto the rail at MOVE_SPEED_FAST_2")
eq(Player.cellX, 8, "now on the rail")
ticks(1, fakeInput({ down = true }))
eq(Player.facing, "right", "a horizontal rail will not let the bike face south")
for _ = 1, 40 do
if Player.cellX >= 10 then break end
Player.update(game, right)
end
eq(Player.cellX, 10, "rode the rail to its end")
ticks(20, fakeInput())
eq(Player.moving, false, "stopped on the rail")
Player.update(game, fakeInput({ down = true, b = true }))
eq(rse.state.acroState, rse.ACRO.SIDE_JUMP, "DOWN + B within 4 frames is a side jump")
check(Player.moving and Player.jumping and Player.jumpType == "normal", "the side jump is a JUMP_TYPE_NORMAL hop")
eq(Player.facing, "right", "facing stays locked through the side jump")
ticks(20, fakeInput())
eq(Player.cellY, 6, "landed on the isolated rail")
ticks(10, fakeInput())
Player.update(game, fakeInput({ down = true, b = true }))
ticks(20, fakeInput())
eq(Player.cellY, 7, "a second side jump reaches the lower rail")
Player.update(game, fakeInput({ down = true }))
ticks(8, fakeInput({ down = true }))
eq(Player.cellY, 7, "plain DOWN on the rail does not step off it")
rse = mount("mach", 7, 5, "right")
eq(framesOfStep(right), 16, "the Mach bike may roll onto a long rail (COLLISION_HORIZONTAL_RAIL passes)")
ticks(30, fakeInput())
Player.update(game, fakeInput({ down = true, b = true }))
ticks(20, fakeInput())
eq(Player.cellY, 5, "the Mach bike has no side jump")
print("[test] wheelie and bunny hop")
load("EM_ROUTE119")
rse = mount("acro", 4, 13, "right")
Player.update(game, fakeInput({ b = true }))
eq(rse.state.acroState, rse.ACRO.WHEELIE_STANDING, "B from a standstill pops a wheelie")
check(Player.action ~= nil and Player.acroAnim and Player.acroAnim.kind == "back", "pop-wheelie anim runs")
local hopped = false
for _ = 1, 80 do
Player.update(game, fakeInput({ b = true }, {}))
if Player.action and Player.action.jump == "low" then hopped = true break end
end
check(hopped, "holding B 40 frames bunny hops in place")
eq(rse.state.acroState, rse.ACRO.BUNNY_HOP, "ACRO_STATE_BUNNY_HOP")
ticks(40, fakeInput({ b = true, right = true }, {}))
check(Player.cellX > 4, "hopping with RIGHT moves the hop (x=" .. Player.cellX .. ")")
ticks(40, fakeInput())
eq(rse.state.acroState, rse.ACRO.NORMAL, "releasing B lands the wheelie")
print("[test] item use: bikes, registered swap, dismount rules")
load("EM_ROUTE110")
Player.reset(27, 29, "up")
Player.biking, Player.bikeType = false, nil
local mach, acro = C:require("items", "ITEM_MACH_BIKE"), C:require("items", "ITEM_ACRO_BIKE")
local ok, kind = ItemUse.useBike(session, acro)
check(ok and kind == "bike" and Player.biking and Player.bikeType == "acro", "ACRO_BIKE mounts the Acro Bike")
ok = ItemUse.useBike(session, mach)
check(ok and not Player.biking, "any bike item dismounts (GetOnOffBike)")
ok = ItemUse.useBike(session, mach)
check(ok and Player.bikeType == "mach", "MACH_BIKE mounts the Mach Bike")
local cyclingFlag = Flags.forVersion("emerald").IDS.FLAG_SYS_CYCLING_ROAD
Flags.setFlag(Space.store, nil, cyclingFlag, true)
local ok2, _, text = ItemUse.useBike(session, mach)
check(not ok2 and type(text) == "string" and text:find("dismount", 1, true), "FLAG_SYS_CYCLING_ROAD: can't dismount")
Flags.setFlag(Space.store, nil, cyclingFlag, false)
ItemUse.useBike(session, mach)
load("EM_LITTLEROOT_TOWN_BRENDANS_HOUSE_1F")
session.map = "EM_LITTLEROOT_TOWN_BRENDANS_HOUSE_1F"
Player.reset(5, 5, "down")
local ok3, _, text3 = ItemUse.useBike(session, mach)
check(not ok3 and type(text3) == "string" and text3:find("DAD", 1, true), "indoors: Dad's advice (" .. tostring(text3) .. ")")
local specials = require("src.core.game3.bike.specials_rse").BY_NAME
session.registeredItem = mach
specials.SwapRegisteredBike()
eq(session.registeredItem, acro, "SwapRegisteredBike Mach -> Acro")
Player.biking, Player.bikeType = true, "acro"
local _, v = specials.GetPlayerAvatarBike()
eq(v, 1, "GetPlayerAvatarBike reads 1 on the Acro Bike")
Player.biking, Player.bikeType = false, nil
local ctx = { stringVars = {} }
local result = require("src.core.game3.bike.specials_rse").cyclingRoadResults(ctx, 9 * 60, 0)
eq(result, 10, "DetermineCyclingRoadResults: no bumps under 10 s scores 10")
eq(ctx.stringVars[2], " 9.00 seconds", "time string")
print("[test] per-step callbacks")
local function stepOnto(x, y, fromX, fromY)
Player.reset(fromX, fromY, "down")
ForcedMovement.runStepCallback(game)
Player.cellX, Player.cellY, Player.targetX, Player.targetY = x, y, x, y
end
load("EM_ROUTE113")
local Steps = require("src.core.game3.step_callbacks_rse")
Ctx.setStepCallback(Ctx.STEP_CB.ASH, "EM_ROUTE113")
Bag.add(session.bag, C:require("items", "ITEM_SOOT_SACK"), 1)
local ashX, ashY = 43, 2
local before = Steps.metatileAt(ashX, ashY)
eq(before, C:require("metatile_labels", "METATILE_Fallarbor_AshGrass"), "Route 113 (43,2) is ash grass")
stepOnto(ashX, ashY, ashX, ashY - 1)
for _ = 1, 5 do ForcedMovement.runStepCallback(game) end
eq(Steps.metatileAt(ashX, ashY), C:require("metatile_labels", "METATILE_Fallarbor_NormalGrass"),
"ash is swept off the grass 4 frames after the step")
eq(Flags.getVar(Space.store, nil, C:require("vars", "VAR_ASH_GATHER_COUNT")), 1, "the Soot Sack gathers one ash")
load("EM_SKY_PILLAR_2F")
Ctx.setStepCallback(Ctx.STEP_CB.CRACKED_FLOOR, "EM_SKY_PILLAR_2F")
local iceVar = C:require("vars", "VAR_ICE_STEP_COUNT")
Flags.setVar(Space.store, nil, iceVar, 1)
check(Collision.isCrackedFloor(Collision.behavior(10, 5)), "Sky Pillar 2F (10,5) is cracked floor")
stepOnto(10, 5, 10, 4)
ForcedMovement.runStepCallback(game)
eq(Flags.getVar(Space.store, nil, iceVar), 0, "walking onto a cracked floor zeroes VAR_ICE_STEP_COUNT")
for _ = 1, 3 do ForcedMovement.runStepCallback(game) end
check(Collision.isCrackedFloorHole(Collision.behavior(10, 5)), "three frames later the floor is a hole")
load("EM_SOOTOPOLIS_CITY_GYM_1F")
Ctx.setStepCallback(Ctx.STEP_CB.ICE, "EM_SOOTOPOLIS_CITY_GYM_1F")
Flags.setVar(Space.store, nil, iceVar, 0)
check(Collision.isThinIce(Collision.behavior(3, 6)), "Sootopolis Gym (3,6) is thin ice")
stepOnto(3, 6, 3, 5)
for _ = 1, 7 do ForcedMovement.runStepCallback(game) end
eq(Flags.getVar(Space.store, nil, iceVar), 1, "thin ice counts a step")
eq(Steps.metatileAt(3, 6), C:require("metatile_labels", "METATILE_SootopolisGym_Ice_Cracked"), "and cracks")
check(Steps.isIceVisited(3, 6), "the row var marks (3,6) visited")
Field.clearMetatiles()
Collision.bindMap(game, "EM_SOOTOPOLIS_CITY_GYM_1F", game.data.maps.EM_SOOTOPOLIS_CITY_GYM_1F)
eq(Steps.setSootopolisGymCrackedIceMetatiles(), 1, "SetSootopolisGymCrackedIceMetatiles restores the visited crack")
stepOnto(3, 6, 3, 5)
for _ = 1, 7 do ForcedMovement.runStepCallback(game) end
eq(Flags.getVar(Space.store, nil, iceVar), 0, "cracked ice zeroes the count (the ON_FRAME fall)")
eq(Steps.metatileAt(3, 6), C:require("metatile_labels", "METATILE_SootopolisGym_Ice_Broken"), "and breaks")
load("EM_FORTREE_CITY")
Ctx.setStepCallback(Ctx.STEP_CB.FORTREE_BRIDGE, "EM_FORTREE_CITY")
local RAISED = C:require("metatile_labels", "METATILE_Fortree_BridgeOverTrees_Raised")
local LOWERED = C:require("metatile_labels", "METATILE_Fortree_BridgeOverTrees_Lowered")
eq(Steps.metatileAt(16, 14), RAISED, "Fortree (16,14) is a raised log bridge")
Player.reset(15, 14, "right")
Player.elevation = 4
ForcedMovement.runStepCallback(game)
eq(Steps.metatileAt(15, 14), LOWERED, "standing on the bridge when the callback starts lowers it")
Player.cellX, Player.targetX = 16, 16
ForcedMovement.runStepCallback(game)
eq(Steps.metatileAt(15, 14), RAISED, "the section stepped off rises")
eq(Steps.metatileAt(16, 14), LOWERED, "the section stepped onto sinks")
local bounced = false
for _ = 1, 16 do
ForcedMovement.runStepCallback(game)
if Steps.metatileAt(15, 14) == LOWERED then bounced = true end
end
check(bounced, "the old section bounces")
eq(Steps.metatileAt(15, 14), RAISED, "and settles raised")
load("EM_PACIFIDLOG_TOWN")
Ctx.setStepCallback(Ctx.STEP_CB.PACIFIDLOG_BRIDGE, "EM_PACIFIDLOG_TOWN")
local function logMid(stage, part) return C:require("metatile_labels", "METATILE_Pacifidlog_" .. stage .. "_" .. part) end
Player.reset(4, 17, "right")
ForcedMovement.runStepCallback(game)
eq(Steps.metatileAt(4, 17), logMid("SubmergedLogs", "HorizontalLeft"), "the log under the player sinks")
eq(Steps.metatileAt(5, 17), logMid("SubmergedLogs", "HorizontalRight"), "both halves of the log sink")
Player.cellX, Player.targetX = 5, 5
ForcedMovement.runStepCallback(game)
eq(Steps.metatileAt(4, 17), logMid("SubmergedLogs", "HorizontalLeft"), "walking along the same log keeps it under")
Player.cellY, Player.targetY = 16, 16
ForcedMovement.runStepCallback(game)
eq(Steps.metatileAt(4, 17), logMid("HalfSubmergedLogs", "HorizontalLeft"), "stepping off starts the log rising")
for _ = 1, 8 do ForcedMovement.runStepCallback(game) end
eq(Steps.metatileAt(4, 17), logMid("FloatingLogs", "HorizontalLeft"), "eight frames later it floats")
eq(Steps.metatileAt(5, 17), logMid("FloatingLogs", "HorizontalRight"), "both halves float")
T.finish("emerald_bike")