mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 05:32:07 -04:00
123feef3a1
CLOSES #2425, CLOSES #2428, CLOSES #2431, CLOSES #2432, CLOSES #2433, CLOSES #2435, CLOSES #2436, CLOSES #2437, CLOSES #2440
374 lines
16 KiB
Lua
374 lines
16 KiB
Lua
#!/usr/bin/env luajit
|
|
-- pokefirered/src/trainer_tower.c:438 CallTrainerTowerFunc
|
|
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
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 Ctx = require("src.core.game3.scripting.ctx")
|
|
local Flags = require("src.core.game3.scripting.flags")
|
|
local Std = require("src.core.game3.scripting.stdscripts")
|
|
local Natives = require("src.core.game3.scripting.natives")
|
|
local Ops = require("src.core.game3.scripting.ops_a")
|
|
local Bag = require("src.core.game3.bag")
|
|
local Task = require("src.core.game3.task")
|
|
local Tower = require("src.core.game3.trainer_tower")
|
|
local TowerNatives = require("src.core.game3.scripting.natives_tower")
|
|
local Cache = require("tests.game3_cache")
|
|
|
|
-- pokefirered/src/trainer_tower_sets.c:8956 gTrainerTowerFloors is cache data
|
|
Cache.mount("meta.json")
|
|
local romBundle = Cache.bundle()
|
|
if not romBundle then
|
|
package.loaded["src.core.game3.rom_text"] = {
|
|
plain = function(key) return key end, box = function(key) return key end,
|
|
ascii = function(key) return key end, has = function() return true end,
|
|
ir = function(key) return { { t = "text", s = key } } end,
|
|
key = function(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (n .. "[" .. i .. "]") end,
|
|
at = function(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (n .. "[" .. i .. "]") end,
|
|
count = function() return 0 end, list = function() return {} end,
|
|
lazy = function(map) return setmetatable({}, { __index = function(_, k) return map[k] end }) end,
|
|
}
|
|
end
|
|
Tower.resetPack()
|
|
local havePack = Tower.floors(Tower.CHALLENGE_TYPE.SINGLE) ~= nil
|
|
if not havePack then
|
|
print("[skip] the floor-set checks: " .. tostring(Cache.reason or "no trainer_tower.lua in cache"))
|
|
end
|
|
|
|
local store = Flags.newStore()
|
|
package.loaded["src.core.game3.scripting.space"] = { store = store, ensureBundle = function() return romBundle end }
|
|
|
|
local adapters = { log = function() end }
|
|
|
|
local function newCtx()
|
|
local ctx = Ctx.new({})
|
|
ctx.mode = "bytecode"
|
|
ctx.status = "running"
|
|
return ctx
|
|
end
|
|
|
|
local function useSession(tbl)
|
|
package.loaded["src.core.game3.runtime"] = {
|
|
getSession = function() return tbl end,
|
|
isActive = function() return true end,
|
|
}
|
|
return tbl
|
|
end
|
|
|
|
local function newSession(map)
|
|
return useSession({
|
|
name = "RED",
|
|
gender = 0,
|
|
money = 3000,
|
|
trainerId = 4242,
|
|
party = {},
|
|
bag = Bag.new(),
|
|
map = map or "FR_TRAINER_TOWER_LOBBY",
|
|
x = 9,
|
|
y = 7,
|
|
modData = {},
|
|
})
|
|
end
|
|
|
|
local function getVar(ctx, id) return Flags.getVar(store, ctx, id) end
|
|
local function setVar(ctx, id, v) Flags.setVar(store, ctx, id, v) end
|
|
|
|
-- pokefirered/asm/macros/trainer_tower.inc:2
|
|
local function towerFunc(ctx, index, arg5, arg6)
|
|
setVar(ctx, 0x8004, index)
|
|
if arg5 ~= nil then setVar(ctx, 0x8005, arg5) end
|
|
if arg6 ~= nil then setVar(ctx, 0x8006, arg6) end
|
|
Ops.dispatch({ ctx = ctx, store = store, adapters = adapters, setPc = function() end },
|
|
{ op = "special", id = Std.SPECIAL.CallTrainerTowerFunc })
|
|
return getVar(ctx, 0x800D)
|
|
end
|
|
|
|
print("[test] 1. special 0x194 is bound and every gTrainerTowerFuncs index is reachable")
|
|
eq(Std.SPECIAL.CallTrainerTowerFunc, 0x194, "CallTrainerTowerFunc special id")
|
|
check(Natives.ALLOW["special:" .. Std.SPECIAL.CallTrainerTowerFunc] ~= nil,
|
|
"natives.lua discovered natives_tower and bound special 404")
|
|
local session = newSession("FR_TRAINER_TOWER_1F")
|
|
local ctx = newCtx()
|
|
TowerNatives._logged = {}
|
|
for index = 0, Tower.FUNC_COUNT - 1 do
|
|
local ok, err = pcall(towerFunc, ctx, index)
|
|
check(ok, "gTrainerTowerFuncs[" .. index .. "] ran (" .. tostring(err) .. ")")
|
|
end
|
|
local outOfRange = "CallTrainerTowerFunc index out of range: "
|
|
local logged = 0
|
|
for msg in pairs(TowerNatives._logged) do
|
|
if msg:sub(1, #outOfRange) == outOfRange then logged = logged + 1 end
|
|
end
|
|
eq(logged, 0, "no index in 0..20 fell through the dispatcher")
|
|
towerFunc(ctx, Tower.FUNC_COUNT)
|
|
check(TowerNatives._logged[outOfRange .. Tower.FUNC_COUNT] == true,
|
|
"index 21 is out of range and is logged, so the sweep above proves reachability")
|
|
|
|
print("[test] 2. StartTrainerTowerChallenge picks the mode and clamps it")
|
|
session = newSession("FR_TRAINER_TOWER_LOBBY")
|
|
ctx = newCtx()
|
|
-- pokefirered/data/maps/TrainerTower_Lobby/scripts.inc:187
|
|
towerFunc(ctx, Tower.FUNC.START_CHALLENGE, Tower.CHALLENGE_TYPE.KNOCKOUT)
|
|
eq(Tower.getChallengeId(session), Tower.CHALLENGE_TYPE.KNOCKOUT, "mode is KNOCKOUT")
|
|
towerFunc(ctx, Tower.FUNC.START_CHALLENGE, Tower.NUM_CHALLENGE_TYPES)
|
|
eq(Tower.getChallengeId(session), Tower.CHALLENGE_TYPE.SINGLE,
|
|
"an out of range mode clamps to SINGLE (pokefirered/src/trainer_tower.c:772)")
|
|
|
|
print("[test] 3. the timer starts, runs on the engine task and stops")
|
|
session = newSession("FR_TRAINER_TOWER_LOBBY")
|
|
ctx = newCtx()
|
|
Task.clear()
|
|
towerFunc(ctx, Tower.FUNC.START_CHALLENGE, Tower.CHALLENGE_TYPE.SINGLE)
|
|
check(Tower.isTimerRunning(session), "START_CHALLENGE started the clock")
|
|
eq(Tower.record(session).timer, 0, "START_CHALLENGE zeroed the clock")
|
|
for _ = 1, 45 do Task.update(1 / 60) end
|
|
eq(Tower.record(session).timer, 45, "45 engine frames = 45 tower frames")
|
|
-- pokefirered/src/trainer_tower.c:788
|
|
towerFunc(ctx, Tower.FUNC.GET_OWNER_STATE)
|
|
check(not Tower.isTimerRunning(session), "GetOwnerState disabled the counter")
|
|
for _ = 1, 30 do Task.update(1 / 60) end
|
|
eq(Tower.record(session).timer, 45, "a stopped clock does not advance")
|
|
-- pokefirered/src/trainer_tower.c:840
|
|
towerFunc(ctx, Tower.FUNC.RESUME_TIMER)
|
|
check(not Tower.isTimerRunning(session),
|
|
"ResumeTimer stays stopped once the owner has been spoken to")
|
|
|
|
print("[test] 4. the timer clamps at TRAINER_TOWER_MAX_TIME and formats like pret")
|
|
session = newSession("FR_TRAINER_TOWER_ROOF")
|
|
ctx = newCtx()
|
|
Task.clear()
|
|
towerFunc(ctx, Tower.FUNC.START_CHALLENGE, Tower.CHALLENGE_TYPE.SINGLE)
|
|
Tower.record(session).timer = Tower.MAX_TIME + 500
|
|
towerFunc(ctx, Tower.FUNC.GET_TIME)
|
|
eq(Tower.record(session).timer, Tower.MAX_TIME, "GetCurrentTime clamped the clock")
|
|
check(not Tower.isTimerRunning(session), "GetCurrentTime stopped the counter at the ceiling")
|
|
eq(ctx.stringVars[1], "59", "gStringVar1 minutes at the ceiling")
|
|
eq(ctx.stringVars[2], "59", "gStringVar2 seconds at the ceiling")
|
|
eq(ctx.stringVars[3], "99", "gStringVar3 hundredths at the ceiling")
|
|
local m, s, c = Tower.formatTime(3661)
|
|
eq(m, " 1", "1 minute right aligned")
|
|
eq(s, " 1", "1 second right aligned")
|
|
eq(c, "01", "1 frame is 01 hundredths (1 * 168 / 100)")
|
|
|
|
print("[test] 5. the floor counter advances and the floor index clamps to the roof")
|
|
session = newSession("FR_TRAINER_TOWER_1F")
|
|
ctx = newCtx()
|
|
Task.clear()
|
|
towerFunc(ctx, Tower.FUNC.START_CHALLENGE, Tower.CHALLENGE_TYPE.SINGLE)
|
|
eq(Tower.record(session).floorsCleared, 0, "a fresh run has cleared no floors")
|
|
-- pokefirered/data/scripts/trainer_tower.inc:80
|
|
eq(towerFunc(ctx, Tower.FUNC.GET_FLOOR_CLEARED), 0, "1F is not cleared yet")
|
|
towerFunc(ctx, Tower.FUNC.CLEARED_FLOOR)
|
|
eq(Tower.record(session).floorsCleared, 1, "clearing 1F advanced the counter")
|
|
eq(towerFunc(ctx, Tower.FUNC.GET_FLOOR_CLEARED), 1, "1F now reports cleared")
|
|
session.map = "FR_TRAINER_TOWER_2F"
|
|
eq(towerFunc(ctx, Tower.FUNC.GET_FLOOR_CLEARED), 0, "2F is the next uncleared floor")
|
|
for i = 0, Tower.MAX_FLOORS - 1 do
|
|
eq(Tower.floorIndexForMap("FR_TRAINER_TOWER_" .. (i + 1) .. "F"), i,
|
|
"floor index of " .. (i + 1) .. "F")
|
|
end
|
|
check(Tower.floor(Tower.CHALLENGE_TYPE.SINGLE, Tower.MAX_FLOORS) == nil,
|
|
"there is no floor past 8F")
|
|
check(not Tower.isPastFinalFloor("FR_TRAINER_TOWER_8F"), "8F is inside the tower")
|
|
check(Tower.isPastFinalFloor("FR_TRAINER_TOWER_ROOF"),
|
|
"the roof is past the last floor (pokefirered/src/trainer_tower.c:546)")
|
|
session.map = "FR_TRAINER_TOWER_ROOF"
|
|
eq(towerFunc(ctx, Tower.FUNC.INIT_FLOOR), 3,
|
|
"InitTrainerTowerFloor on the roof returns 3 and skips the challenge types")
|
|
|
|
print("[test] 6. the lobby queries")
|
|
session = newSession("FR_TRAINER_TOWER_LOBBY")
|
|
ctx = newCtx()
|
|
Task.clear()
|
|
-- pokefirered/data/maps/TrainerTower_Lobby/scripts.inc:148
|
|
eq(towerFunc(ctx, Tower.FUNC.GET_NUM_FLOORS), 0,
|
|
"GetNumFloors is FALSE for the built in floor set")
|
|
-- pokefirered/data/scripts/trainer_tower.inc:90
|
|
eq(towerFunc(ctx, Tower.FUNC.SHOULD_WARP_TO_COUNTER), 0, "ShouldWarpToCounter is dummied to FALSE")
|
|
-- pokefirered/data/maps/TrainerTower_Lobby/scripts.inc:106
|
|
eq(towerFunc(ctx, Tower.FUNC.GET_BEAT_CHALLENGE), 0, "the owner has not been spoken to")
|
|
towerFunc(ctx, Tower.FUNC.START_CHALLENGE, Tower.CHALLENGE_TYPE.SINGLE)
|
|
-- pokefirered/src/trainer_tower.c:856
|
|
eq(towerFunc(ctx, Tower.FUNC.GET_CHALLENGE_STATUS), Tower.CHALLENGE_STATUS.NORMAL,
|
|
"an ongoing run reports CHALLENGE_STATUS_NORMAL")
|
|
towerFunc(ctx, Tower.FUNC.SET_LOST)
|
|
eq(towerFunc(ctx, Tower.FUNC.GET_CHALLENGE_STATUS), Tower.CHALLENGE_STATUS.LOST,
|
|
"after SetPlayerLost the lobby reports CHALLENGE_STATUS_LOST")
|
|
eq(towerFunc(ctx, Tower.FUNC.GET_CHALLENGE_STATUS), Tower.CHALLENGE_STATUS.NORMAL,
|
|
"the lost flag is consumed once")
|
|
local Party = require("src.core.game3.party")
|
|
-- pokefirered/include/constants/pokemon.h:197
|
|
eq(towerFunc(ctx, Tower.FUNC.CHECK_DOUBLES), Party.PLAYER_HAS_ONE_MON,
|
|
"an empty party is not eligible for a double battle")
|
|
session.party = {
|
|
{ species = 1, hp = 20, level = 10 },
|
|
{ species = 4, hp = 18, level = 10 },
|
|
}
|
|
eq(towerFunc(ctx, Tower.FUNC.CHECK_DOUBLES), Party.PLAYER_HAS_TWO_USABLE_MONS,
|
|
"two healthy mons clear the double battle trigger")
|
|
session.party = {}
|
|
|
|
print("[test] 7. the owner ladder, the prize and the record time")
|
|
session = newSession("FR_TRAINER_TOWER_ROOF")
|
|
ctx = newCtx()
|
|
Task.clear()
|
|
towerFunc(ctx, Tower.FUNC.START_CHALLENGE, Tower.CHALLENGE_TYPE.SINGLE)
|
|
for _ = 1, 120 do Task.update(1 / 60) end
|
|
-- pokefirered/data/scripts/trainer_tower.inc:268
|
|
eq(towerFunc(ctx, Tower.FUNC.GET_OWNER_STATE), 0, "the owner has not been met")
|
|
eq(towerFunc(ctx, Tower.FUNC.GET_OWNER_STATE), 1, "the second visit offers the prize")
|
|
-- pokefirered/src/trainer_tower.c:801
|
|
if havePack then
|
|
local prize = Tower.prizeItem(session, Tower.CHALLENGE_TYPE.SINGLE)
|
|
eq(towerFunc(ctx, Tower.FUNC.GIVE_PRIZE), 0, "the prize is handed over")
|
|
eq(Bag.has(session.bag, prize, 1), true, "the SINGLE prize item reached the bag")
|
|
eq(towerFunc(ctx, Tower.FUNC.GIVE_PRIZE), 2, "the prize is only given once")
|
|
else
|
|
-- pokefirered/src/trainer_tower.c:805 AddBagItem of a prize the cache does not name
|
|
eq(towerFunc(ctx, Tower.FUNC.GIVE_PRIZE), 1, "with no floor set the prize is refused")
|
|
end
|
|
-- pokefirered/src/trainer_tower.c:825
|
|
eq(towerFunc(ctx, Tower.FUNC.CHECK_FINAL_TIME), 0, "120 frames beats the empty record")
|
|
eq(Tower.record(session).bestTime, 120, "the record time was written")
|
|
eq(towerFunc(ctx, Tower.FUNC.CHECK_FINAL_TIME), 2, "the final time is only checked once")
|
|
if havePack then
|
|
eq(towerFunc(ctx, Tower.FUNC.GET_OWNER_STATE), 2, "the owner is done with the player")
|
|
end
|
|
|
|
print("[test] 8. the prize differs per mode, as floors[0].prize does")
|
|
if havePack then
|
|
local seen, distinct = {}, 0
|
|
for mode = 0, Tower.NUM_CHALLENGE_TYPES - 1 do
|
|
-- pokefirered/src/trainer_tower.c:801 sPrizeList[floors[0].prize]
|
|
local item = Tower.prizeItem(session, mode)
|
|
eq(item, Tower.PRIZE_ITEMS[Tower.floors(mode)[1].prize], "mode " .. mode .. " prize item")
|
|
if item and not seen[item] then
|
|
seen[item] = true
|
|
distinct = distinct + 1
|
|
end
|
|
end
|
|
check(distinct > 1, "the prize is per mode, not one item for the whole tower")
|
|
eq(Tower.prizeItem(session, Tower.CHALLENGE_TYPE.SINGLE),
|
|
Tower.prizeItem(session, Tower.CHALLENGE_TYPE.SINGLE + Tower.NUM_CHALLENGE_TYPES),
|
|
"an out of range mode clamps to SINGLE's prize")
|
|
end
|
|
|
|
print("[test] 9. the mixed challenge mixes challenge types per floor")
|
|
if havePack then
|
|
local kinds = {}
|
|
-- pokefirered/include/constants/trainer_tower.h:8 mixed uses one of the other 3 per floor
|
|
for _, row in ipairs(Tower.floors(Tower.CHALLENGE_TYPE.MIXED)) do
|
|
kinds[row.challengeType] = true
|
|
end
|
|
local n = 0
|
|
for _ in pairs(kinds) do n = n + 1 end
|
|
check(n > 1, "MIXED carries more than one challenge type across its floors")
|
|
check(kinds[Tower.CHALLENGE_TYPE.MIXED] == nil, "and never MIXED itself")
|
|
for mode = 0, Tower.NUM_CHALLENGE_TYPES - 2 do
|
|
for _, row in ipairs(Tower.floors(mode)) do
|
|
eq(row.challengeType, mode, "mode " .. mode .. " is that type on every floor")
|
|
end
|
|
end
|
|
for mode = 0, Tower.NUM_CHALLENGE_TYPES - 1 do
|
|
local floors = Tower.floors(mode)
|
|
eq(#floors, Tower.MAX_FLOORS, "mode " .. mode .. " has 8 floors")
|
|
for i = 1, Tower.MAX_FLOORS do
|
|
eq(floors[i].floorIdx, Tower.MAX_FLOORS, "mode " .. mode .. " floor " .. i .. " floorIdx")
|
|
end
|
|
end
|
|
end
|
|
|
|
print("[test] 10. sFloorLayouts maps floor and challenge type to a layout id")
|
|
-- pokefirered/src/trainer_tower.c:353
|
|
eq(Tower.floorLayoutFor(0, Tower.CHALLENGE_TYPE.SINGLE), 298, "1F singles layout")
|
|
eq(Tower.floorLayoutFor(0, Tower.CHALLENGE_TYPE.DOUBLE), 366, "1F doubles layout")
|
|
eq(Tower.floorLayoutFor(7, Tower.CHALLENGE_TYPE.KNOCKOUT), 381, "8F knockout layout")
|
|
|
|
print("[test] 11. the tower state survives a save and reload")
|
|
session = newSession("FR_TRAINER_TOWER_5F")
|
|
ctx = newCtx()
|
|
Task.clear()
|
|
towerFunc(ctx, Tower.FUNC.START_CHALLENGE, Tower.CHALLENGE_TYPE.DOUBLE)
|
|
for _ = 1, 600 do Task.update(1 / 60) end
|
|
towerFunc(ctx, Tower.FUNC.CLEARED_FLOOR)
|
|
towerFunc(ctx, Tower.FUNC.CLEARED_FLOOR)
|
|
local Schema = require("src.core.game3.save_schema_firered")
|
|
local saved = Schema.toSaveTable(session)
|
|
local reloaded = useSession(Schema.fromSaveTable(saved))
|
|
eq(Tower.getChallengeId(reloaded), Tower.CHALLENGE_TYPE.DOUBLE, "the mode survived the save")
|
|
eq(Tower.record(reloaded).timer, 600, "the clock survived the save")
|
|
eq(Tower.record(reloaded).floorsCleared, 2, "the floors cleared survived the save")
|
|
ctx = newCtx()
|
|
towerFunc(ctx, Tower.FUNC.RESUME_TIMER)
|
|
check(Tower.isTimerRunning(reloaded), "OnResume restarts the clock after a reload")
|
|
for _ = 1, 60 do Task.update(1 / 60) end
|
|
eq(Tower.record(reloaded).timer, 660, "the reloaded clock keeps counting")
|
|
Task.clear()
|
|
|
|
print("[test] 12. the battle runs through the host and the unused results board opens")
|
|
session = newSession("FR_TRAINER_TOWER_1F")
|
|
ctx = newCtx()
|
|
Task.clear()
|
|
Tower.setPack(nil)
|
|
TowerNatives._logged = {}
|
|
session.party = { { species = 1, hp = 20, level = 20 } }
|
|
towerFunc(ctx, Tower.FUNC.START_CHALLENGE, Tower.CHALLENGE_TYPE.SINGLE)
|
|
-- pokefirered/include/constants/battle.h:76
|
|
eq(towerFunc(ctx, Tower.FUNC.DO_BATTLE), 2,
|
|
"with no floor trainers in the cache DoTrainerTowerBattle reports B_OUTCOME_LOST")
|
|
eq(Tower.record(session).floorsCleared, 0, "a lost battle cleared no floor")
|
|
eq(Tower.record(session).bestTime, Tower.MAX_TIME, "a lost battle wrote no record")
|
|
check(TowerNatives._logged["DoTrainerTowerBattle has no floor trainers in this cache"] == true,
|
|
"the missing gTrainerTowerFloors table is what stopped the battle")
|
|
session.party = {}
|
|
local Screen = require("src.ui.game3.trainer_tower_records")
|
|
-- pokefirered/asm/macros/trainer_tower.inc:96 ttower_showresults
|
|
towerFunc(ctx, Tower.FUNC.SHOW_RESULTS)
|
|
check(Screen.isOpen(), "ShowResultsBoard opened the results board")
|
|
eq(getVar(ctx, 0x4001), Screen.BOARD_WINDOW_ID,
|
|
"the board window id is in VAR_TEMP_1 (pokefirered/src/trainer_tower.c:921)")
|
|
eq(Screen.kind(), "board", "the unused board is not the full screen records scene")
|
|
towerFunc(ctx, Tower.FUNC.CLOSE_RESULTS)
|
|
check(not Screen.isOpen(),
|
|
"CloseResultsBoard read VAR_TEMP_1 back and closed it (pokefirered/src/trainer_tower.c:926)")
|
|
Task.clear()
|
|
|
|
print("[test] 13. pokefirered/src/trainer_tower.c:960 the encounter song leaves the floor song alone")
|
|
do
|
|
local realAudio = package.loaded["src.core.game3.audio"]
|
|
local calls = {}
|
|
local stubAudio = {
|
|
_mapSong = 300,
|
|
playSong = function(id) calls[#calls + 1] = { "playSong", id } end,
|
|
}
|
|
stubAudio.playMapSong = function(id)
|
|
calls[#calls + 1] = { "playMapSong", id }
|
|
stubAudio._mapSong = id
|
|
end
|
|
package.loaded["src.core.game3.audio"] = stubAudio
|
|
towerFunc(ctx, Tower.FUNC.ENCOUNTER_MUSIC)
|
|
package.loaded["src.core.game3.audio"] = realAudio
|
|
eq(calls[1] and calls[1][1], "playSong", "PlayNewMapMusic goes through Audio.playSong")
|
|
eq(calls[1] and calls[1][2], Tower.MUS_ENCOUNTER_BOY, "with no floor trainers it plays MUS_ENCOUNTER_BOY")
|
|
eq(stubAudio._mapSong, 300, "the location song BattleBridge restores after the battle is untouched")
|
|
end
|
|
|
|
if failed > 0 then
|
|
print(string.format("[FAIL] %d check(s) failed", failed))
|
|
os.exit(1)
|
|
end
|
|
print("[pass] trainer tower state")
|