Files
gen1recomp/tests/game3_tower_party_test.lua
bryanthaboi dee935a4ea CLOSES #2430
2026-09-23 11:58:32 -04:00

494 lines
20 KiB
Lua

#!/usr/bin/env luajit
-- pokefirered/src/load_save.c:160, src/script_pokemon_util.c:152, src/trainer_tower.c:733
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 store = Flags.newStore()
local keyText = setmetatable({}, {
__index = function(_, key) return require("src.core.game3.scripting.text_ir").fromAscii(key) end,
})
package.loaded["src.core.game3.scripting.space"] = {
store = store,
ensureBundle = function() return { text = keyText } 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 mon(species, level, opts)
opts = opts or {}
return {
species = species,
speciesId = species,
level = level,
hp = opts.hp or (level * 2),
maxHp = level * 2,
moves = { 33 },
pp = { 35 },
maxPp = { 35 },
isEgg = opts.isEgg or nil,
heldItem = opts.heldItem,
nickname = opts.nickname,
}
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
local function special(ctx, id, adapters)
return Ops.dispatch({ ctx = ctx, store = store, adapters = adapters or { log = function() end },
setPc = function() end }, { op = "special", id = id })
end
-- pokefirered/asm/macros/trainer_tower.inc:2
local function towerFunc(ctx, index, adapters, temp1)
setVar(ctx, 0x8004, index)
if temp1 ~= nil then setVar(ctx, 0x4001, temp1) end
special(ctx, Std.SPECIAL.CallTrainerTowerFunc, adapters)
return getVar(ctx, 0x800D)
end
-- pokefirered/src/trainer_tower_sets.c:8956
local function towerMon(species, moveId)
return {
species = species,
heldItem = 0,
moves = { moveId, 0, 0, 0 },
level = 1,
ppBonuses = 0,
hpEV = 0, attackEV = 0, defenseEV = 0, speedEV = 0, spAttackEV = 0, spDefenseEV = 0,
otId = 0,
hpIV = 8, attackIV = 8, defenseIV = 8, speedIV = 8, spAttackIV = 8, spDefenseIV = 8,
abilityNum = 0,
personality = 7,
nickname = "",
friendship = 0,
}
end
local function towerTrainer(name, base)
local mons = {}
for i = 1, 6 do mons[i] = towerMon(base + i, 30 + i) end
return { name = name, facilityClass = 44, textColor = 0, mons = mons }
end
-- pokefirered/src/trainer_tower.c:529 gTrainerTowerFloors
local function installFloorPack()
local floors = {}
for mode = 0, Tower.NUM_CHALLENGE_TYPES - 1 do
local rows = {}
for i = 1, Tower.MAX_FLOORS do
rows[i] = {
id = i,
floorIdx = Tower.MAX_FLOORS,
challengeType = (mode == Tower.CHALLENGE_TYPE.MIXED) and Tower.CHALLENGE_TYPE.SINGLE or mode,
prize = 0,
trainers = {
towerTrainer("ALPHA", 100),
towerTrainer("BETA", 200),
towerTrainer("GAMMA", 300),
},
}
end
floors[mode] = rows
end
Tower.setPack({ header = { numFloors = Tower.MAX_FLOORS, id = 1 }, floors = floors })
end
print("[test] 1. the five party and special-battle specials are bound with pret's ids")
-- pokefirered/data/specials.inc:50
eq(Std.SPECIAL.SavePlayerParty, 0x27, "SavePlayerParty")
eq(Std.SPECIAL.LoadPlayerParty, 0x28, "LoadPlayerParty")
eq(Std.SPECIAL.ChooseHalfPartyForBattle, 0x29, "ChooseHalfPartyForBattle")
-- pokefirered/data/specials.inc:247
eq(Std.SPECIAL.StartSpecialBattle, 0xEC, "StartSpecialBattle")
-- pokefirered/data/specials.inc:259
eq(Std.SPECIAL.ReducePlayerPartyToThree, 0xF8, "ReducePlayerPartyToThree")
for _, name in ipairs({ "SavePlayerParty", "LoadPlayerParty", "ChooseHalfPartyForBattle",
"StartSpecialBattle", "ReducePlayerPartyToThree" }) do
check(Natives.ALLOW["special:" .. Std.SPECIAL[name]] ~= nil, name .. " reaches a handler")
end
print("[test] 2. SavePlayerParty stashes all six slots and LoadPlayerParty gives them back")
local session = newSession("FR_SEVEN_ISLAND_HOUSE_ROOM1")
local ctx = newCtx()
for i = 1, 6 do session.party[i] = mon(i, 10 + i, { nickname = "SLOT" .. i }) end
special(ctx, Std.SPECIAL.SavePlayerParty)
eq(#session.party, 6, "the live party is untouched by the stash")
session.party[1].hp = 0
session.party[6] = nil
session.party[5] = nil
eq(#session.party, 4, "the party was cut down after the stash")
special(ctx, Std.SPECIAL.LoadPlayerParty)
eq(#session.party, 6, "LoadPlayerParty restored six slots")
eq(session.party[6].nickname, "SLOT6", "slot 6 came back")
eq(session.party[1].hp, 11 * 2, "the stashed copy was not aliased to the live mon")
session.party[3].hp = 1
eq((Tower.savedPlayerParty(session) or {})[3].hp, 13 * 2,
"editing the live party does not edit the stash")
print("[test] 3. the stash survives a save and a reload mid-flow")
local Schema = require("src.core.game3.save_schema_firered")
local saved = Schema.toSaveTable(session)
local reloaded = useSession(Schema.fromSaveTable(saved))
reloaded.party = { mon(25, 5) }
ctx = newCtx()
special(ctx, Std.SPECIAL.LoadPlayerParty)
eq(#reloaded.party, 6, "the reloaded save still restores the six stashed mons")
eq(reloaded.party[4].nickname, "SLOT4", "the stashed nicknames survived the save")
print("[test] 4. ChooseHalfPartyForBattle offers only battle-eligible mons")
session = newSession("FR_SEVEN_ISLAND_HOUSE_ROOM1")
ctx = newCtx()
session.party = {
mon(1, 20),
mon(4, 20, { isEgg = true }),
mon(7, 20, { hp = 0 }),
mon(25, 20),
mon(133, 20),
}
local opts = TowerNatives.chooseOptions()
-- pokefirered/include/constants/party_menu.h:59
eq(opts.menuType, 4, "PARTY_MENU_TYPE_CHOOSE_MULTIPLE_MONS")
eq(opts.count, 3, "the picker asks for three slots")
eq(opts.mode, "choose_multi", "the picker runs in multi select mode")
-- pokefirered/include/constants/party_menu.h:133
eq(opts.chooseMonsBattleType, 0, "CHOOSE_MONS_FOR_CABLE_CLUB_BATTLE")
eq(opts.eligible, nil, "the eligibility rule is the menu's own, not a list the tower publishes")
local PartyMenu = require("src.ui.game3.party_menu")
opts.session = session
opts.onClose = function() end
PartyMenu.show(session.party, nil, opts)
-- pokefirered/src/party_menu.c:5674 GetBattleEntryEligibility
local function firstAction(slot) return (PartyMenu.multiActions(slot) or {})[1] end
eq(firstAction(1), "ENTER", "slot 1 can enter")
eq(firstAction(2), "SUMMARY", "the egg cannot enter")
eq(firstAction(3), "SUMMARY", "the fainted mon cannot enter")
eq(firstAction(4), "ENTER", "slot 4 can enter")
eq(firstAction(5), "ENTER", "slot 5 can enter")
PartyMenu.close()
print("[test] 5. a multi select pick fills gSelectedOrderFromParty in pick order")
local passedOpts = nil
local adaptersPick = {
log = function() end,
chooseParty = function(o, done)
passedOpts = o
done({ 5, 1 })
end,
}
special(ctx, Std.SPECIAL.ChooseHalfPartyForBattle, adaptersPick)
check(passedOpts ~= nil and passedOpts.count == 3, "the seam was called with the count option")
local order = Tower.selectedOrder(session)
eq(order[1], 5, "first pick is slot 5")
eq(order[2], 1, "second pick is slot 1")
eq(order[3], 0, "the third slot stays empty")
eq(getVar(ctx, 0x800D), 1, "VAR_RESULT = TRUE when the player confirmed")
print("[test] 6. ReducePlayerPartyToThree keeps the picked mons in the picked order")
special(ctx, Std.SPECIAL.SavePlayerParty)
special(ctx, Std.SPECIAL.ReducePlayerPartyToThree)
eq(#session.party, 2, "two picks leave a two mon party")
eq(session.party[1].species, 133, "the first pick leads")
eq(session.party[2].species, 1, "the second pick follows")
special(ctx, Std.SPECIAL.LoadPlayerParty)
eq(#session.party, 5, "LoadPlayerParty gave the whole party back")
eq(session.party[2].isEgg, true, "the egg came back with it")
print("[test] 7. cancelling the picker sets VAR_RESULT = FALSE and picks nothing")
ctx = newCtx()
special(ctx, Std.SPECIAL.ChooseHalfPartyForBattle,
{ log = function() end, chooseParty = function(_, done) done(nil) end })
eq(getVar(ctx, 0x800D), 0, "VAR_RESULT = FALSE on cancel")
eq(Tower.selectedOrder(session)[1], 0, "the order was cleared")
special(ctx, Std.SPECIAL.ReducePlayerPartyToThree)
eq(#session.party, 0, "pret empties the party when no slot was chosen")
special(ctx, Std.SPECIAL.LoadPlayerParty)
eq(#session.party, 5, "and the stash puts it back")
print("[test] 8. a single select host still yields a legal one mon order")
ctx = newCtx()
special(ctx, Std.SPECIAL.ChooseHalfPartyForBattle,
{ log = function() end, chooseParty = function(_, done) done(3) end })
eq(Tower.selectedOrder(session)[1], 4, "slot 4 (0 based 3) became the only pick")
eq(getVar(ctx, 0x800D), 1, "VAR_RESULT = TRUE for a one mon pick")
print("[test] 9. an ineligible pick is refused the way the menu refuses it")
ctx = newCtx()
special(ctx, Std.SPECIAL.ChooseHalfPartyForBattle,
{ log = function() end, chooseParty = function(_, done) done({ 2, 3 }) end })
eq(Tower.selectedOrder(session)[1], 0, "the egg and the fainted mon were dropped")
eq(getVar(ctx, 0x800D), 0, "VAR_RESULT = FALSE when nothing eligible was picked")
print("[test] 10. the picker blocks the script until the host closes it")
ctx = newCtx()
local pending = nil
local yielded = special(ctx, Std.SPECIAL.ChooseHalfPartyForBattle,
{ log = function() end, chooseParty = function(_, done) pending = done end })
check(yielded, "the special yielded while the party menu is open")
eq(ctx.status, "waiting", "the script is waiting")
eq(getVar(ctx, 0x800D), 0, "VAR_RESULT is not written before the pick")
pending({ 1 })
check(ctx.nativePoll(), "the poll finished once the host closed the menu")
eq(getVar(ctx, 0x800D), 1, "VAR_RESULT was written on close")
print("[test] 11. DoTrainerTowerBattle builds the floor party pret builds")
installFloorPack()
session = newSession("FR_TRAINER_TOWER_1F")
ctx = newCtx()
Task.clear()
session.party = { mon(1, 24), mon(4, 31), mon(7, 18) }
setVar(ctx, 0x8005, Tower.CHALLENGE_TYPE.SINGLE)
towerFunc(ctx, Tower.FUNC.START_CHALLENGE, { log = function() end })
local seenFoe, seenOpts = nil, nil
local battleAdapters = {
log = function() end,
startTrainerBattle = function(foe, done, o)
seenFoe, seenOpts = foe, o
done("win")
end,
}
eq(towerFunc(ctx, Tower.FUNC.DO_BATTLE, battleAdapters, 0), 1,
"a won tower battle reports B_OUTCOME_WON")
check(seenFoe ~= nil, "the battle reached the host seam")
eq(#seenFoe.party, 2, "a singles floor sends two mons")
-- pokefirered/src/trainer_tower.c:401 sSingleBattleChallengeMonIdxs[0] = {0, 2}
eq(seenFoe.party[1].species, 101, "floor 1 mon index 0")
eq(seenFoe.party[2].species, 103, "floor 1 mon index 2")
-- pokefirered/src/trainer_tower.c:1026 GetPartyMaxLevel
eq(seenFoe.party[1].level, 31, "the floor mons match the player's top level")
eq(seenFoe.party[2].level, 31, "both of them")
eq(seenOpts.double, false, "a singles floor is not a double battle")
eq(seenOpts.trainerId, 0, "gTrainerBattleOpponent_A = 0")
-- pokefirered/src/trainer_tower.c:717 CB2_EndTrainerTowerBattle
eq(seenOpts.noWhiteout, true, "a tower loss goes through the script, not the white out")
eq(seenOpts.trainerTower, true, "the battle is flagged BATTLE_TYPE_TRAINER_TOWER")
print("[test] 12. the knockout and doubles floors pick the mons pret picks")
session = newSession("FR_TRAINER_TOWER_3F")
ctx = newCtx()
session.party = { mon(1, 40) }
setVar(ctx, 0x8005, Tower.CHALLENGE_TYPE.KNOCKOUT)
towerFunc(ctx, Tower.FUNC.START_CHALLENGE, { log = function() end })
towerFunc(ctx, Tower.FUNC.CLEARED_FLOOR, { log = function() end })
towerFunc(ctx, Tower.FUNC.CLEARED_FLOOR, { log = function() end })
towerFunc(ctx, Tower.FUNC.DO_BATTLE, battleAdapters, 1)
eq(#seenFoe.party, 1, "a knockout battle is one mon")
-- pokefirered/src/trainer_tower.c:427 sKnockoutChallengeMonIdxs[2] = {2, 3, 1}
eq(seenFoe.party[1].species, 204, "floor 3, trainer 2, mon index 3")
session = newSession("FR_TRAINER_TOWER_2F")
ctx = newCtx()
session.party = { mon(1, 40), mon(4, 12) }
setVar(ctx, 0x8005, Tower.CHALLENGE_TYPE.DOUBLE)
towerFunc(ctx, Tower.FUNC.START_CHALLENGE, { log = function() end })
towerFunc(ctx, Tower.FUNC.CLEARED_FLOOR, { log = function() end })
towerFunc(ctx, Tower.FUNC.DO_BATTLE, battleAdapters, 0)
eq(#seenFoe.party, 2, "a doubles floor sends one mon from each trainer")
-- pokefirered/src/trainer_tower.c:414 sDoubleBattleChallengeMonIdxs[1] = {1, 3}
eq(seenFoe.party[1].species, 102, "trainer 1 mon index 1")
eq(seenFoe.party[2].species, 204, "trainer 2 mon index 3")
eq(seenOpts.double, true, "a doubles floor is a double battle")
print("[test] 13. a lost tower battle routes B_OUTCOME_LOST back to the script")
session = newSession("FR_TRAINER_TOWER_1F")
ctx = newCtx()
session.party = { mon(1, 20) }
setVar(ctx, 0x8005, Tower.CHALLENGE_TYPE.SINGLE)
towerFunc(ctx, Tower.FUNC.START_CHALLENGE, { log = function() end })
eq(towerFunc(ctx, Tower.FUNC.DO_BATTLE, {
log = function() end,
startTrainerBattle = function(_, done) done("lose") end,
}, 0), 2, "a lost tower battle reports B_OUTCOME_LOST")
eq(Tower.record(session).floorsCleared, 0, "a loss clears no floor")
print("[test] 14. the battle blocks the script until the battle ends")
ctx = newCtx()
local finishBattle = nil
yielded = towerFunc(ctx, Tower.FUNC.DO_BATTLE, {
log = function() end,
startTrainerBattle = function(_, done) finishBattle = done end,
}, 0)
eq(ctx.status, "waiting", "ttower_dobattle waits on the battle")
finishBattle("win")
check(ctx.nativePoll(), "the poll finished with the battle")
eq(getVar(ctx, 0x800D), 1, "VAR_RESULT = B_OUTCOME_WON after the battle")
print("[test] 15. with no floor data in the cache the tower loses instead of cheating")
Tower.setPack(nil)
TowerNatives._logged = {}
session = newSession("FR_TRAINER_TOWER_1F")
ctx = newCtx()
session.party = { mon(1, 20) }
setVar(ctx, 0x8005, Tower.CHALLENGE_TYPE.SINGLE)
towerFunc(ctx, Tower.FUNC.START_CHALLENGE, { log = function() end })
local reached = false
eq(towerFunc(ctx, Tower.FUNC.DO_BATTLE, {
log = function() end,
startTrainerBattle = function() reached = true end,
}, 0), 2, "no floor trainers reports B_OUTCOME_LOST")
check(not reached, "and starts no battle at all")
check(TowerNatives._logged["DoTrainerTowerBattle has no floor trainers in this cache"] == true,
"the missing gTrainerTowerFloors table is logged once")
print("[test] 16. StartSpecialBattle fights the e-reader trainer when the save holds one")
session = newSession("FR_SEVEN_ISLAND_HOUSE_ROOM2")
ctx = newCtx()
session.party = { mon(1, 30) }
TowerNatives._logged = {}
-- pokefirered/data/maps/SevenIsland_House_Room1/scripts.inc:9
special(ctx, Std.SPECIAL.ValidateEReaderTrainer, { log = function() end })
eq(getVar(ctx, 0x800D), 1, "a clean save has no e-reader trainer, so the door stays shut")
session.ereaderTrainer = { name = "EMPTY" }
special(ctx, Std.SPECIAL.ValidateEReaderTrainer, { log = function() end })
eq(getVar(ctx, 0x800D), 1, "a record with no party is an all-zero record")
eq(session.ereaderTrainer, nil, "and ClearEReaderTrainer wiped it")
setVar(ctx, 0x8004, 2)
special(ctx, Std.SPECIAL.StartSpecialBattle, { log = function() end })
eq(getVar(ctx, 0x800D), 2,
"an empty e-reader slot reports B_OUTCOME_LOST instead of hanging on waitstate")
check(TowerNatives._logged["StartSpecialBattle 2 has no opponent data in this save"] == true,
"the empty e-reader slot is logged once")
session.ereaderTrainer = {
name = "READER",
facilityClass = 44,
party = { towerMon(150, 94), towerMon(151, 60), towerMon(144, 58) },
}
seenFoe, seenOpts = nil, nil
ctx = newCtx()
setVar(ctx, 0x8004, 2)
special(ctx, Std.SPECIAL.StartSpecialBattle, battleAdapters)
check(seenFoe ~= nil, "the e-reader battle reached the host seam")
eq(#seenFoe.party, 3, "an e-reader trainer brings three mons")
eq(seenFoe.party[1].species, 150, "the first e-reader mon")
eq(seenFoe.party[1].level, 1, "e-reader mons keep their own level")
eq(seenOpts.eReader, true, "BATTLE_TYPE_EREADER_TRAINER")
eq(getVar(ctx, 0x800D), 1, "the outcome reached VAR_RESULT")
ctx = newCtx()
special(ctx, Std.SPECIAL.ValidateEReaderTrainer, { log = function() end })
eq(getVar(ctx, 0x800D), 0, "a valid card record opens the Seven Island door")
print("[test] 17. the secret base case copies the held items into the stash first")
session = newSession("FR_PALLET_TOWN")
ctx = newCtx()
session.party = { mon(1, 10, { heldItem = 0 }), mon(4, 10, { heldItem = 0 }) }
special(ctx, Std.SPECIAL.SavePlayerParty)
session.party[1].heldItem = 172
session.party[2].heldItem = 173
setVar(ctx, 0x8004, 1)
special(ctx, Std.SPECIAL.StartSpecialBattle, { log = function() end })
-- pokefirered/src/battle_tower.c:917
eq((Tower.savedPlayerParty(session) or {})[1].heldItem, 172, "slot 1 held item reached the stash")
eq((Tower.savedPlayerParty(session) or {})[2].heldItem, 173, "slot 2 held item reached the stash")
eq(getVar(ctx, 0x800D), 2, "and with no secret base trainer the battle reports B_OUTCOME_LOST")
print("[test] 18. the tower flag survives the adapters and the bridge and reaches the engine")
local captured = nil
package.loaded["src.core.game3.battle_bridge"] = nil
local realBridge = require("src.core.game3.battle_bridge")
local bridgeStart = realBridge.start
realBridge.start = function(_, _, _, o) captured = o end
local Adapters = require("src.core.game3.scripting.adapters")
local host = Adapters.host({}, { session = session }, {})
-- pokefirered/src/trainer_tower.c:735 BATTLE_TYPE_TRAINER_TOWER
host.startTrainerBattle({ party = {}, trainerId = 0, trainerName = "ALBERTO", trainerPicId = 44 },
function() end, { trainerId = 0, trainerTower = true, noWhiteout = true })
realBridge.start = bridgeStart
check(captured ~= nil, "the tower battle reached BattleBridge.start")
eq(captured and captured.trainerTower, true, "adapters.startTrainerBattle forwards trainerTower")
eq(captured and captured.trainerName, "ALBERTO", "and the tower opponent's name")
eq(captured and captured.trainerPicId, 44, "and its facility-class pic")
if not require("tests.game3_cache").mount() then
print("[skip] the battle intro names gTrainers[0] from the ROM trainer pack: "
.. tostring(require("tests.game3_cache").reason))
Task.clear()
Tower.resetPack()
os.exit(failed > 0 and 1 or 0)
end
local Battle = require("src.core.game3.battle")
Tower.resetPack()
-- include/constants/trainers.h:382 FACILITY_CLASS_SAILOR
local towerMons = { { species = 129, level = 30, hp = 40, maxHp = 40, moves = { 33 }, pp = { 35 }, trainerClass = 91 } }
Battle.start({
headless = true,
wild = false,
trainerId = 0,
trainerTower = true,
trainerName = "ALBERTO",
trainerPicId = 44,
playerParty = { { species = 1, level = 30, hp = 50, maxHp = 50, moves = { 33 }, pp = { 35 } } },
foe = towerMons[1],
foeParty = towerMons,
})
local bst = Battle.getState()
check(bst ~= nil and bst.trainerTower == true, "battle/init.lua kept BATTLE_TYPE_TRAINER_TOWER")
eq(bst and bst.trainerName, "ALBERTO", "the battle shows the tower trainer, not gTrainers[0]")
eq(bst and bst.trainerClassName, nil, "and no gTrainers[0] class name")
-- src/trainer_tower.c:447, include/constants/trainers.h:243 TRAINER_CLASS_SAILOR
eq(bst and bst.trainerClass, 60, "gFacilityClassToTrainerClass maps the floor trainer's facility class")
eq(bst and bst.trainerPicId, 44, "and the facility-class front sprite")
local BattleBg = require("src.core.game3.battle.bg")
-- pokefirered/src/battle_bg.c:1048 GetBattleTerrainOverride
eq(BattleBg.terrainId(), BattleBg.TERRAIN.LINK, "a tower battle uses TERRAIN_LINK")
if Battle.endBattle then pcall(Battle.endBattle) end
Task.clear()
Tower.resetPack()
if failed > 0 then
print(string.format("[FAIL] %d check(s) failed", failed))
os.exit(1)
end
print("[pass] trainer tower party and battles")