-- FLY's destination picker (pokegold engine/pokegear/pokegear.asm _FlyMap). -- -- _FlyMap is the town map on its OWN screen: the same LoadTownMapGFX art the -- POKeGEAR's MAP card draws, with TownMapBubble's "Where?" plate instead of the -- card strip and no ENGINE_MAP_CARD gate at all. The cursor walks the -- Flypoints table between wStartFlypoint and wEndFlypoint, skipping every row -- CheckIfVisitedFlypoint rejects; A takes the row under it and B answers -1. -- luajit tests/gen2_fly_map_test.lua package.path = "./?.lua;./?/init.lua;" .. package.path local S = require("tests.harness").suite("gen2 fly map") local check, eq = S.check, S.eq love = require("tests.love_stub") require("src.core.Logger").warn = function() end local FieldMoves = require("src.world.gen2.FieldMoves") local Pokegear = require("src.ui.gen2.Pokegear") local function fakeInput() local pressed = {} return { press = function(_self, button) pressed[button] = true end, wasPressed = function(_self, button) if pressed[button] then pressed[button] = nil return true end return false end, } end -- landmarks.lua's shape, for the rows the picker names and parks on. -- KANTO_LANDMARK is $2e = 46, and Pokegear:region reads the PLAYER's landmark -- index against it, so the Kanto rows have to sit above it here too. local LANDMARKS = { landmarks = {}, order = {} } for i, row in ipairs(FieldMoves.FLYPOINTS) do local index = i < FieldMoves.KANTO_FLYPOINT and i or (46 + i - FieldMoves.KANTO_FLYPOINT) LANDMARKS.landmarks[row.landmark] = { index = index, name = row.landmark:gsub("^LANDMARK_", ""), x = 8, y = 8, } LANDMARKS.order[index] = row.landmark end local function visited(...) local save = { engineFlags = {} } for _, spawn in ipairs({ ... }) do for _, row in ipairs(FieldMoves.FLYPOINTS) do if row.spawn == spawn then save.engineFlags[row.flag] = true end end end return save end -- The gate World:openFlyMap reads before it pushes this screen instead of -- falling back to its yes/no chain. check(Pokegear.FLY_MAP == true, "Pokegear now declares a fly mode") local function flyScreen(save, opts) opts = opts or {} local input = fakeInput() local points = FieldMoves.flyPoints(save, LANDMARKS, opts.region or "johto") local chosen, closed local screen = Pokegear.new({ input = input, save = save }, { save = save, landmarks = LANDMARKS, currentLandmark = opts.currentLandmark, fly = points, onFly = function(spawn) chosen = spawn end, onClose = function() closed = true end, }) return screen, input, points, function() return chosen end, function() return closed end end -- ---------------------------------------------------------------- the screen do local save = visited("SPAWN_NEW_BARK", "SPAWN_VIOLET", "SPAWN_GOLDENROD") local screen, input, points, chosen, closed = flyScreen(save) eq(#points, 3, "only the visited flypoints are offered") eq(#screen.cards, 1, "the picker is one screen, not the card strip") eq(screen.mode, "card", "and it opens straight onto the map") -- FlyMap's Johto default is JOHTO_FLYPOINT, the first row (New Bark Town). eq(screen.flyIndex, 1, "the Johto map opens on the first flypoint") eq(screen:flyRow().spawn, "SPAWN_NEW_BARK", "which is New Bark Town") -- The MAP card's cursor follows the flypoint, so the name plate and the -- arrow both land on the row rather than on a free landmark. eq(screen:mapCursorIndex(), LANDMARKS.landmarks.LANDMARK_NEW_BARK_TOWN.index, "and the map cursor sits on it") input:press("up") screen:update(0) eq(screen:flyRow().spawn, "SPAWN_VIOLET", ".ScrollNext takes the next row") eq(screen:mapCursorIndex(), LANDMARKS.landmarks.LANDMARK_VIOLET_CITY.index, "and the cursor moves with it") input:press("up") screen:update(0) input:press("up") screen:update(0) eq(screen:flyRow().spawn, "SPAWN_NEW_BARK", "and it wraps at wEndFlypoint") input:press("down") screen:update(0) eq(screen:flyRow().spawn, "SPAWN_GOLDENROD", ".ScrollPrev wraps the other way at wStartFlypoint") -- Left and right do nothing: there is no card to page to on this screen. input:press("right") screen:update(0) eq(screen:flyRow().spawn, "SPAWN_GOLDENROD", "right does not page the gear") input:press("left") screen:update(0) eq(screen:flyRow().spawn, "SPAWN_GOLDENROD", "and neither does left") eq(#screen.cards, 1, "the strip is still one row") input:press("a") screen:update(0) eq(chosen(), "SPAWN_GOLDENROD", "A takes the flypoint under the cursor") check(not closed(), "and does not also close the screen") end do -- `ld a, -1`: B answers "no flypoint" and the caller drops it. local save = visited("SPAWN_NEW_BARK") local screen, input, _points, chosen, closed = flyScreen(save) input:press("b") screen:update(0) check(closed(), "B leaves the picker") eq(chosen(), nil, "with nothing chosen") end do -- .KantoFlyMap opens on the LAST row (Indigo Plateau), and the Kanto half is -- withheld entirely until Indigo is visited. local save = visited("SPAWN_NEW_BARK", "SPAWN_PALLET", "SPAWN_INDIGO") local screen = flyScreen(save, { region = "kanto", currentLandmark = "LANDMARK_PALLET_TOWN", }) eq(screen.flyIndex, #screen.fly, "the Kanto map opens on the last flypoint") eq(screen:flyRow().spawn, "SPAWN_INDIGO", "which is Indigo Plateau") end do local save = visited("SPAWN_NEW_BARK", "SPAWN_PALLET") local points = FieldMoves.flyPoints(save, LANDMARKS, "kanto") eq(#points, 1, "with no Indigo the Kanto half is withheld") eq(points[1].spawn, "SPAWN_NEW_BARK", "and the Johto map is shown instead") end -- Drawing must not throw: the plain path (no town-map art in the cache) is -- what a headless run and an older cache take. do local save = visited("SPAWN_NEW_BARK", "SPAWN_VIOLET") local screen = flyScreen(save) local ok, err = pcall(function() screen:drawPanel() end) check(ok, "the unstyled fly picker draws (" .. tostring(err) .. ")") end -- IsInJohto (home/region.asm:10, :23) do local World = require("src.world.gen2.World") local function landmarkTable(indices) local out = { landmarks = {}, order = {} } for id, index in pairs(indices) do out.landmarks[id] = { index = index, name = id, x = 8, y = 8 } out.order[index + 1] = id end return out end local GOLD = landmarkTable({ LANDMARK_NEW_BARK_TOWN = 0x01, LANDMARK_SILVER_CAVE = 0x2d, LANDMARK_PALLET_TOWN = 0x2e, LANDMARK_ROUTE_28 = 0x5d, LANDMARK_FAST_SHIP = 0x5e, }) local CRYSTAL = landmarkTable({ LANDMARK_NEW_BARK_TOWN = 0x01, LANDMARK_BATTLE_TOWER = 0x1d, LANDMARK_SILVER_CAVE = 0x2e, LANDMARK_PALLET_TOWN = 0x2f, LANDMARK_ROUTE_28 = 0x5e, LANDMARK_FAST_SHIP = 0x5f, }) local function regionAt(landmarks, id) return World.region(setmetatable({ landmarks = landmarks, map = { def = { landmark = id } }, }, World)) end eq(regionAt(CRYSTAL, "LANDMARK_SILVER_CAVE"), "johto", "Crystal's $2e is SILVER CAVE, so Mt. Silver flies to Johto") eq(regionAt(CRYSTAL, "LANDMARK_PALLET_TOWN"), "kanto", "and its $2f is PALLET TOWN, so Kanto") eq(regionAt(CRYSTAL, "LANDMARK_FAST_SHIP"), "johto", "the S.S. Aqua is Johto at $5f") eq(regionAt(CRYSTAL, "LANDMARK_ROUTE_28"), "kanto", "and $5e is ROUTE 28, which is Kanto") eq(regionAt(GOLD, "LANDMARK_SILVER_CAVE"), "johto", "Gold's $2d is Johto") eq(regionAt(GOLD, "LANDMARK_PALLET_TOWN"), "kanto", "Gold's PALLET TOWN is Kanto at $2e") eq(regionAt(GOLD, "LANDMARK_FAST_SHIP"), "johto", "and its S.S. Aqua is Johto at $5e") eq(World.region(setmetatable({ map = { def = { landmark = 0x2e } } }, World)), "kanto", "with no landmark table the Gold constants stand in") end -- (engine/pokemon/mon_menu.asm:609-626, engine/events/overworld.asm:556-568). -- the list, and the exit is ExitAllMenus' white (home/map.asm:2281). do local World = require("src.world.gen2.World") local PartyMenu = require("src.ui.gen2.PartyMenu") local function fakeStack() local stack = { states = {}, low = math.huge } function stack:push(state) self.states[#self.states + 1] = state self.low = math.min(self.low, #self.states) end function stack:pop() local state = table.remove(self.states) self.low = math.min(self.low, #self.states) return state end function stack:top() return self.states[#self.states] end function stack:clear() self.states = {} self.low = math.min(self.low, 0) end return stack end local function flyFromParty() local save = visited("SPAWN_NEW_BARK", "SPAWN_VIOLET", "SPAWN_GOLDENROD") save.player = { badges = { STORM = true } } local stack = fakeStack() local game = { save = save, stack = stack, input = fakeInput() } local mon = { species = 17, nickname = "PIDGEOTTO" } local world = setmetatable({ game = game, landmarks = LANDMARKS, map = { def = { landmark = "LANDMARK_NEW_BARK_TOWN" } }, }, World) game.world = world local flown world.flyTo = function(_self, spawnId, who) flown = { spawn = spawnId, mon = who } return true end world.useFieldMove = function(_self, moveId, who) local result = FieldMoves.fromMenu(moveId, { save = save, environment = "TOWN", mon = who }) result.mon = result.mon or who if result.ok then _self.queuedFieldMove = result end return result end local party = setmetatable({ game = game }, PartyMenu) stack:push(party) party:useFieldMove("FLY", mon) return world, party, stack, mon, function() return flown end, game end do local world, party, stack = flyFromParty() eq(#stack.states, 2, "FLY opens a screen OVER the party list") eq(stack.states[1], party, "which is still underneath") eq(stack:top().screenId, "Gen2BlankScreen", "_FlyMap's ClearBGPalettes blanks first") eq(world.queuedFieldMove, nil, "and nothing is queued for the overworld yet") local blank = stack:top() eq(blank.left, 28, "_FlyMap: ClearBGPalettes 4 + ClearTilemap 4 + LoadTownMapGFX 7 + border 1" .. " + TownMapBGUpdate 7 + TownMapMon 3 + TownMapPlayerIcon 2 = 28 white") for _ = 1, 27 do blank:update(0) end eq(stack:top(), blank, "the blank is still up on its 28th drawn frame") blank:update(0) eq(stack:top().screenId, "Gen2Pokegear", "the 28th update hands over: the town map is frame 29") eq(#stack.states, 2, "still over the list") eq(stack.low, 1, "and the stack never emptied on the way in") end do local world, party, stack, mon, _flown, game = flyFromParty() game.save.party = { mon, mon } local blank = stack:top() for _ = 1, blank.left do blank:update(0) end local gear = stack:top() game.input:press("b") gear:update(0) local cancelBlank = stack:top() eq(cancelBlank and cancelBlank.screenId, "Gen2BlankScreen", "B on the fly map: .exit's ClearBGPalettes whites out first") eq(cancelBlank.left, 21 + 3 * 2, "held for .exit 4 + .illegal 8 + .choosemenu 4 + 3 per party icon + 5") eq(#stack.states, 2, "over the party list") eq(stack.states[1], party, "which is still underneath") for _ = 1, cancelBlank.left do cancelBlank:update(0) end eq(stack:top(), party, "then the party list is back") eq(#stack.states, 1, "with nothing else left standing") eq(world.queuedFieldMove, nil, "and no fly queued behind it") eq(world.fade, nil, "nothing fades out for a cancel") end do local world, _party, stack, mon, flown, game = flyFromParty() local blank = stack:top() for _ = 1, blank.left do blank:update(0) end local gear = stack:top() gear.flyIndex = 1 game.input:press("a") gear:update(0) eq(#stack.states, 0, "A takes the spawn and exits the menus") eq(world.queuedFieldMove and world.queuedFieldMove.flySpawn, "SPAWN_NEW_BARK", "with the chosen spawn on the queued script") eq(world.fade, "white", "ExitAllMenus' ClearBGPalettes is already up") eq(world.fadeLevel, 1, "at full white") eq(world.mapSetup and world.mapSetup.phase, "in", "and FadeInFromWhite is armed behind it") eq(world.mapSetup.wait, 31, ".exit 4 + CloseWindow 4 + ExitAllMenus 4 + LCD-off reload 9" .. " + WaitBGMap2 8 + fade entry 6 for 2 = 31 white") for _ = 1, 30 do world:updateMapSetup() end eq(world.fadeLevel, 1, "still full white after 30 updates") world:updateMapSetup() eq(world.fadeLevel, 0.75, "the 31st update starts the ramp") for _ = 1, 2 do world:updateMapSetup() end eq(world.fadeLevel, 0.5, "two frames a level") for _ = 1, 2 do world:updateMapSetup() end eq(world.fadeLevel, 0.25, "...and the third level") for _ = 1, 2 do world:updateMapSetup() end eq(world.fade, nil, "three levels at two frames each, then clear") eq(world.mapSetup, nil, "and the setup chain is done") world.mapSetup = nil local queued = world.queuedFieldMove world.queuedFieldMove = nil world:runFieldMove(queued) eq(flown() and flown().spawn, "SPAWN_NEW_BARK", "the queued fly takes the spawn the picker chose") eq(flown().mon, mon, "on the mon the list picked") end do -- engine/events/overworld.asm:568 local world, _party, _stack, mon, _flown, game = flyFromParty() game.stack = nil local asked = false world.showText = function() asked = true end eq(world:openFlyMap(mon, { onChosen = function() end }), false, "a picker with no screen refuses the menu-side open") eq(asked, false, "and does not ask from under the list") check(world:openFlyMap(mon) == true, "while the queued path still asks") eq(asked, true, "through the same yesorno box") end end S.finish()