-- The Pokegear and Pokedex unlock chain: `setflag` to the START menu and the -- card strip. -- -- GOLD_CACHE="$HOME/Library/Application Support/LOVE/gold-dev/gold" \ -- luajit tests/gen2_pokegear_unlock_test.lua -- -- On the cart every unlock in this family is ONE engine flag -- (constants/engine_flags.asm const order over data/events/engine_flags.asm): -- ENGINE_RADIO_CARD 0, ENGINE_MAP_CARD 1, ENGINE_PHONE_CARD 2, -- ENGINE_EXPN_CARD 3 and ENGINE_POKEGEAR 4 are wPokegearFlags bits, and -- ENGINE_POKEDEX 11 is wStatusFlags' STATUSFLAGS_POKEDEX_F. The menus read -- the same bits the scripts set: start_menu.asm's .SetUpMenuItems bit-tests -- wStatusFlags and wPokegearFlags, and the Pokegear's card strip is -- wPokegearFlags again. So the round trip under test is -- script `setflag` -> Vm -> World:setEngineFlag -> save.engineFlags -> -- StartMenu:availability / Pokegear:flags -- with nothing hand-seeded in between. The first half runs fixture rows that -- mirror the granting scripts; the cache-gated half runs the real extracted -- scripts themselves. package.path = "./?.lua;./?/init.lua;" .. package.path local S = require("tests.harness").suite("gen2 pokegear unlock") local check, eq = S.check, S.eq love = require("tests.love_stub") -- No font is loaded (logic assertions only), so quiet the per-glyph warns. require("src.core.Logger").warn = function() end local Pokegear = require("src.ui.gen2.Pokegear") local Save = require("src.core.gen2.Save") local StartMenu = require("src.ui.gen2.StartMenu") local Vm = require("src.script.gen2.Vm") local World = require("src.world.gen2.World") -- ---- plumbing -------------------------------------------------------------- local function newInput() local input = { pressed = {} } function input:press(...) for _, b in ipairs({ ... }) do self.pressed[b] = true end end function input:wasPressed(b) if self.pressed[b] then self.pressed[b] = nil return true end return false end function input:isDown() return false end return input end local function newGame(save) return { input = newInput(), save = save, options = save and save.options or Save.defaultOptions(), data = { audio = {}, pokemon = {}, items = {} }, stack = { _items = {}, push = function(self, s) self._items[#self._items + 1] = s end, pop = function(self) return table.remove(self._items) end, top = function(self) return self._items[#self._items] end, }, } end -- A real World:setEngineFlag / World:engineFlag over a real save, without the -- rest of the world: the same slice gen2_badges_test.lua drives. local function newWorld(save) return setmetatable({ game = { save = save } }, { __index = World }) end -- wEventFlags for the Vm: setevent / checkevent land here. local function newEvents() return { flags = {}, set = function(self, id, v) self.flags[id] = v or nil end, get = function(self, id) return self.flags[id] == true end, } end -- Run a script list to completion, spending pause/showemote waits. yesorno -- answers come from `answers` in order and default to YES past the end: the -- confirm loops (Mom's clock wheel re-asks until the player agrees) terminate -- on YES, so an unscripted question is confirmed rather than looping forever. local function runScript(scripts, key, world, opts) opts = opts or {} local answers = opts.answers or {} local asked = 0 local vm = Vm.new(scripts, opts.text or {}, opts.events or newEvents(), { setEngineFlag = function(flag, v) world:setEngineFlag(flag, v) end, getEngineFlag = function(flag) return world:engineFlag(flag) end, yesorno = function(done) asked = asked + 1 local answer = answers[asked] if answer == nil then answer = true end done(answer == true) end, specialOrder = opts.specialOrder, -- The world half of the special handlers, as ONE sub-table: pushScreen -- rides here, the same seam World:specialHooks fills. specials = opts.specials, }) check(vm:start(key), "script " .. tostring(key) .. " started") for _ = 1, 5000 do if not vm:running() then break end vm:update() end eq(vm:running(), false, "script " .. tostring(key) .. " ran to completion") return vm, asked end local function menuIds(menu) local out = {} for _, item in ipairs(menu.items) do out[#out + 1] = item.value end return table.concat(out, ",") end local function cardIds(gear) local out = {} for _, card in ipairs(gear.cards) do out[#out + 1] = card.id end return table.concat(out, ",") end -- ---- the locked state ------------------------------------------------------ do local save = Save.newGame() save.party = { { species = "CYNDAQUIL" } } local menu = StartMenu.new(newGame(save), { save = save }) eq(menuIds(menu), "pokemon,pack,status,save,option,quit", "no engine flags: no POKeDEX and no POKeGEAR row") local gear = Pokegear.new(newGame(save), { save = save }) eq(cardIds(gear), "clock", "and the gear alone is just the clock card") end -- ---- fixture rows: the same setflag ids the granting scripts carry --------- -- Mom (maps/PlayersHouse1F.asm): `setflag ENGINE_POKEGEAR` then -- `setflag ENGINE_PHONE_CARD` on the way out the door. do local save = Save.newGame() save.party = { { species = "CYNDAQUIL" } } local world = newWorld(save) runScript({ mom = { { op = "setflag", flag = 4 }, { op = "setflag", flag = 2 }, { op = "end" }, } }, "mom", world) eq(save.engineFlags[4], true, "ENGINE_POKEGEAR landed in save.engineFlags") local menu = StartMenu.new(newGame(save), { save = save }) eq(menuIds(menu), "pokemon,pack,pokegear,status,save,option,quit", "the POKeGEAR row unlocks from the flag alone") local gear = Pokegear.new(newGame(save), { save = save }) eq(cardIds(gear), "clock,phone", "and the PHONE card is on the strip") end -- Oak (maps/MrPokemonsHouse.asm): `setflag ENGINE_POKEDEX`. do local save = Save.newGame() save.party = { { species = "CYNDAQUIL" } } local world = newWorld(save) runScript({ oak = { { op = "setflag", flag = 11 }, { op = "end" }, } }, "oak", world) local menu = StartMenu.new(newGame(save), { save = save }) eq(menuIds(menu), "pokedex,pokemon,pack,status,save,option,quit", "ENGINE_POKEDEX puts POKeDEX at the top of the menu") end -- The Guide Gent (maps/CherrygroveCity.asm `setflag ENGINE_MAP_CARD`), the -- Radio Tower quiz lady (maps/RadioTower1F.asm `setflag ENGINE_RADIO_CARD`) -- and the Lavender tower director (maps/LavRadioTower1F.asm -- `setflag ENGINE_EXPN_CARD`). do local save = Save.newGame() local world = newWorld(save) runScript({ cards = { { op = "setflag", flag = 1 }, { op = "setflag", flag = 0 }, { op = "setflag", flag = 3 }, { op = "end" }, } }, "cards", world) local gear = Pokegear.new(newGame(save), { save = save }) eq(cardIds(gear), "clock,map,radio", "MAP and RADIO cards unlock; PHONE stays locked without flag 2") -- POKEGEAR_EXPN_CARD_F is not a card of its own -- it is the Kanto radio -- upgrade the Poke Flute channel checks. eq(gear:radioContext().expnCard, true, "EXPN reaches the radio context") end -- With every card granted the strip has to read in POKEGEARCARD_* order -- (constants/pokegear_constants.asm: CLOCK 0, MAP 1, PHONE 2, RADIO 3). The -- order is what paging walks, and AnimatePokegearModeIndicatorArrow indexes its -- $00/$10/$20/$30 x offsets by wPokegearCard, so a strip out of card order -- makes the mode arrow skip an icon and then jump backwards. do local save = Save.newGame() local world = newWorld(save) runScript({ cards = { { op = "setflag", flag = 0 }, { op = "setflag", flag = 1 }, { op = "setflag", flag = 2 }, { op = "end" }, } }, "cards", world) local gear = Pokegear.new(newGame(save), { save = save }) eq(cardIds(gear), "clock,map,phone,radio", "the strip is in card order") local columns = {} for _, card in ipairs(gear.cards) do columns[#columns + 1] = tostring(card.iconX) end eq(table.concat(columns, ","), "0,2,4,6", "and paging steps the arrow left to right, one icon at a time") end -- The overlay stays: a test or driver may still seed save.pokegearFlags and -- the older proxy fields directly. do local save = Save.newGame() save.pokegearFlags = { phone = true } save.pokedexReceived = true save.pokegearReceived = true save.party = { { species = "TOTODILE" } } local gear = Pokegear.new(newGame(save), { save = save }) eq(cardIds(gear), "clock,phone", "seeded pokegearFlags still read") local menu = StartMenu.new(newGame(save), { save = save }) eq(menuIds(menu), "pokedex,pokemon,pack,pokegear,status,save,option,quit", "and the proxy fields still unlock the menu rows") end -- ---- the EXPN gate and the two landmark stations --------------------------- -- RadioChannels (engine/pokegear/pokegear.asm): knob 78 (20.0) is the Poke -- Flute channel, Kanto plus EXPN only; knob 52 (13.5) resolves ????? only at -- the Ruins of Alph. Both were unreachable while the flags never arrived. local LANDMARKS = { landmarks = { LANDMARK_NEW_BARK_TOWN = { index = 2 }, LANDMARK_RUINS_OF_ALPH = { index = 10 }, LANDMARK_VERMILION_CITY = { index = 50 }, } } local function stationAt(save, landmark, knob) local gear = Pokegear.new(newGame(save), { save = save, landmarks = LANDMARKS, currentLandmark = landmark, }) for _, row in ipairs(gear:stations()) do if row.knob == knob then return row end end return nil end do local save = Save.newGame() local world = newWorld(save) world:setEngineFlag(0, true) local locked = stationAt(save, "LANDMARK_VERMILION_CITY", 78) eq(locked and locked.station, nil, "20.0 is dead air without the EXPN flag") world:setEngineFlag(3, true) local flute = stationAt(save, "LANDMARK_VERMILION_CITY", 78) eq(flute and flute.station, "POKE_FLUTE_RADIO", "ENGINE_EXPN_CARD opens the Poke Flute channel in Kanto") eq(flute and flute.name, "POKé FLUTE", "with its own station name") local ruins = stationAt(save, "LANDMARK_RUINS_OF_ALPH", 52) eq(ruins and ruins.station, "UNOWN_RADIO", "and 13.5 resolves the ????? station at the Ruins of Alph") local away = stationAt(save, "LANDMARK_NEW_BARK_TOWN", 52) eq(away and away.station, nil, "which is dead air anywhere else") end -- ---- ExitPokegearRadio_HandleMusic ----------------------------------------- -- RadioMusicRestartDE writes the tuned song into wMapMusic, so closing the -- gear on a station leaves the song playing AS the map music; NoRadioStation's -- ENTER_MAP_MUSIC brings the map theme back instead. local Music = require("src.core.Music") do local save = Save.newGame() local world = newWorld(save) world:setEngineFlag(0, true) world:setEngineFlag(3, true) local game = newGame(save) game.world = { map = { def = { music = "Music_VermilionCity" } } } local gear = Pokegear.new(game, { save = save, landmarks = LANDMARKS, currentLandmark = "LANDMARK_VERMILION_CITY", }) -- The knob on 20.0: RADIO_CHANNELS row 7. gear.station = 7 gear:tuneRadio() eq(gear.radioShow, "POKE_FLUTE_RADIO", "the tuner resolved the channel") gear:tickRadio() eq(gear.radioSong, "Music_PokeFluteChannel", "the station started its song") eq(gear.radioMusicPlaying, "Music_PokeFluteChannel", "and parked it in wPokegearRadioMusicPlaying") Music.setMapSong("Music_VermilionCity") gear:stopRadio() eq(Music.mapSong(), "Music_PokeFluteChannel", "closing the radio makes the tuned song the map music (wMapMusic)") eq(gear.radioMusicPlaying, nil, "and zeroes the handoff byte") -- Dead air: knob 16 asks for Johto, and this gear is in Kanto. Music.setMapSong("Music_VermilionCity") gear.station = 1 gear:tuneRadio() eq(gear.radioShow, nil, "04.5 is dead air in Kanto") eq(gear.radioMusicPlaying, "enterMap", "NoRadioStation parks ENTER_MAP_MUSIC") gear:stopRadio() eq(Music.mapSong(), "Music_VermilionCity", "and closing on dead air leaves the map song alone") end -- The Pokemon Channel jingle is the one RESTART_MAP_MUSIC writer. eq(Pokegear.radioPlayingValue("Music_PokemonChannel"), "restartMap", "RadioMusicRestartPokemonChannel restores the map theme on exit") eq(Pokegear.radioPlayingValue("Music_PokeFluteChannel"), "Music_PokeFluteChannel", "RadioMusicRestartDE hands the song itself over") -- ---- the cache half: the real granting scripts ----------------------------- local cacheDir = os.getenv("GOLD_CACHE") if not cacheDir then cacheDir = (os.getenv("HOME") or "") .. "/Library/Application Support/LOVE/gold-dev/gold" end local scriptsFile = loadfile(cacheDir .. "/data/generated/scripts.lua") if not scriptsFile then check(true, "cache absent (SKIP)") S.finish() return end local scripts = scriptsFile() local constants = assert( loadfile(cacheDir .. "/data/generated/constants.lua"))() -- Mom's send-off (maps/PlayersHouse1F.asm): flags 4 and 2 in one script. do local save = Save.newGame() save.party = { { species = "CYNDAQUIL" } } local world = newWorld(save) runScript(scripts, "60:564f", world) eq(save.engineFlags[4], true, "Mom set ENGINE_POKEGEAR") eq(save.engineFlags[2], true, "and ENGINE_PHONE_CARD") local menu = StartMenu.new(newGame(save), { save = save }) check(menuIds(menu):find("pokegear", 1, true) ~= nil, "so the real script unlocks the POKeGEAR row") eq(cardIds(Pokegear.new(newGame(save), { save = save })), "clock,phone", "and the PHONE card") end -- Oak's dex handover (maps/MrPokemonsHouse.asm): flag 11. do local save = Save.newGame() save.party = { { species = "CYNDAQUIL" } } local world = newWorld(save) runScript(scripts, "62:46c1", world) eq(save.engineFlags[11], true, "Oak set ENGINE_POKEDEX") local menu = StartMenu.new(newGame(save), { save = save }) eq(menu.items[1] and menu.items[1].value, "pokedex", "so the real script puts POKeDEX first") end -- The Guide Gent's tour (maps/CherrygroveCity.asm): flag 1. do local save = Save.newGame() local world = newWorld(save) runScript(scripts, "48:43ea", world) eq(save.engineFlags[1], true, "the Guide Gent set ENGINE_MAP_CARD") eq(cardIds(Pokegear.new(newGame(save), { save = save })), "clock,map", "so the real script unlocks the MAP card") end -- The Radio Tower quiz (maps/RadioTower1F.asm): five questions whose right -- answers are YES/YES/NO/YES/NO, after a YES to sit the quiz at all. do local save = Save.newGame() local world = newWorld(save) local _, asked = runScript(scripts, "43:4d7d", world, { answers = { true, true, true, false, true, false }, }) eq(asked, 6, "the quiz asked its six questions") eq(save.engineFlags[0], true, "a perfect run set ENGINE_RADIO_CARD") eq(cardIds(Pokegear.new(newGame(save), { save = save })), "clock,radio", "so the real quiz unlocks the RADIO card") -- One wrong answer and the card is withheld. local save2 = Save.newGame() runScript(scripts, "43:4d7d", newWorld(save2), { answers = { true, true, true, true }, }) eq(save2.engineFlags[0], nil, "a wrong answer grants nothing") end -- The Lavender tower director (maps/LavRadioTower1F.asm): flag 3. do local save = Save.newGame() local world = newWorld(save) runScript(scripts, "5d:47b3", world) eq(save.engineFlags[3], true, "the director set ENGINE_EXPN_CARD") local gear = Pokegear.new(newGame(save), { save = save, landmarks = LANDMARKS, currentLandmark = "LANDMARK_VERMILION_CITY", }) eq(gear:radioContext().expnCard, true, "so the real script arms the Poke Flute channel gate") end -- ---- the wall radios reach the new special --------------------------------- -- std_scripts.asm Radio1Script is `setval MAPRADIO_POKEMON_CHANNEL` + -- `special MapRadio`; the id must resolve to a HANDLER now, not a stub. do local Specials = require("src.script.gen2.Specials") eq(constants.specialOrder[41], "MapRadio", "special id 40 is MapRadio") check(Specials.HANDLERS.MapRadio ~= nil, "MapRadio is implemented") eq(Specials.STUB_REASONS.MapRadio, nil, "and no longer a stub") local save = Save.newGame() local world = newWorld(save) local pushed = nil local vm = Vm.new(scripts, {}, newEvents(), { setEngineFlag = function(flag, v) world:setEngineFlag(flag, v) end, getEngineFlag = function(flag) return world:engineFlag(flag) end, specialOrder = constants.specialOrder, specials = { pushScreen = function(id, opts) pushed = { id = id, opts = opts } -- A pushed screen answers later; here the "screen" closes at once. if opts and opts.onDone then opts.onDone() end return true end, }, }) check(vm:start("40:4132"), "Radio1Script's body started") for _ = 1, 200 do if not vm:running() then break end vm:update() end eq(vm:running(), false, "and ran through the special to its end") eq(pushed and pushed.id, "Gen2MapRadio", "special 40 pushed the wall-radio screen") eq(pushed and pushed.opts and pushed.opts.channel, 0, "with setval's MAPRADIO_POKEMON_CHANNEL") -- Radio2Script (the Lucky Channel houses): channel 4. pushed = nil check(vm:start("40:413a"), "Radio2Script's body started") for _ = 1, 200 do if not vm:running() then break end vm:update() end eq(pushed and pushed.opts and pushed.opts.channel, 4, "Radio2Script tunes MAPRADIO_LUCKY_CHANNEL") end S.finish()