-- ../pokecrystal/engine/events/move_tutor.asm:1, engine/events/buena.asm:1 -- and :64, engine/events/poke_seer.asm:18, mobile/mobile_12_2.asm:191. -- ROM-free: -- luajit tests/gen2_crystal_extras_test.lua package.path = "./?.lua;./?/init.lua;" .. package.path -- The same love stub tests/gen2_menus_test.lua installs; nothing here draws. love = love or {} love.graphics = love.graphics or { getColor = function() return 1, 1, 1, 1 end, setColor = function() end, rectangle = function() end, print = function() end, printf = function() end, draw = function() end, newQuad = function() return {} end, newImage = function() return nil end, getShader = function() return nil end, setShader = function() end, newShader = function() error("no shaders in this harness") end, getDimensions = function() return 160, 144 end, push = function() end, pop = function() end, translate = function() end, scale = function() end, circle = function() end, clear = function() end, } love.math = love.math or { random = function(a, b) return b and a or 1 end } love.image = love.image or {} love.filesystem = love.filesystem or { load = function() return nil end, getInfo = function() return nil end, read = function() return nil end, } love.timer = love.timer or { getTime = function() return 0 end } local S = require("tests.harness").suite("gen2 crystal extras") local check, eq = S.check, S.eq require("src.core.Logger").warn = function() end local Events = require("src.world.gen2.Events") local Mon = require("src.battle.gen2.Mon") local MoveTutorScreen = require("src.ui.gen2.MoveTutor") local Save = require("src.core.gen2.Save") local Specials = require("src.script.gen2.Specials") local Vm = require("src.script.gen2.Vm") local H = Specials.HANDLERS -- --------------------------------------------------------------- fixtures -- The cache tables the handlers read, plus the landmark registry -- GetLandmarkName walks (../pokecrystal/engine/overworld/landmarks.asm:16). local DATA = { pokemon = { tutorMoves = { "FLAMETHROWER", "THUNDERBOLT", "ICE_BEAM" }, TYPHLOSION = { index = 157, name = "TYPHLOSION", tmhm = { "CUT", "STRENGTH" }, tutorMoves = { "FLAMETHROWER" } }, GEODUDE = { index = 74, name = "GEODUDE", tmhm = { "STRENGTH" }, tutorMoves = { "FLAMETHROWER" } }, LAPRAS = { index = 131, name = "LAPRAS", tmhm = { "SURF" } }, CYNDAQUIL = { index = 155, name = "CYNDAQUIL" }, TOTODILE = { index = 158, name = "TOTODILE" }, CHIKORITA = { index = 152, name = "CHIKORITA" }, PIKACHU = { index = 25, name = "PIKACHU" }, RATTATA = { index = 19, name = "RATTATA" }, HOOTHOOT = { index = 163, name = "HOOTHOOT" }, SPINARAK = { index = 165, name = "SPINARAK" }, DROWZEE = { index = 96, name = "DROWZEE" }, }, items = { ULTRA_BALL = { index = 2, name = "ULTRA BALL" }, FULL_RESTORE = { index = 16, name = "FULL RESTORE" }, NUGGET = { index = 92, name = "NUGGET" }, RARE_CANDY = { index = 50, name = "RARE CANDY" }, PROTEIN = { index = 33, name = "PROTEIN" }, IRON = { index = 34, name = "IRON" }, CARBOS = { index = 35, name = "CARBOS" }, CALCIUM = { index = 37, name = "CALCIUM" }, HP_UP = { index = 32, name = "HP UP" }, POTION = { index = 17, name = "POTION" }, ANTIDOTE = { index = 18, name = "ANTIDOTE" }, PARLYZ_HEAL = { index = 21, name = "PARLYZ HEAL" }, FRESH_WATER = { index = 39, name = "FRESH WATER" }, SODA_POP = { index = 40, name = "SODA POP" }, LEMONADE = { index = 41, name = "LEMONADE" }, POKE_BALL = { index = 5, name = "POKé BALL" }, GREAT_BALL = { index = 1, name = "GREAT BALL" }, X_ATTACK = { index = 68, name = "X ATTACK" }, X_DEFEND = { index = 69, name = "X DEFEND" }, X_SPEED = { index = 67, name = "X SPEED" }, }, moves = { FLAMETHROWER = { name = "FLAMETHROWER" }, THUNDERBOLT = { name = "THUNDERBOLT" }, ICE_BEAM = { name = "ICE BEAM" }, TACKLE = { name = "TACKLE" }, GROWL = { name = "GROWL" }, MUD_SLAP = { name = "MUD-SLAP" }, }, gen2Landmarks = { order = {}, landmarks = { NEW_BARK_TOWN = { index = 1, name = "NEW BARK\nTOWN" }, ROUTE_29 = { index = 2, name = "ROUTE 29" }, }, }, } local function newSave() local save = { version = "crystal", player = { name = "KRIS", id = 0x1234, money = 0 }, party = {}, inventory = {}, bagOrder = {}, } Save.crystalState(save) return save end -- World:specialHooks, stubbed; every screen answers through its own onDone. local function newHooks(save, opts) opts = opts or {} local vars = opts.vars or {} local log = { pushed = {}, sfx = {} } local hooks = { log = log, vars = vars, save = function() return save end, data = function() return DATA end, party = function() return save.party end, itemIndex = function(id) local def = DATA.items[id] return def and def.index end, hasItem = function(index) return opts.held and opts.held[index] == true end, playSfxNamed = function(name) log.sfx[#log.sfx + 1] = name end, selectPartyMon = function(prompt, done) log.selectPrompt = prompt done(opts.pickIndex, opts.pickIndex and save.party[opts.pickIndex]) end, pushScreen = function(id, screenOpts) log.pushed[#log.pushed + 1] = { id = id, opts = screenOpts } if opts.noScreens then return false end local answer = opts.answers and opts.answers[#log.pushed] if screenOpts.onDone then screenOpts.onDone(answer) end return true end, } return hooks end -- The var pair is Vm.readVarFn / Vm.writeVarFn, not a `specials` hook: -- engine/overworld/variables.asm's rows belong to `readvar` / `writevar`. local function newVm(hooks, scriptVar) local vars = hooks.vars local vm = Vm.new({}, {}, Events.new(), { specials = hooks, readVar = function(id) return vars[id] or 0 end, writeVar = function(id, value) vars[id] = value end, }) vm.showTextFn = function() end vm.scriptVar = scriptVar or 0 return vm end -- Run one handler out, collecting its pages and feeding its YES/NO answers. local function run(vm, handler, answers) answers = answers or {} local pages, asked = {}, 0 local co = coroutine.create(function() handler(vm) end) vm.co = co local send while true do local ok, req = coroutine.resume(co, send) if not ok then error(req, 0) end if coroutine.status(co) == "dead" then break end send = nil if type(req) == "table" and req.kind == "text" then pages[#pages + 1] = req.text elseif type(req) == "table" and req.kind == "yesorno" then asked = asked + 1 send = answers[asked] else pages.parked = req break end end return pages end -- ============================================================ the move tutor -- .GetMoveTutorMove (engine/events/move_tutor.asm:36-52); anything that is -- not MOVETUTOR_FLAMETHROWER or _THUNDERBOLT falls through to MT03. do local function tutorMoveFor(value, answer) local save = newSave() local hooks = newHooks(save, { answers = { answer } }) local vm = newVm(hooks, value) run(vm, H.MoveTutor) local pushed = hooks.log.pushed[1] return pushed and pushed.opts.move, vm.scriptVar end eq(select(1, tutorMoveFor(1, true)), "FLAMETHROWER", "MOVETUTOR_FLAMETHROWER is MT01") eq(select(1, tutorMoveFor(2, true)), "THUNDERBOLT", "MOVETUTOR_THUNDERBOLT is MT02") eq(select(1, tutorMoveFor(3, true)), "ICE_BEAM", "MOVETUTOR_ICE_BEAM is MT03") eq(select(1, tutorMoveFor(0, true)), "ICE_BEAM", "and anything else falls through to MT03, as the `cp` ladder does") local _, learned = tutorMoveFor(1, true) eq(learned, 0, "a taught move leaves wScriptVar FALSE, which is .TeachMove") local _, cancelled = tutorMoveFor(1, false) eq(cancelled, 255, "and a cancel leaves -1, which is .Incompatible") end -- engine/events/move_tutor.asm:29 .cancel, ahead of the script's -- takecoins 4000 (maps/GoldenrodCity.asm:124). do local save = newSave() local hooks = newHooks(save, { noScreens = true }) local vm = newVm(hooks, 2) run(vm, H.MoveTutor) eq(vm.scriptVar, 255, "no screen is the -1 cancel, not a free lesson") end -- `add_mt` gives the three tutor moves TMHM flags 58-60 -- (constants/item_constants.asm:295-307), so CanLearnTMHMMove sees them. do local view = MoveTutorScreen.speciesView(DATA.pokemon) check(MoveTutorScreen.canLearn(view.TYPHLOSION, "FLAMETHROWER"), "TYPHLOSION can be taught FLAMETHROWER") check(MoveTutorScreen.canLearn(view.TYPHLOSION, "CUT"), "and its ordinary TM list still answers") check(not MoveTutorScreen.canLearn(view.LAPRAS, "FLAMETHROWER"), "LAPRAS cannot") local merged = view.TYPHLOSION.tmhm eq(#merged, 3, "the view's tmhm list carries the tutor row too") eq(merged[3], "FLAMETHROWER", "appended after the TM/HM rows") check(view.TYPHLOSION == view.TYPHLOSION, "and the view is memoised") eq(view.MISSINGNO, nil, "a species the cache does not carry stays nil") eq(#DATA.pokemon.TYPHLOSION.tmhm, 2, "the cache's own record is not touched") end -- ================================================================== Buena -- engine/events/buena_menu.asm:1-9: carry (NO or B) is 0, YES is 1. do local save = newSave() local vm = newVm(newHooks(save)) run(vm, H.AskRememberPassword, { true }) eq(vm.scriptVar, 1, "YES is 1, which the script reads as `iffalse` not taken") local vm2 = newVm(newHooks(save)) run(vm2, H.AskRememberPassword, { false }) eq(vm2.scriptVar, 0, "and NO is 0, .ForgotPassword") end -- engine/events/buena.asm:19-23: only wBuenasPassword's low nybble is right. do local save = newSave() -- BuenasPassword4's two rejection rolls, pinned -- (engine/pokegear/radio.asm:1470-1487). local rolls = { 1, 2 } local taken = 0 local realRandom = Specials.random Specials.random = function() taken = taken + 1 return rolls[taken] end local hooks = newHooks(save, { answers = { 1 } }) local vm = newVm(hooks) run(vm, H.BuenasPassword) Specials.random = realRandom local pushed = hooks.log.pushed[1] eq(pushed.id, "Gen2BuenaPassword", "the show opens its own menu") eq(pushed.opts.mode, "password", "in password mode") eq(pushed.opts.width, 10, "the box is as wide as the category's points byte") eq(table.concat(pushed.opts.words, ","), "CYNDAQUIL,TOTODILE,CHIKORITA", ".PlacePasswordChoices resolves BUENA_MON rows through GetPokemonName") eq(vm.scriptVar, 1, "picking the low nybble's row is the right answer") eq(save.crystal.buenaPassword.word, 0x01, "and the roll is packed group-high, word-low into wBuenasPassword") eq(hooks.vars[0x19], 0x01, "VAR_BUENASPASSWORD sees the same byte") -- DAILYFLAGS2_BUENAS_PASSWORD_F holds the roll for the day -- (engine/pokegear/radio.asm:1467). local hooks2 = newHooks(save, { answers = { 0 } }) local vm2 = newVm(hooks2) run(vm2, H.BuenasPassword) eq(vm2.scriptVar, 0, "row 0 is a wrong guess") eq(save.crystal.buenaPassword.word, 0x01, "and the day's password is unchanged") eq(table.concat(hooks2.log.pushed[1].opts.words, ","), "CYNDAQUIL,TOTODILE,CHIKORITA", "so the menu offers the same three words") end -- GetBuenasPassword's four BUENA_* arms (engine/pokegear/radio.asm:1534), -- and the points byte as the menu width (engine/events/buena.asm:9-12). do local save = newSave() local realRandom = Specials.random local function pin(group, word) local rolls, taken = { group + 1, word + 1 }, 0 Specials.random = function() taken = taken + 1 return rolls[taken] end save.crystal.buenaPassword.word = nil save.crystal.buenaPassword.day = nil local hooks = newHooks(save, { answers = { word } }) local vm = newVm(hooks) run(vm, H.BuenasPassword) return hooks.log.pushed[1].opts, vm.scriptVar end local balls = pin(3, 2) eq(table.concat(balls.words, ","), "POKé BALL,GREAT BALL,ULTRA BALL", "BUENA_ITEM rows resolve through GetItemName") eq(balls.width, 12, "and .Balls is worth 12 points") local towns = pin(6, 0) eq(table.concat(towns.words, ","), "NEW BARK TOWN,CHERRYGROVE CITY,AZALEA TOWN", "BUENA_STRING rows are the literals themselves") eq(towns.width, 16, "the widest category is 16 wide") local moves = pin(8, 1) eq(table.concat(moves.words, ","), "TACKLE,GROWL,MUD-SLAP", "BUENA_MOVE rows resolve through GetMoveName") local types = pin(7, 0) eq(types.width, 6, ".Types is the narrowest box at 6") local _, right = pin(10, 2) eq(right, 1, "the eleventh category exists and its third row can be picked") Specials.random = realRandom end -- engine/events/buena.asm:25 .wrong is what a menu-less run has to take. do local save = newSave() local vm = newVm(newHooks(save, { noScreens = true })) run(vm, H.BuenasPassword) eq(vm.scriptVar, 0, "no menu means no correct answer") end -- ------------------------------------------------------------ the prizes -- data/items/buena_prizes.asm, and the cost / ReceiveItem / subtract order -- (engine/events/buena.asm:95-119). do local save = newSave() local hooks = newHooks(save, { vars = { [0x18] = 5 }, answers = { 1 } }) local vm = newVm(hooks) local pages = run(vm, H.BuenaPrize, { true }) local pushed = hooks.log.pushed[1] eq(pushed.opts.mode, "prize", "the counter opens the prize list") eq(#pushed.opts.prizes, 9, "NUM_BUENA_PRIZES rows") eq(pushed.opts.prizes[1].name, "ULTRA BALL", "the first prize") eq(pushed.opts.prizes[1].cost, 2, "costs 2 points") eq(pushed.opts.prizes[9].name, "HP UP", "the last prize") eq(pushed.opts.prizes[9].cost, 5, "costs 5") eq(pushed.opts.balance, 5, "and the Points box shows wBlueCardBalance") eq(save.inventory.ULTRA_BALL, 1, "one ULTRA BALL leaves the counter") eq(hooks.vars[0x18], 3, "and the two points come off the card") eq(hooks.log.sfx[1], "Sfx_Transaction", "SFX_TRANSACTION rings the sale") eq(pages[#pages], "Oh. Please come\nback again!", ".done prints _BuenaComeAgainText after the loop") end -- engine/events/buena.asm:93 `jr c, .loop`: nothing is spent. do local save = newSave() local hooks = newHooks(save, { vars = { [0x18] = 9 }, answers = { 1, 0 } }) local vm = newVm(hooks) run(vm, H.BuenaPrize, { false }) eq(save.inventory.ULTRA_BALL, nil, "a refused confirmation buys nothing") eq(hooks.vars[0x18], 9, "and spends no points") eq(#hooks.log.pushed, 2, "the list reopens once before the B press ends it") end -- .InsufficientBalance and .BagFull (engine/events/buena.asm:121-127). do local save = newSave() local hooks = newHooks(save, { vars = { [0x18] = 1 }, answers = { 1, 0 } }) local vm = newVm(hooks) local pages = run(vm, H.BuenaPrize, { true }) eq(save.inventory.ULTRA_BALL, nil, "one point cannot buy a two point ball") eq(hooks.vars[0x18], 1, "the balance is untouched") check(pages[3] == "You don't have\nenough points.", "_BuenaNotEnoughPointsText is the refusal") -- ReceiveItem's own refusal: a stack already at 99. local full = newSave() full.inventory.ULTRA_BALL = 99 local fullHooks = newHooks(full, { vars = { [0x18] = 9 }, answers = { 1, 0 } }) local fullVm = newVm(fullHooks) local fullPages = run(fullVm, H.BuenaPrize, { true }) eq(full.inventory.ULTRA_BALL, 99, "a full stack takes no more") eq(fullHooks.vars[0x18], 9, "a full bag spends nothing either") check(fullPages[3] == "You have no room\nfor it.", "_BuenaNoRoomText is that refusal") end -- ================================================================ the Seer local function seerMon(fields) local mon = { species = "GEODUDE", nickname = "ROCKY", level = 30, otId = 0x1234, otName = "KRIS", moves = {}, } for key, value in pairs(fields or {}) do mon[key] = value end return mon end local function runSeer(mon, cancel) local save = newSave() save.party = { mon } local hooks = newHooks(save, { pickIndex = (not cancel) and 1 or nil }) local vm = newVm(hooks) return run(vm, H.PokeSeer), hooks end -- engine/events/poke_seer.asm:38 .cancel, SeerDoNothingText. do local pages = runSeer(seerMon(), true) eq(#pages, 2, "the intro and then the refusal") eq(pages[2], "Fufufu! I saw that\nyou'd do nothing!", "_SeerDoNothingText") end -- engine/events/poke_seer.asm:28-29 `cp EGG / jr z, .egg`. do local pages = runSeer(seerMon({ isEgg = true })) eq(pages[2], "Hey!\fThat's an EGG!\fYou can't say that\nyou've met it yet…", "_SeerEggText") end -- ReadCaughtData's `.error` (engine/events/poke_seer.asm:104-105, :133). do local pages = runSeer(seerMon()) check(pages[2]:find("Whaaaat", 1, true) ~= nil, "a mon with no caught data at all gets _SeerCantTellAThingText") end -- SeerAction0 (engine/events/poke_seer.asm:64-70), then SeerAdvice. do local mon = seerMon({ caughtTime = 1, caughtLevel = 10, caughtLocation = 1, caughtByGender = "boy" }) local pages = runSeer(mon) eq(pages[2], "Hm… I see you met\nROCKY here:\vNEW BARK TOWN!", "_SeerNameLocationText, with the town map's line break spent as a space") eq(pages[3], "The time was\nMorning!\fIts level was 10!\fAm I good or what?", "_SeerTimeLevelText") check(pages[4]:find("more confident", 1, true) ~= nil, "a 20 level gain is SeerMoreConfidentText, the 29 row") end -- SeerAction1, and engine/events/poke_seer.asm:110-119 where the OT id's -- second `cp` is commented out, so only the HIGH byte decides. do local traded = seerMon({ otId = 0x9999, otName = "SILVER", caughtTime = 3, caughtLevel = 5, caughtLocation = 2 }) local pages = runSeer(traded) eq(pages[2], "Hm… ROCKY\ncame from SILVER\vin a trade?\fROUTE 29\n" .. "was where SILVER\vmet ROCKY!", "_SeerTradeText") eq(pages[3], "The time was\nNight!\fIts level was 5!\fAm I good or what?", "the same time/level page follows") local sameHigh = seerMon({ otId = 0x12ff, caughtTime = 2, caughtLevel = 5, caughtLocation = 2 }) local samePages = runSeer(sameHigh) check(samePages[2]:find("I see you met", 1, true) ~= nil, "an OT id that differs only in its LOW byte reads as met, bug and all") end -- GetCaughtLocation's two sentinels (engine/events/poke_seer.asm:239-249). do local event = seerMon({ caughtLevel = 20, caughtLocation = Mon.LANDMARK_EVENT }) local pages = runSeer(event) check(pages[2]:find("What!? Incredible!", 1, true) ~= nil, "LANDMARK_EVENT is SEERACTION_LEVEL_ONLY, _SeerNoLocationText") check(pages[2]:find("was at level 20", 1, true) ~= nil, "which still prints the level") check(pages[3] ~= nil, "and still gives the advice page") local gift = seerMon({ caughtLevel = 5, caughtLocation = Mon.LANDMARK_GIFT }) local giftPages = runSeer(gift) check(giftPages[2]:find("Whaaaat", 1, true) ~= nil, "LANDMARK_GIFT is SEERACTION_CANT_TELL_2, the same refusal") eq(#giftPages, 2, "and there is no advice behind it") end -- GetCaughtLevel (engine/events/poke_seer.asm:148-179) and GetCaughtTime's -- .none arm (:198-201). do local unknown = seerMon({ caughtLevel = 0, caughtLocation = 1, caughtTime = 0 }) -- byte1 is set, so ReadCaughtData's `or [hl]` misses its `.error` arm -- (engine/events/poke_seer.asm:105). local pages = runSeer(unknown) eq(pages[3], "The time was\nUnknown!\fIts level was ???!\fAm I good or what?", "no time and no level print Unknown and ???") local hatched = seerMon({ caughtLevel = 1, caughtLocation = 1, caughtTime = 2, level = 6 }) local hatchedPages = runSeer(hatched) check(hatchedPages[3]:find("level was 5", 1, true) ~= nil, "CAUGHT_EGG_LEVEL prints EGG_LEVEL, not 1") end -- SeerAdviceTexts (engine/events/poke_seer.asm:357-364), walked in order; -- `sub c` is one byte, so the 255 row catches an underflow. do local function adviceFor(level, caught) local mon = seerMon({ level = level, caughtLevel = caught, caughtLocation = 1, caughtTime = 2 }) return runSeer(mon)[4] end check(adviceFor(12, 10):find("little more care", 1, true) ~= nil, "a 2 level gain is the 9 row") check(adviceFor(35, 10):find("more confident", 1, true) ~= nil, "25 is the 29 row") check(adviceFor(50, 10):find("much strength", 1, true) ~= nil, "40 is the 59 row") check(adviceFor(70, 10):find("grown mighty", 1, true) ~= nil, "60 is the 89 row") check(adviceFor(95, 5):find("I'm impressed", 1, true) ~= nil, "90 is the 100 row") check(adviceFor(5, 10):find("little more care", 1, true) ~= nil, "and `sub c` underflowing lands on the 255 row, which repeats the first") end -- ================================================ UnusedFindItemInPCOrBag -- mobile/mobile_12_2.asm:194 wNumPCItems, then :201 wNumItems. do local save = newSave() save.pcItems = { RARE_CANDY = 1 } local vm = newVm(newHooks(save), 50) run(vm, H.UnusedFindItemInPCOrBag) eq(vm.scriptVar, 1, "an item in the PC answers TRUE") local vm2 = newVm(newHooks(save, { held = { [92] = true } }), 92) run(vm2, H.UnusedFindItemInPCOrBag) eq(vm2.scriptVar, 1, "an item in the pack answers TRUE too") local vm3 = newVm(newHooks(save), 92) run(vm3, H.UnusedFindItemInPCOrBag) eq(vm3.scriptVar, 0, "and neither is FALSE") end -- ==================================================== the seam these ride do for _, name in ipairs({ "MoveTutor", "BuenasPassword", "BuenaPrize", "AskRememberPassword", "PokeSeer", "UnusedFindItemInPCOrBag" }) do eq(Specials.HANDLER_SOURCE[name], "specials/crystal_extras.lua", name .. " is owned by this module") eq(Specials.STUBS[name], nil, name .. " is no longer a stub") check(Specials.SUPERSEDED_STUBS[name] ~= nil, "and its old stub reason was retired") end local ids = {} for _, id in ipairs(require("src.ui.Screens").GEN2_IDS) do ids[id] = true end check(ids.Gen2MoveTutor, "Gen2MoveTutor is a registered screen id") check(ids.Gen2BuenaPassword, "Gen2BuenaPassword is one too") end S.finish()