Files
gen1recomp/tests/gen2_trainerhouse_test.lua
T
bryanthaboi ae6cac89e1 G2 support
2026-08-11 11:53:30 -04:00

304 lines
13 KiB
Lua

-- The Viridian Trainer House (maps/TrainerHouseB1F.asm, plus the CAL arms of
-- engine/battle/read_trainer_party.asm). ROM-free for the fixture half; the
-- second half runs the map's own extracted script and needs a cache:
-- GOLD_CACHE="$HOME/Library/Application Support/LOVE/gold-dev/gold" \
-- luajit tests/gen2_trainerhouse_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 trainer house")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local Apricorns = require("src.core.gen2.Apricorns")
local Events = require("src.world.gen2.Events")
local Specials = require("src.script.gen2.Specials")
local TrainerHouse = require("src.world.gen2.TrainerHouse")
local Trainers = require("src.world.gen2.Trainers")
local Vm = require("src.script.gen2.Vm")
-- ---------------------------------------------------------------------------
-- sMysteryGiftTrainerHouseFlag, and the three routines that read it
-- ---------------------------------------------------------------------------
eq(TrainerHouse.CAL, 12, "CAL is trainer class 12")
eq(TrainerHouse.CAL2, 2, "CAL2 is the Mystery Gift visitor")
eq(TrainerHouse.CAL3, 3, "CAL3 is the house's own trainer")
do
local empty = {}
check(not TrainerHouse.hasCustomTrainer(empty),
"with no Mystery Gift record the flag reads clear")
check(not TrainerHouse.hasCustomTrainer(nil),
"and a nil save is the same answer rather than an error")
-- ReadTrainerParty's `.cal2` arm: the only redirect, and only for CAL2.
eq(TrainerHouse.resolveMember(empty, TrainerHouse.CAL, TrainerHouse.CAL2),
TrainerHouse.CAL3, "an unlinked cart answers a CAL2 lookup with CAL3")
eq(TrainerHouse.resolveMember(empty, TrainerHouse.CAL, TrainerHouse.CAL1),
TrainerHouse.CAL1, "CAL1 (Route 27) is left alone")
eq(TrainerHouse.resolveMember(empty, TrainerHouse.CAL, TrainerHouse.CAL3),
TrainerHouse.CAL3, "CAL3 is left alone")
eq(TrainerHouse.resolveMember(empty, 13, 2), 2,
"and member 2 of any OTHER class is left alone: the cart tests the "
.. "class first")
-- GetTrainerName's CAL arm falls through to the table when the flag is
-- clear, which is what nil means here.
eq(TrainerHouse.customName(empty, TrainerHouse.CAL), nil,
"no custom name without a Mystery Gift partner")
eq(TrainerHouse.customName(empty, 13), nil, "and never for another class")
-- The one shape that would come back the day Mystery Gift lands.
local gifted = { mysteryGift = { trainerHouse = true, partnerName = "KRIS" } }
check(TrainerHouse.hasCustomTrainer(gifted), "a stored trade sets the flag")
eq(TrainerHouse.resolveMember(gifted, TrainerHouse.CAL, TrainerHouse.CAL2),
TrainerHouse.CAL2,
"and then CAL2 is NOT redirected: the party comes from the trade")
eq(TrainerHouse.customName(gifted, TrainerHouse.CAL), "KRIS",
"sMysteryGiftPartnerName is the name")
end
-- `special TrainerHouse` is that same byte, so the two cannot disagree.
do
local vm = { scriptVar = 7, specials = { save = function() return {} end } }
Specials.ALL.TrainerHouse(vm)
eq(vm.scriptVar, 0, "unlinked: wScriptVar FALSE, so the script picks CAL3")
local gifted = { mysteryGift = { trainerHouse = true } }
local vm2 = { scriptVar = 0, specials = { save = function() return gifted end } }
Specials.ALL.TrainerHouse(vm2)
eq(vm2.scriptVar, 1, "with a trade stored it answers TRUE, the CAL2 arm")
local bare = { scriptVar = 3, specials = {} }
Specials.ALL.TrainerHouse(bare)
eq(bare.scriptVar, 0,
"and with no save hook it still answers FALSE rather than leaving a "
.. "stale wScriptVar to pick the arm")
end
-- ---------------------------------------------------------------------------
-- The once-a-day gate
-- ---------------------------------------------------------------------------
do
eq(TrainerHouse.ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY, 86,
"ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY is engine flag 86")
local found
for _, row in ipairs(Apricorns.DAILY_ENGINE_FLAGS) do
if row.id == TrainerHouse.ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY then
found = row
end
end
check(found ~= nil, "and it is one of the wDailyFlags bits")
eq(found and found.name, "ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY",
"under that name")
-- CheckDailyResetTimer wipes both daily bytes whole, which is the ONLY
-- thing that lets you back into the hall.
local save = { engineFlags = { [86] = true } }
check(TrainerHouse.foughtToday(save), "the flag reads back set")
Apricorns.dailyReset(save)
check(not TrainerHouse.foughtToday(save),
"and the daily rollover is what clears it")
end
-- ---------------------------------------------------------------------------
-- The map's own script, out of a real cache
-- ---------------------------------------------------------------------------
local cache = os.getenv("GOLD_CACHE")
if not cache then
local home = os.getenv("HOME") or ""
cache = home .. "/Library/Application Support/LOVE/gold-dev/gold"
end
local mapsPath = cache .. "/data/generated/maps.lua"
local mf = io.open(mapsPath, "r")
if not mf then
check(true, "gold cache absent : fixture checks only (SKIP cache facts)")
S.finish()
return
end
mf:close()
local maps = assert(loadfile(mapsPath))()
local scripts = assert(loadfile(cache .. "/data/generated/scripts.lua"))()
local text = assert(loadfile(cache .. "/data/generated/text.lua"))()
local constants = assert(loadfile(cache .. "/data/generated/constants.lua"))()
local trainerData = assert(loadfile(cache .. "/data/generated/trainers.lua"))()
-- CAL's three rows. CAL (2) is dead data on the cart -- ReadTrainerParty
-- branches into SRAM before it indexes the table -- and the redirect above is
-- what stops the port fielding it.
do
local cal = trainerData.classes and trainerData.classes.CAL
check(cal ~= nil, "the cache carries the CAL class")
eq(cal and cal.index, TrainerHouse.CAL, "at class index 12")
local three = Trainers.lookup(trainerData, TrainerHouse.CAL,
TrainerHouse.CAL3)
check(three ~= nil, "CAL3 is in the table")
eq(three and three.name, "CAL", "named CAL")
local roster = {}
for _, row in ipairs((three and three.roster) or {}) do
roster[#roster + 1] = string.format("%s@%d", tostring(row.species),
row.level)
end
eq(table.concat(roster, " "), "MEGANIUM@50 TYPHLOSION@50 FERALIGATR@50",
"the default opponent is the three fully evolved starters at 50")
local redirected = TrainerHouse.lookup(trainerData, {}, TrainerHouse.CAL,
TrainerHouse.CAL2)
eq(redirected and redirected.id, "CAL3",
"and a CAL2 lookup lands on that same row rather than on the level 30 "
.. "one no cartridge fields")
eq(TrainerHouse.name(trainerData, {}, TrainerHouse.CAL, TrainerHouse.CAL2),
"CAL", "gettrainername answers CAL either way")
end
local map = maps.TRAINER_HOUSE_B1F
check(map ~= nil, "the cache carries TRAINER_HOUSE_B1F")
-- coord_event 7, 3, SCENE_TRAINERHOUSEB1F_ASK_BATTLE: the doorway cell, not an
-- object, which is why the receptionist is never talked to.
do
local ce = (map.coordEvents or {})[1]
check(ce ~= nil, "one coord event")
eq(ce and ce.x, 7, "at x 7")
eq(ce and ce.y, 3, "y 3")
eq(ce and ce.sceneId, 0, "on SCENE_TRAINERHOUSEB1F_ASK_BATTLE")
check(ce and ce.scriptKey and scripts[ce.scriptKey] ~= nil,
"and it names a script body the cache carries")
-- object_const_def is const_def 2, so TRAINERHOUSEB1F_CHRIS is script id 3
-- and the second extracted object.
eq(#(map.objects or {}), 2, "two objects: the receptionist and CAL")
eq(map.objects[2].sprite, "SPRITE_CHRIS",
"the second is the one setlasttalked 3 names")
eq(#(map.warps or {}), 1, "one warp, back up to the ground floor")
eq(map.warps[1].destMap, "TRAINER_HOUSE_1F", "to TRAINER_HOUSE_1F")
end
-- Run the receptionist's script for each of its three arms. The hooks are a
-- log rather than a World, so this checks the SCRIPT against the port's
-- commands: the wiring is World's own (World:trainerParty, and the coord event
-- World:checkCoordEvents takes on the step onto the cell).
local function runScript(opts)
opts = opts or {}
local save = { engineFlags = {} }
if opts.foughtToday then
save.engineFlags[TrainerHouse.ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY] = true
end
if opts.gift then save.mysteryGift = { trainerHouse = true } end
local log, moves, battles = {}, {}, {}
local vm = Vm.new(scripts, text, Events.new(), {
specialOrder = constants.specialOrder,
specials = { save = function() return save end },
showText = function(body, onDone)
log[#log + 1] = body
onDone()
end,
yesorno = function(onChoose) onChoose(opts.accept and true or false) end,
applyMovement = function(object, bytes, onDone)
moves[#moves + 1] = { object = object, steps = #bytes }
onDone()
end,
turnObject = function(object, facing)
log.turned = { object = object, facing = facing }
end,
getEngineFlag = function(flag) return save.engineFlags[flag] end,
setEngineFlag = function(flag, value)
save.engineFlags[flag] = value or nil
end,
getTrainerName = function(class, member)
return TrainerHouse.name(trainerData, save, class, member)
end,
lookupTrainer = function(class, member)
return TrainerHouse.lookup(trainerData, save, class, member)
end,
startBattle = function(trainer, _wild, onDone)
battles[#battles + 1] = trainer
onDone(opts.lose and "lose" or "win")
end,
reloadMap = function() log.reloaded = (log.reloaded or 0) + 1 end,
})
local started = vm:start(map.coordEvents[1].scriptKey)
return {
started = started, busy = vm.busy, log = log, moves = moves,
battles = battles, save = save,
}
end
local function saw(log, needle)
for _, body in ipairs(log) do
if body:find(needle, 1, true) then return true end
end
return false
end
do -- the first visit of the day, accepted
local run = runScript({ accept = true })
check(run.started, "the script starts")
check(not run.busy, "and runs to completion without parking on a command")
eq(run.log.turned and run.log.turned.facing, "up",
"turnobject PLAYER, UP squares the player up to the desk")
check(saw(run.log, "TRAINING HALL"), "the welcome")
check(saw(run.log, "is your"), "the {STRBUF} opponent line")
eq(run.log[2], "CAL is your\nopponent today.",
"and gettrainername filled it with CAL")
check(saw(run.log, "Would you like to"), "the question")
check(saw(run.log, "Please go right"), "the go-ahead")
check(saw(run.log, "I traveled out"), "CAL's own line inside the room")
check(run.save.engineFlags[86] == true,
"setflag ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY is taken")
eq(#run.battles, 1, "exactly one battle")
eq(run.battles[1] and run.battles[1].id, "CAL3", "against CAL3")
eq(#run.moves, 2, "walked in and walked back out")
eq(run.moves[1].steps, 14, "Movement_EnterTrainerHouseBattleRoom")
eq(run.moves[2].steps, 14, "Movement_ExitTrainerHouseBattleRoom")
eq(run.log.reloaded, 1, "reloadmapafterbattle, once")
end
do -- with a Mystery Gift trade stored, the CAL2 arm is the one taken
local run = runScript({ accept = true, gift = true })
eq(#run.battles, 1, "still one battle")
eq(run.battles[1] and run.battles[1].id, "CAL2",
"special TrainerHouse TRUE routes the script to CAL2")
end
do -- declined
local run = runScript({ accept = false })
check(saw(run.log, "Sorry. Only those"), "the polite refusal")
eq(#run.battles, 0, "no battle")
check(run.save.engineFlags[86] == nil,
"and the daily flag is NOT set: a declined offer costs you nothing")
eq(#run.moves, 1, "one movement, the step back off the cell")
eq(run.moves[1].steps, 3, "Movement_TrainerHouseTurnBack")
end
do -- already fought today
local run = runScript({ accept = true, foughtToday = true })
check(saw(run.log, "second time today"), "the second-challenge refusal")
eq(#run.log, 1, "and nothing else is said")
eq(#run.battles, 0, "no battle")
eq(#run.moves, 1, "just the step back off the cell")
eq(run.moves[1].steps, 3, "Movement_TrainerHouseTurnBack")
end
do -- losing ends the run, and ends the SCRIPT, before the walk back out
local run = runScript({ accept = true, lose = true })
eq(#run.battles, 1,
"a loss takes reloadmapafterbattle's iffalse arm: one battle, not two")
-- One movement, not two: the walk in, and nothing after it.
--
-- This expected 2 while a lost battle still fell through
-- `reloadmapafterbattle` and ran the rest of the winner's script -- the bug
-- tests/gen2_battle_loss_test.lua now pins. On the cart that command reads
-- wBattleResult and, on LOSE, does `jp ScriptJump` into Script_BattleWhiteout
-- (engine/overworld/scripting.asm:1080); it never returns, so TrainerHouseB1F
-- never reaches `.End: applymovement PLAYER,
-- Movement_ExitTrainerHouseBattleRoom`. You wake up in a Pokecenter, not
-- politely walked back out of the battle room.
eq(#run.moves, 1, "and the script is over: no walk back out after a whiteout")
end
S.finish()