-- Gen 2 menu behaviour: the naming screen's keyboard, the intro menu's entries, -- the OPTION rows, the start menu's unlock rules, and the PACK's pockets. -- -- ROM-free and draw-free: every screen's logic is separable from its drawing, so -- this drives them with a stub input and asserts state. What a test cannot say -- -- whether the layout looks right -- is what tests/drivers/gold_menu_shots.lua -- exists for. package.path = "./?.lua;" .. package.path -- The UI modules require love-side helpers at load time. Stub the pieces they -- touch during construction and logic; nothing here draws. local drawn = {} 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) if b then return a end return a and 1 or 0.5 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, write = function() return true end, remove = function() return true end, } love.timer = love.timer or { getTime = function() return 0 end } -- No font is loaded here (these are logic assertions, not rendering ones), so -- Font.encode would warn once per unknown glyph. Quiet it: the noise would -- bury a real failure. require("src.core.Logger").warn = function() end local Chrome = require("src.ui.gen2.Chrome") local MainMenu = require("src.ui.gen2.MainMenu") local NamingScreen = require("src.ui.gen2.NamingScreen") local OptionsMenu = require("src.ui.gen2.OptionsMenu") local PackMenu = require("src.ui.gen2.PackMenu") local Save = require("src.core.gen2.Save") local StartMenu = require("src.ui.gen2.StartMenu") local failures, checks = 0, 0 local function check(name, got, want) checks = checks + 1 if got ~= want then failures = failures + 1 print(("FAIL %s: got %s, want %s"):format( name, tostring(got), tostring(want))) end end -- A stub input: queue presses, then each read consumes them. local function newInput() local input = { pressed = {} } function input:press(...) for _, button in ipairs({ ... }) do self.pressed[button] = true end end function input:wasPressed(button) if self.pressed[button] then self.pressed[button] = nil return true end return false end function input:isDown() return false end return input end local function newGame(save) local input = newInput() return { input = input, 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, }, }, input end -- ------------------------------------------------------------ Chrome.wrap -- Nothing may print past the 160px frame, so long text has to wrap. Without a -- loaded font Font.width falls back to a fixed advance, which is enough to -- assert that wrapping happens at all and never loses a word. local wrapped = Chrome.wrap("What will CYNDAQUIL do?", 8) check("wrap splits", #wrapped > 1, true) local rejoined = table.concat(wrapped, " ") check("wrap loses no words", rejoined, "What will CYNDAQUIL do?") check("empty wraps to nothing", #Chrome.wrap("", 8), 0) check("wrap keeps a single word whole", #Chrome.wrap("CYNDAQUIL", 2), 1) -- Chrome.number pads the way PrintNum does. check("number pads with spaces", Chrome.number(5, 3), " 5") check("number pads with zeroes", Chrome.number(5, 3, true), "005") check("number does not truncate", Chrome.number(1234, 2), "1234") -- ------------------------------------------------------- Chrome.List local chosen, cancelled local list = Chrome.List.new({ items = { "ONE", "TWO", "THREE" }, onChoose = function(value) chosen = value end, onCancel = function() cancelled = true end, }) local input = newInput() check("list starts at 1", list.index, 1) input:press("down") list:update(input) check("down moves", list.index, 2) input:press("up") list:update(input) check("up moves back", list.index, 1) input:press("up") list:update(input) check("up wraps to the end", list.index, 3) input:press("down") list:update(input) check("down wraps to the start", list.index, 1) input:press("a") list:update(input) check("a chooses", chosen, "ONE") input:press("b") list:update(input) check("b cancels", cancelled, true) -- A no-wrap list stops at the ends. local bounded = Chrome.List.new({ items = { "A", "B" }, wrap = false }) input:press("up") bounded:update(input) check("no-wrap stays at the top", bounded.index, 1) -- A list longer than its window scrolls. local scrolling = Chrome.List.new({ items = { "A", "B", "C", "D", "E" }, rows = 3 }) for _ = 1, 3 do input:press("down") scrolling:update(input) end check("scrolled", scrolling.index, 4) check("window followed", scrolling.scroll, 1) -- --------------------------------------------------------- intro menu -- MainMenu_GetWhichMenu: no save file means NEW GAME / OPTION only. local fresh = MainMenu.new(newGame(nil), { hasSave = false, save = false }) -- Plus the port's own EXIT GAME row, which the cart has no equivalent for. check("no save has three entries", #fresh.list.items, 3) check("first entry is NEW GAME", fresh.list.items[1].value, "new") check("second is OPTION", fresh.list.items[2].value, "option") check("last is EXIT GAME", fresh.list.items[3].value, "exit") local withSave = MainMenu.new(newGame(Save.newGame()), { hasSave = true, save = Save.newGame() }) check("a save adds CONTINUE", #withSave.list.items, 4) check("CONTINUE is first", withSave.list.items[1].value, "continue") -- MenuHeader's default option is the first entry, so a save lands on CONTINUE. check("default lands on CONTINUE", withSave.list.index, 1) -- CONTINUE shows the save panel first and only then hands off; B backs out. local continued local confirmGame, confirmInput = newGame(Save.newGame()) local confirm = MainMenu.new(confirmGame, { hasSave = true, save = Save.newGame(), onContinue = function() continued = true end, }) confirmInput:press("a") confirm:update(0) check("A opens the save panel", confirm.phase, "confirm") check("no hand-off yet", continued, nil) confirm.confirmDelay = 0 confirmInput:press("b") confirm:update(0) check("B returns to the menu", confirm.phase, "menu") confirmInput:press("a") confirm:update(0) confirm.confirmDelay = 0 confirmInput:press("a") confirm:update(0) check("A confirms", continued, true) -- The clock box formats 12-hour time with an AM/PM half. check("midnight is 12 AM", (function() local m = MainMenu.new(newGame(nil), { hasSave = false, save = false, clock = { hour = 0, minute = 0, weekday = 1 } }) local hour = select(1, m:clockParts()) local display = hour % 12 if display == 0 then display = 12 end return display .. (hour < 12 and " AM" or " PM") end)(), "12 AM") check("weekday names", MainMenu.DAYS[6], "FRIDAY") -- ------------------------------------------------------- naming screen local namingGame, namingInput = newGame(Save.newGame()) local naming = NamingScreen.new(namingGame, { type = "player" }) check("player field is 7 long", naming.maxLength, 7) check("starts uppercase", naming.lower, false) check("cursor starts at A", naming:cursorCharacter(), "A") -- The grid is nine wide; right wraps. namingInput:press("right") naming:update(0) check("moved right", naming:cursorCharacter(), "B") naming.col = 8 namingInput:press("right") naming:update(0) check("right wraps to the start", naming.col, 0) namingInput:press("left") naming:update(0) check("left wraps to the end", naming.col, 8) -- Typing appends; B deletes rather than cancelling. naming.col, naming.row = 0, 0 namingInput:press("a") naming:update(0) check("typed A", naming.text, "A") namingInput:press("right") naming:update(0) namingInput:press("a") naming:update(0) check("typed AB", naming.text, "AB") namingInput:press("b") naming:update(0) check("B deleted", naming.text, "A") namingInput:press("b") naming:update(0) namingInput:press("b") naming:update(0) check("B on an empty field is safe", naming.text, "") -- SELECT toggles case; the same cell then types lowercase. namingInput:press("select") naming:update(0) check("now lowercase", naming.lower, true) naming.col, naming.row = 0, 0 namingInput:press("a") naming:update(0) check("typed a", naming.text, "a") check("lower label says UPPER", naming:cursorCharacter(), "a") -- The bottom row's three fat targets: case / DEL / END. naming.lower = false naming.row = naming:bottomRow() naming.col = 0 check("target 1 is the case switch", naming:bottomTarget(), 1) check("cursor reads CASE", naming:cursorCharacter(), "CASE") naming.col = 3 check("target 2 is DEL", naming:bottomTarget(), 2) naming.col = 6 check("target 3 is END", naming:bottomTarget(), 3) -- Left/right hop between targets rather than stepping a column at a time. namingInput:press("left") naming:update(0) check("left hops a whole target", naming:bottomTarget(), 2) namingInput:press("right") naming:update(0) check("right hops back", naming:bottomTarget(), 3) -- START parks the cursor on END, wherever it was. naming.row, naming.col = 0, 0 namingInput:press("start") naming:update(0) check("START jumps to END row", naming.row, naming:bottomRow()) check("START jumps to END target", naming:bottomTarget(), 3) -- END hands the typed name back. local finished local endGame, endInput = newGame(Save.newGame()) local ending = NamingScreen.new(endGame, { type = "player", onDone = function(name) finished = name end, }) ending.text = "GOLD" ending.row = ending:bottomRow() ending.col = 6 endInput:press("a") ending:update(0) check("END returned the name", finished, "GOLD") -- Filling the last slot does NOT end entry: `.a` is `ret nc` and -- AdvanceCursor_CheckEndOfString answers carry once the buffer is full, so the -- handler falls through into `.start` and parks the cursor on END with the -- screen still up (engine/menus/naming_screen.asm:401-410). Only `.end` calls -- StoreEntry. This case used to assert the auto-accept the port did instead. local autoDone local autoGame, autoInput = newGame(Save.newGame()) local auto = NamingScreen.new(autoGame, { type = "player", onDone = function(name) autoDone = name end, }) auto.text = "ABCDEF" -- one short of 7 auto.row, auto.col = 0, 0 autoInput:press("a") auto:update(0) check("the seventh character is typed", auto.text, "ABCDEFA") check("but entry is not over", autoDone, nil) check("the cursor parks on the END row", auto.row, auto:bottomRow()) check("on END itself", auto:cursorCharacter(), "END") -- A further letter press is the `cp c / ret nc` no-op, and A on END is what -- finally hands the name back. auto.row, auto.col = 0, 0 autoInput:press("a") auto:update(0) check("a full buffer takes no more letters", auto.text, "ABCDEFA") auto.row, auto.col = auto:bottomRow(), 6 autoInput:press("a") auto:update(0) check("A on END stores the entry", autoDone, "ABCDEFA") -- The blank cells are real spaces, not dead keys: the NameInput* rows are -- written into the tilemap and GetLastCharacter reads the tile under the cursor -- back out, so the trailing blanks of "S T U V W X Y Z " type a space -- (data/text/name_input_chars.asm, engine/menus/naming_screen.asm -- GetLastCharacter). local spaceGame, spaceInput = newGame(Save.newGame()) local spacer = NamingScreen.new(spaceGame, { type = "player" }) spacer.text = "AB" spacer.row, spacer.col = 2, 8 -- the blank after Z check("the cell after Z is a space", spacer:cursorCharacter(), " ") spaceInput:press("a") spacer:update(0) check("and pressing A types it", spacer.text, "AB ") -- A box name is longer and gets an extra keyboard row. local box = NamingScreen.new(newGame(Save.newGame()), { type = "box" }) check("box field is 8 long", box.maxLength, 8) check("box has six rows", box:bottomRow(), 5) check("box keyboard starts higher", box:keyboardTop(), 6) check("name keyboard start", naming:keyboardTop(), 8) -- ------------------------------------------------------------- options local optionsGame, optionsInput = newGame(Save.newGame()) local options = OptionsMenu.new(optionsGame, { options = Save.defaultOptions(), }) -- The cart's seven value rows, then the port's audio, speed and display -- rows, then CANCEL -- which is what makes this screen scroll. check("sixteen rows", #OptionsMenu.ROWS, 16) check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame") check("then the port's audio group", OptionsMenu.ROWS[8].key, "musicVol") check("last row is CANCEL", OptionsMenu.ROWS[#OptionsMenu.ROWS].cancel, true) check("starts on TEXT SPEED", options:row().key, "textSpeed") check("default text speed", options.options.textSpeed, "MID") optionsInput:press("right") options:update(0) check("right cycles forward", options.options.textSpeed, "SLOW") optionsInput:press("right") options:update(0) check("right wraps", options.options.textSpeed, "FAST") optionsInput:press("left") options:update(0) check("left wraps back", options.options.textSpeed, "SLOW") optionsInput:press("down") options:update(0) check("down moves a row", options:row().key, "battleScene") optionsInput:press("right") options:update(0) check("battle scene toggles off", options.options.battleScene, false) -- FRAME is 1-8 and wraps. options.index = 7 check("frame row", options:row().frame, true) options.options.frame = 8 optionsInput:press("right") options:update(0) check("frame wraps to 1", options.options.frame, 1) optionsInput:press("left") options:update(0) check("frame wraps to 8", options.options.frame, 8) -- options_menu.asm:475 UpdateFrame calls LoadFontsExtra, so the value is live. check("frame reaches the font", require("src.render.Font").frameIndex(), 8) -- CANCEL and START both leave, handing the edited table back. local savedOptions local exitGame, exitInput = newGame(Save.newGame()) local exiting = OptionsMenu.new(exitGame, { options = Save.defaultOptions(), onDone = function(o) savedOptions = o end, }) exiting.index = #OptionsMenu.ROWS exitInput:press("a") exiting:update(0) check("CANCEL leaves", savedOptions ~= nil, true) -- --------------------------------------------------------- start menu local save = Save.newGame() local bare = StartMenu.new(newGame(save), { save = save }) -- With no party, no dex and no Pokegear, only PACK and the always-on entries. local function labels(menu) local out = {} for _, item in ipairs(menu.items) do out[#out + 1] = item.value end return table.concat(out, ",") end check("bare start menu", labels(bare), "pack,status,save,option,quit") save.party = { { species = "CYNDAQUIL" } } save.pokedexReceived = true save.inventory = { POKEGEAR = 1 } local full = StartMenu.new(newGame(save), { save = save }) check("unlocked start menu", labels(full), "pokedex,pokemon,pack,pokegear,status,save,option,quit") -- The player's own name is the STATUS entry's label. save.player.name = "SILVER" local named = StartMenu.new(newGame(save), { save = save }) for _, item in ipairs(named.items) do if item.value == "status" then check("status label is the player name", item.label, "SILVER") end end -- MENU ACCOUNT off hides the description box. save.options = Save.defaultOptions() save.options.menuAccount = false local quiet = StartMenu.new(newGame(save), { save = save }) check("description hidden", quiet.showDescription, false) save.options.menuAccount = true check("description shown", StartMenu.new(newGame(save), { save = save }).showDescription, true) -- ---------------------------------------------------------------- pack local packSave = Save.newGame() packSave.inventory = { POTION = 3, POKE_BALL = 5, BICYCLE = 1, TM_HEADBUTT = 1, } local packGame, packInput = newGame(packSave) packGame.data.items = { POTION = { id = "POTION", name = "POTION", pocket = "ITEM", index = 1 }, POKE_BALL = { id = "POKE_BALL", name = "POKé BALL", pocket = "BALL", index = 2 }, BICYCLE = { id = "BICYCLE", name = "BICYCLE", pocket = "KEY_ITEM", index = 3 }, TM_HEADBUTT = { id = "TM_HEADBUTT", name = "TM02", pocket = "TM_HM", index = 4, teaches = "HEADBUTT", tmNumber = 2 }, } local pack = PackMenu.new(packGame, { pocket = "ITEM" }) check("four pockets", #PackMenu.POCKETS, 4) check("items pocket has the potion", pack.rows[1].id, "POTION") check("items pocket has only it", #pack.rows, 1) check("potion shows a count", pack.rows[1].showCount, true) packInput:press("right") pack:update(0) check("right switches pocket", pack:pocket().id, "BALL") check("balls pocket", pack.rows[1].id, "POKE_BALL") packInput:press("right") pack:update(0) check("next is key items", pack:pocket().id, "KEY_ITEM") check("key items show no count", pack.rows[1].showCount, false) packInput:press("right") pack:update(0) check("then TM/HM", pack:pocket().id, "TM_HM") check("TM row names its move", pack.rows[1].teaches, "HEADBUTT") packInput:press("right") pack:update(0) check("pocket wraps around", pack:pocket().id, "ITEM") -- CANCEL sits one past the last row. check("cancel is past the end", pack:total(), #pack.rows + 1) pack.index = pack:total() check("on cancel", pack:isCancel(), true) -- ---- the item submenu (.ItemBallsKey_LoadSubmenu, engine/items/pack.asm:243) -- -- A on a row picks the row; it does not use it. Without this menu there is no -- TOSS anywhere in the PACK, which is the bug this block pins. do local items = { POTION = { id = "POTION", name = "POTION", pocket = "ITEM", index = 1, canToss = true, canSelect = false, fieldMenu = "ITEMMENU_PARTY" }, -- ITEMMENU_NOUSE + tossable: MenuHeader_HoldableItem, no USE row at all. BERRY = { id = "BERRY", name = "BERRY", pocket = "ITEM", index = 2, canToss = true, canSelect = false, fieldMenu = "ITEMMENU_NOUSE" }, -- CANT_TOSS + selectable: MenuHeader_UnusableKeyItem. BICYCLE = { id = "BICYCLE", name = "BICYCLE", pocket = "KEY_ITEM", index = 3, canToss = false, canSelect = true, fieldMenu = "ITEMMENU_CLOSE" }, -- CANT_TOSS + not selectable: MenuHeader_UnusableItem. SECRETPOTION = { id = "SECRETPOTION", name = "SECRETPOTION", pocket = "KEY_ITEM", index = 4, canToss = false, canSelect = false, fieldMenu = "ITEMMENU_NOUSE" }, HM_CUT = { id = "HM_CUT", name = "HM01", pocket = "TM_HM", index = 5, canToss = false, canSelect = false, teaches = "CUT" }, TM_HEADBUTT = { id = "TM_HEADBUTT", name = "TM02", pocket = "TM_HM", index = 6, canToss = true, canSelect = false, teaches = "HEADBUTT" }, } local save = Save.newGame() save.inventory = { POTION = 5, BERRY = 1, BICYCLE = 1, SECRETPOTION = 1, HM_CUT = 1, TM_HEADBUTT = 1 } local game, input = newGame(save) game.data.items = items game.data.moves = { CUT = { name = "CUT" }, HEADBUTT = { name = "HEADBUTT" } } -- A world with a useFieldItem is what makes this a field PACK rather than a -- chooser: the mart's SELL and the item PC's DEPOSIT pass `world = {}`. game.world = { useFieldItem = function() return nil end } local menu = PackMenu.new(game, { pocket = "ITEM" }) local function rows(id) return table.concat(menu:submenuRows(id), ",") end check("a usable tossable item", rows("POTION"), "use,give,toss,quit") check("an unusable tossable item", rows("BERRY"), "give,toss,quit") check("a registerable key item", rows("BICYCLE"), "use,sel,quit") check("a plain key item", rows("SECRETPOTION"), "use,quit") menu.pocketIndex = 4 check("an HM", rows("HM_CUT"), "use,quit") check("a TM", rows("TM_HEADBUTT"), "use,give,quit") menu.pocketIndex = 1 menu:rebuild() menu.index = 1 check("the row under the cursor", menu.rows[1].id, "POTION") input:press("a") menu:update(0) check("A opens the submenu", menu.submenu ~= nil, true) check("on USE", menu.submenu.rows[menu.submenu.index], "use") check("and uses nothing yet", save.inventory.POTION, 5) -- TOSS: "Throw away how many?", the count, the yes/no, then TossItem. input:press("down") menu:update(0) input:press("down") menu:update(0) check("down twice reaches TOSS", menu.submenu.rows[menu.submenu.index], "toss") input:press("a") menu:update(0) check("TOSS asks how many", menu.message and menu.message[1], "Throw away how") check("starting at one", menu.qtyState and menu.qtyState.qty, 1) input:press("up") menu:update(0) check("up steps the count", menu.qtyState.qty, 2) input:press("a") menu:update(0) check("A asks to confirm", menu.confirm and menu.confirm.prompt[1], "Throw away 2") check("naming the item", menu.confirm.prompt[2], "POTION(S)?") check("and still nothing tossed", save.inventory.POTION, 5) input:press("down") menu:update(0) input:press("a") menu:update(0) check("NO keeps the item", save.inventory.POTION, 5) -- ...and YES spends it. input:press("a") menu:update(0) input:press("down") menu:update(0) input:press("down") menu:update(0) input:press("a") menu:update(0) input:press("a") menu:update(0) input:press("a") menu:update(0) check("YES tosses the count", save.inventory.POTION, 4) check("with _ThrewAwayText", menu.message and menu.message[1], "Threw away") -- B out of the submenu is QuitItemSubmenu: nothing happens at all. menu.message = nil input:press("a") menu:update(0) input:press("b") menu:update(0) check("B closes the submenu", menu.submenu, nil) check("spending nothing", save.inventory.POTION, 4) -- GIVE is the party list under PARTYMENUACTION_GIVE_ITEM, then the same -- TryGiveItemToPartymon the party's own GIVE row runs. save.party = { { species = "CYNDAQUIL", nickname = "CYNDA", hp = 20, maxHp = 20, level = 5 } } menu.index = 1 input:press("a") menu:update(0) input:press("down") menu:update(0) check("down once reaches GIVE", menu.submenu.rows[menu.submenu.index], "give") input:press("a") menu:update(0) check("GIVE opens the party list", game.stack:top().prompt, "To which ?") input:press("a") game.stack:top():update(0) input:press("a") game.stack:top():update(0) check("the mon is holding it", save.party[1].item, "POTION") check("and the bag is one lighter", save.inventory.POTION, 3) while game.stack:top() do game.stack:pop() end -- A chooser PACK still answers its caller on the first press: DepositSellPack -- and TutorialPack have no submenu on the cart. local chosen = nil local chooser = PackMenu.new(game, { pocket = "ITEM", give = true, onChoose = function(id) chosen = id end }) chooser.index = 1 input:press("a") chooser:update(0) check("a chooser opens no submenu", chooser.submenu, nil) check("and answers straight away", chosen, "POTION") end -- ------------------------------------------------------------------- #DEX -- -- The dex's numbers are PrintNum fields, and getting them wrong is what put -- "1.08" where the cart prints 1'08". The height word is four digits with -- two in front of the point; the weight word is five with four in front; and -- both blank their leading zeroes rather than printing them. local PokedexMenu = require("src.ui.gen2.PokedexMenu") local num = PokedexMenu.printNumString check("3-digit count is space padded", num(7, 3), " 7") check("leading zeros when asked", num(7, 3, true), "007") check("a full field is untouched", num(250, 3), "250") check("Bulbasaur is 2'04\"", num(204, 4, false, 2), " 2.04") check("Mewtwo is 6'07\"", num(607, 4, false, 2), " 6.07") check("a ten-foot mon keeps both digits", num(1300, 4, false, 2), "13.00") check("Bulbasaur weighs 15.0", num(150, 5, false, 4), " 15.0") check("Mewtwo weighs 269.0", num(2690, 5, false, 4), " 269.0") -- .PrintDigit forces the digit in front of the point, so a sub-pound mon -- reads 0.1 rather than losing its zero. check("a light mon keeps its tenth", num(1, 5, false, 4), " 0.1") check("a short mon keeps its zero feet", num(3, 4, false, 2), " 0.03") -- The listing lives on the window layer, and OLD mode is the only one that -- moves it (hWX $4a rather than $47) or prints dex numbers. local dexSave = Save.newGame("GOLD") dexSave.pokedex = { seen = {}, caught = {} } local dexGame = newGame(dexSave) dexGame.data.gen2Pokedex = { entries = { BULBASAUR = { dex = 1, kind = "SEED", height = 204, weight = 150, text = "abc", text2 = "de" }, IVYSAUR = { dex = 2, kind = "SEED", height = 303, weight = 290 }, }, newOrder = { "BULBASAUR", "IVYSAUR" }, alphabeticalOrder = { "BULBASAUR", "IVYSAUR" }, } local dex = PokedexMenu.new(dexGame, {}) check("dex lists every entry", #dex.rows, 2) check("dex starts in NEW mode", dex:mode(), "NEW") -- Pokedex_UpdateMainScreen: SELECT opens the OPTION screen and START the -- SEARCH screen. Neither cycles anything in place -- the mode changes when -- the OPTION screen's own cursor picks one and A confirms it. check("SELECT opens the OPTION screen", (function() dexGame.input:press("select") dex:update(0) return dex.view end)(), "option") check("...with the cursor on the current mode", dex.optionIndex, 1) check("moving to OLD and confirming changes the mode", (function() dexGame.input:press("down") dex:update(0) dexGame.input:press("a") dex:update(0) return dex:mode() end)(), "OLD") check("...and closes the OPTION screen", dex.view, "list") check("START opens the SEARCH screen", (function() dexGame.input:press("start") dex:update(0) return dex.view end)(), "search") -- Pokedex_InitSearchScreen: TYPE1 starts on NORMAL and TYPE2 on "-----". check("TYPE1 starts on NORMAL", dex:searchTypeName(1), "NORMAL") check("TYPE2 starts blank", dex:searchTypeName(2), "-----") check("B leaves the SEARCH screen", (function() dexGame.input:press("b") dex:update(0) return dex.view end)(), "list") -- Back to NEW so the assertions below are unaffected. dex.modeIndex = 1 dex:rebuild() -- A only opens the entry for a mon that has been seen (Pokedex_UpdateMainScreen -- returns early otherwise), and once open, left/right flips the two pages. dexGame.input:press("a") dex:update(0) check("an unseen mon has no entry to open", dex.view, "list") dexSave.pokedex.seen.BULBASAUR = true dex:rebuild() dex.index = 1 dexGame.input:press("a") dex:update(0) check("a seen mon opens", dex.view, "entry") check("on page 1", dex.page, 1) -- DexEntryScreen_ArrowCursorData: LEFT/RIGHT walk an arrow across PAGE, AREA, -- CRY and PRNT, and A runs whichever it is parked on. This block used to press -- RIGHT and expect the page to flip, which was the port taking a shortcut -- -- and that shortcut is exactly why AREA could never be opened. check("the cursor starts on PAGE", dex.entryAction, 1) dexGame.input:press("right") dex:update(0) check("RIGHT walks the cursor, it does not flip the page", dex.page, 1) check("the cursor is on AREA", dex.entryAction, 2) dexGame.input:press("a") dex:update(0) check("A on AREA opens the nest map", dex.view, "area") dexGame.input:press("b") dex:update(0) check("B backs out of AREA to the entry", dex.view, "entry") dexGame.input:press("left") dex:update(0) check("LEFT walks it back to PAGE", dex.entryAction, 1) dexGame.input:press("a") dex:update(0) check("A on PAGE flips to 2", dex.page, 2) dexGame.input:press("b") dex:update(0) check("B backs out to the listing", dex.view, "list") -- ------------------------------------------------------------- QUIT / EXIT -- The start menu's last row is the port's QUIT, and it asks before throwing -- away everything since the last save. NO is the default. local quitSave = Save.newGame("GOLD") local quitGame, quitInput = newGame(quitSave) local returned = false quitGame.returnToTitle = function() returned = true end local quitMenu = StartMenu.new(quitGame, { save = quitSave }) quitMenu.list.index = #quitMenu.items check("last row is QUIT", quitMenu.items[#quitMenu.items].value, "quit") quitInput:press("a") quitMenu:update(0) check("QUIT asks first", quitMenu.phase, "confirm") check("and defaults to NO", quitMenu.confirmChoice, 2) check("nothing has happened yet", returned, false) quitInput:press("a") quitMenu:update(0) check("NO backs out", quitMenu.phase, nil) check("still nothing", returned, false) quitInput:press("a") quitMenu:update(0) quitInput:press("up") quitMenu:update(0) check("up selects YES", quitMenu.confirmChoice, 1) quitInput:press("a") quitMenu:update(0) check("YES returns to the title", returned, true) -- B out of the confirmation is NO as well. local backGame, backInput = newGame(quitSave) backGame.returnToTitle = function() returned = "again" end local backMenu = StartMenu.new(backGame, { save = quitSave }) backMenu.phase = "confirm" backMenu.confirmChoice = 1 backInput:press("b") backMenu:update(0) check("B is NO", backMenu.phase, nil) check("B ran nothing", returned, true) -- EXIT GAME on the intro menu leaves through the host rather than the cart. local exited = false local exitMenuGame, exitMenuInput = newGame(nil) local exitMenu = MainMenu.new(exitMenuGame, { hasSave = false, save = false, onExit = function() exited = true end, }) exitMenu.list.index = #exitMenu.list.items exitMenuInput:press("a") exitMenu:update(0) check("EXIT GAME quits", exited, true) -- ----------------------------------------------------- scrolling OPTION -- Twelve rows do not fit on an 18-row screen, so the screen scrolls: the -- window only moves once the cursor would leave it. local scrollGame, scrollInput = newGame(Save.newGame()) local scrollOptions = OptionsMenu.new(scrollGame, { options = Save.defaultOptions(), }) check("starts unscrolled", scrollOptions.scroll, 0) for _ = 1, 7 do scrollInput:press("down") scrollOptions:update(0) end check("cursor moved", scrollOptions.index, 8) check("window followed by one", scrollOptions.scroll, 1) scrollOptions.index = 1 scrollOptions:ensureVisible() check("back to the top", scrollOptions.scroll, 0) -- The port rows step their own shared module rather than a values list. local function rowNamed(label) for i, row in ipairs(OptionsMenu.ROWS) do if row.label == label then return i, row end end end local speedRow = select(2, rowNamed("GAME SPEED")) scrollOptions:cycle(speedRow, 1) check("speed left NORMAL", scrollOptions.options.speed ~= 1, true) local volRow = select(2, rowNamed("MUSIC VOL")) scrollOptions:cycle(volRow, -1) check("music volume stepped down", scrollOptions.options.musicVol, 6) for _ = 1, 10 do scrollOptions:cycle(volRow, -1) end check("and clamps at OFF rather than wrapping", scrollOptions.options.musicVol, 0) local filterRow = select(2, rowNamed("MUSIC FILTER")) scrollOptions:cycle(filterRow, 1) check("filter steps to 1X", scrollOptions.options.musicFilter, 1) for _ = 1, 3 do scrollOptions:cycle(filterRow, 1) end check("and wraps back to OFF", scrollOptions.options.musicFilter, 0) -- COLOR: the Gen 2 answer to the Gen 1 screen's COLORS row. GBC is the -- default because Gold IS a colour game -- the other rungs turn it off. local GbcPalette = require("src.render.GbcPalette") local colorRow = select(2, rowNamed("COLOR")) check("COLOR is a row", colorRow ~= nil, true) check("and it defaults to the cart's own colour", Save.DEFAULT_OPTIONS.color, "gbc") scrollOptions.options.color = "gbc" scrollOptions:cycle(colorRow, 1) check("right steps to DMG", scrollOptions.options.color, "dmg") scrollOptions:cycle(colorRow, 1) check("then CLASSIC", scrollOptions.options.color, "classic") scrollOptions:cycle(colorRow, 1) check("and wraps back to GBC", scrollOptions.options.color, "gbc") scrollOptions:cycle(colorRow, -1) check("left walks the ladder the other way", scrollOptions.options.color, "classic") check("CLASSIC is the only mode with a present pass", GbcPalette.presentColors() ~= nil, true) GbcPalette.setMode("dmg") check("DMG substitutes the hardware shades for any palette", GbcPalette.color({ { 1, 2, 3 }, { 4, 5, 6 }, { 7, 8, 9 }, { 10, 11, 12 } }, 1)[1], 255) GbcPalette.setMode("gbc") check("GBC leaves a palette alone", GbcPalette.color({ { 1, 2, 3 }, { 4, 5, 6 }, { 7, 8, 9 }, { 10, 11, 12 } }, 1)[1], 1) check("and has no present pass", GbcPalette.presentColors(), nil) -- ------------------------------------------------------------- name picker -- -- NameMenuHeader's coordinates, which is the whole point of this screen: the -- box, the label origin GetMenuTextStartCoord derives from its flags, and the -- pic walk NamePlayer opens with. local NamePick = require("src.ui.gen2.NamePick") local pickInput = newInput() local pickGame = { input = pickInput, data = {}, stack = { push = function() end, pop = function() end, top = function() end }, } local picked local pick = NamePick.new(pickGame, { onDone = function(name) picked = name end, }) check("five items: NEW NAME and Gold's four presets", #pick.items, 5) check("NEW NAME first", pick.items[1], "NEW NAME") check("then the PlayerNameArray order", pick.items[2] .. pick.items[5], "GOLDKARL") check("default option 1 is NEW NAME", pick.cursor, 1) check("the pic starts where Oak left it", pick.picX, 6) -- MovePlayerPicRight is a blocking loop: no input until it lands on 13. pickInput:press("down") pick:update(0) check("input is ignored while the pic walks", pick.cursor, 1) -- The stub latches a press until something reads it, so drop it by hand -- -- otherwise it fires the instant the walk ends and the rest of this reads a -- cursor one row further on than it looks. pickInput.pressed = {} for _ = 1, 20 do pick:update(0) end check("and it stops at hlcoord 13", pick.picX, 13) check("the menu is up once the walk is done", pick.slide, nil) pickInput:press("down") pick:update(0) check("now the cursor moves", pick.cursor, 2) pickInput:press("a") pick:update(0) check("a preset walks the pic back before answering", pick.slide, "out") check("and answers nothing yet", picked, nil) for _ = 1, 20 do pick:update(0) end check("the pic returns to hlcoord 6", pick.picX, 6) check("and the name is handed over", picked, "GOLD") -- STATICMENU_DISABLE_B: there is no way out of this menu but choosing. local pick2 = NamePick.new(pickGame, { onDone = function() picked = "B" end }) for _ = 1, 20 do pick2:update(0) end pickInput:press("b") pick2:update(0) check("B does nothing", picked, "GOLD") -- ============================================================= POKeGEAR -- -- The radio's shows and the town map's cursor, both transcribed from -- engine/pokegear/radio.asm and engine/pokegear/pokegear.asm. The shows are -- state machines with their own random rolls, so everything below seeds the -- roll source and asserts the exact line sequence that comes out; the cursor -- is a landmark INDEX walked between two limit registers, so everything below -- asserts which landmark a d-pad press lands on. local Pokegear = require("src.ui.gen2.Pokegear") -- `call Random` yields one byte. This hands back the listed bytes in order -- and then starts the list again, so a test only has to name the rolls one -- pass through a show actually spends. It cycles rather than repeating the -- last byte because the shows sample by rejection: a source stuck on one -- value that every sampler rejects would never terminate. local function rolls(...) local list = { ... } local index = 0 return function() index = index % #list + 1 return list[index] end end -- Oak's Pokemon Talk indexes the grass table by time of day (0 morn, 1 day, -- 2 nite) and then by one of the middle three of the seven slots, so the -- fixture makes the third slot of each block distinct: a wrong index shows up -- as the wrong species rather than as no species. local radioClasses = {} for i = 1, 66 do radioClasses[i] = { name = "CLASS" .. i, trainer = "T" .. i } end local radioData = { landmarks = { [2] = { name = "ROUTE 29" }, [46] = { name = "PALLET TOWN" } }, mapLandmark = { ROUTE_29 = 2, PALLET_TOWN = 46 }, grass = { ROUTE_29 = { [0] = { "RATTATA", "SENTRET", "LEDYBA", "PIDGEY", "PIDGEY", "PIDGEY", "PIDGEY" }, [1] = { "RATTATA", "SENTRET", "SPINARAK", "PIDGEY", "PIDGEY", "PIDGEY", "PIDGEY" }, [2] = { "RATTATA", "SENTRET", "HOOTHOOT", "PIDGEY", "PIDGEY", "PIDGEY", "PIDGEY" }, } }, species = { [16] = "PIDGEY" }, caught = function(name) return name == "PIDGEY" end, dex = { PIDGEY = { kind = "TINY BIRD", lines = { "It usually hides", "in tall grass. Be-", "cause it dislikes", "fighting, it pro-", "tects itself by", "kicking up sand." } } }, classes = radioClasses, -- CLASS2 stands in for PnP_HiddenPeople: the show must roll past it. hidden = { [2] = true }, weekday = 1, luckyNumber = 42, } -- A copy with one field changed, so a test can vary the world without the -- other tests seeing it. local function radioDataWith(overrides) local copy = {} for key, value in pairs(radioData) do copy[key] = value end for key, value in pairs(overrides) do copy[key] = value end return copy end -- PrintRadioLine parks 100 frames on wRadioTextDelay and RadioScroll burns -- them one per frame, so a line lands roughly every 102 steps. Step until the -- show has printed as many lines as the test wants, with a cap so a machine -- that has stopped advancing fails instead of hanging. local function runRadio(station, rng, lines, data) local radio = Pokegear.Radio.new({ data = data or radioData, rng = rng }) radio:tune(station) for _ = 1, (lines + 2) * 200 do if #radio.log >= lines then break end radio:step() end return radio end local function checkLines(name, radio, want) check(name .. " line count", #radio.log >= #want, true) for index, line in ipairs(want) do check(("%s line %d"):format(name, index), radio.log[index], line) end end -- ------------------------------------------------- Oak's Pokemon Talk -- -- Rolls per wild-mon segment: the route, the time of day, the grass slot. -- Then one for OaksPKMNTalk8's adverb and one for OaksPKMNTalk9's adjective. -- 0/1/2 picks OaksPKMNTalkRoutes' first route (ROUTE_29), the DAY block and -- the first of the three legal slots; 0/0 picks the first adverb and the first -- adjective. local optRadio = runRadio("OAKS_POKEMON_TALK", rolls(0, 1, 2, 0, 0), 9) checkLines("OPT", optRadio, { "MARY: PROF.OAK'S", "POKéMON TALK!", "With me, MARY!", "OAK: SPINARAK", "may be seen around", "ROUTE 29.", "MARY: SPINARAK's", "sweet and adorably", "cute.", }) -- StartRadioStation reaches RadioChannelSongs with the station id still in -- wCurRadioLine, so tuning is what starts the song. check("OPT starts its own song", optRadio.music, "Music_ProfOaksPokemonTalk") -- The rolls reject rather than wrap. A route byte of 15 or more is thrown -- away (`cp 15` after `and %11111`), a daytime of 3 is DARKNESS_F, and a slot -- below 2 or of 5 and up is outside the middle three -- so this sequence -- burns three rejects before landing on exactly the same line as above. local optReject = runRadio("OAKS_POKEMON_TALK", rolls(20, 0, 3, 1, 7, 2, 0, 0), 4) check("OPT rerolls a route past the table", optReject.log[4], "OAK: SPINARAK") -- wOaksPKMNTalkSegmentCounter counts five wild-mon segments and then hands -- over to the Pokemon Channel jingle, which is NOT a scrolled radio line: it -- redraws the box and stamps two more strings into it with PlaceRadioString. local optJingle = runRadio("OAKS_POKEMON_TALK", rolls(0, 1, 2, 0, 0), 37) check("OPT runs five wild-mon segments", optJingle.log[33], "cute.") check("then the jingle takes the box", optJingle.log[34], "POKéMON") -- `hlcoord 9, 14` is eight cells right of the box's own first column. check("and stamps the second at column 9", optJingle.log[35], "POKéMON POKéMON") check("with the channel name below it", optJingle.log[36], "POKéMON Channel") -- OaksPKMNTalk14 zeroes wNumRadioLinesPrinted and names OAKS_POKEMON_TALK_4, -- so the show resumes at the wild mon rather than at MARY's intro. check("then it drops back into the wild-mon segment", optJingle.log[37], "OAK: SPINARAK") -- ------------------------------------------------------- Pokedex Show -- -- A byte of 15 is species index 16 (PIDGEY, the only caught mon in the -- fixture); the `inc c` after CheckCaughtMon is what makes the roll one-based. local dexRadio = runRadio("POKEDEX_SHOW", rolls(15), 9) checkLines("dex show", dexRadio, { "PIDGEY", "TINY BIRD", "It usually hides", "in tall grass. Be-", "cause it dislikes", "fighting, it pro-", "tects itself by", "kicking up sand.", "PIDGEY", }) -- CheckCaughtMon rejects anything the player has not caught, so an uncaught -- roll is spent and the loop goes round again. local dexReject = runRadio("POKEDEX_SHOW", rolls(0, 15), 1) check("dex show skips an uncaught roll", dexReject.log[1], "PIDGEY") -- --------------------------------------- Pokemon Music / Let's All Sing -- -- FernMonMusic2 names POKEMON_MUSIC_4, not a LETS_ALL_SING segment: Kanto's -- station hands over to Johto's code after two lines and both DJs read the -- same closing three. local singRadio = runRadio("LETS_ALL_SING", rolls(0), 5, radioDataWith({ weekday = 2 })) checkLines("let's all sing", singRadio, { "FERN: POKéMUSIC!", "With DJ FERN!", "Today's TUESDAY,", "so let us jam to", "POKéMON March!", }) check("an even weekday marches", singRadio.music, "Music_PokemonMarch") -- BenFernMusic7 is a bare `ret`: the show stops talking and plays out. The -- scroll after its last line still runs, so that line ends up on the box's -- TOP row with nothing under it, and there it stays. for _ = 1, 500 do singRadio:step() end check("and the show ends there", singRadio.cur, "POKEMON_MUSIC_7") check("nothing follows it", singRadio.log[6], nil) check("and the last line stays up", singRadio.top, "POKéMON March!") check("with the bottom row left clear", singRadio.bottom, "") local benRadio = runRadio("POKEMON_MUSIC", rolls(0), 6, radioDataWith({ weekday = 3 })) checkLines("pokemon music", benRadio, { "BEN: POKéMON MUSIC", "CHANNEL!", "It's me, DJ BEN!", "Today's WEDNESDAY,", "so chill out to", "POKéMON Lullaby!", }) check("an odd weekday lulls", benRadio.music, "Music_PokemonLullaby") -- ---------------------------------------------------- Lucky Number Show -- -- LuckyNumberShow13's `call Random / and a` only branches on a rolled zero, so -- anything else restarts the show and one byte in 256 gets the drag lines. local luckyRadio = runRadio("LUCKY_CHANNEL", rolls(1), 14) checkLines("lucky channel", luckyRadio, { "REED: Yeehaw! How", "y'all doin' now?", "Whether you're up", "or way down low,", "don't you miss the", "LUCKY NUMBER SHOW!", "This week's Lucky", "Number is 00042!", "I'll repeat that!", "This week's Lucky", "Number is 00042!", "Match it and go to", "the RADIO TOWER!", "REED: Yeehaw! How", }) local luckyDrag = runRadio("LUCKY_CHANNEL", rolls(0), 16) check("a rolled zero drags", luckyDrag.log[14], "…Repeating myself") check("and drags again", luckyDrag.log[15], "gets to be a drag…") check("before starting over", luckyDrag.log[16], "REED: Yeehaw! How") -- ---------------------------------------------------- Places and People -- -- PeoplePlaces3 rolls once: below 49 percent - 1 (123) takes the People -- branch, anything else takes Places. PeoplePlaces4 then rolls a trainer -- class, PeoplePlaces5 an adjective, then a 4 percent (10) restart chance, -- then the People/Places coin again. local pnpPeople = runRadio("PLACES_AND_PEOPLE", rolls(0), 6) checkLines("places and people", pnpPeople, { "PLACES AND PEOPLE!", "Brought to you by", "me, DJ LILY!", "CLASS1 T1", "is cute.", -- A zero is under the 4 percent mark, so the show restarts from its intro. "PLACES AND PEOPLE!", }) -- PnP_HiddenPeople is a rejection list, not a skip: the roll is spent and -- another one is taken. A byte of 1 asks for class 2, which the fixture -- hides, so the show falls through to the next roll. local pnpHidden = runRadio("PLACES_AND_PEOPLE", rolls(0, 1, 0), 4) check("a hidden class is rerolled", pnpHidden.log[4], "CLASS1 T1") -- 200 is over the People threshold, so DJ LILY talks about a place instead. local pnpPlaces = runRadio("PLACES_AND_PEOPLE", rolls(200, 0, 5, 20, 20), 6) check("a high roll takes the Places branch", pnpPlaces.log[4], "PALLET TOWN") check("with its own adjective", pnpPlaces.log[5], "is somewhat bold.") -- 20 clears the 4 percent restart and then falls under the People threshold; -- the class roll wraps back to 200, which is past the sixty-six classes and -- so is spent on nothing before 0 lands on the first one. check("and hands back to People", pnpPlaces.log[6], "CLASS1 T1") -- --------------------------------------------------------- Rocket Radio -- -- Ten fixed lines, nothing rolled, and RocketRadio10 names ROCKET_RADIO so it -- runs round again. local rocketRadio = runRadio("ROCKET_RADIO", rolls(0), 11) checkLines("rocket radio", rocketRadio, { "… …Ahem, we are", "TEAM ROCKET!", "After three years", "of preparation, we", "have risen again", "from the ashes!", "GIOVANNI! Can you", "hear? We did it!", "Where is our Boss?", "Is he listening?", "… …Ahem, we are", }) check("rocket radio plays the overture", rocketRadio.music, "Music_RocketTheme") -- PlayRadioShow forces ROCKET_RADIO over any station id below -- POKE_FLUTE_RADIO while the tower is occupied and the player is in Johto. local takeover = Pokegear.Radio.new({ data = radioDataWith({ rocketsInRadioTower = true, inJohto = true }), rng = rolls(0), }) takeover:tune("OAKS_POKEMON_TALK") takeover:step() check("Team Rocket broadcasts on every station", takeover.cur ~= nil, true) check("and it is their script that runs", takeover.log[1], "… …Ahem, we are") -- The override compares the station id, and every mid-show segment is $0a or -- above, so a show already running is never interrupted mid-sentence. local midShow = Pokegear.Radio.new({ data = radioDataWith({ rocketsInRadioTower = true, inJohto = true }), rng = rolls(0, 1, 2, 0, 0), }) midShow:tune("OAKS_POKEMON_TALK") midShow.cur = "OAKS_POKEMON_TALK_4" midShow:step() check("a show mid-sentence is left alone", midShow.log[1], "OAK: SPINARAK") -- ------------------------------------------- the three music stations -- -- PokeFluteRadio, UnownRadio and EvolutionRadio set wNumRadioLinesPrinted to 1 -- and return: they start a song and never print a word. for _, station in ipairs({ "POKE_FLUTE_RADIO", "UNOWN_RADIO", "EVOLUTION_RADIO" }) do local music = Pokegear.Radio.new({ data = radioData, rng = rolls(0) }) music:tune(station) for _ = 1, 1000 do music:step() end check(station .. " says nothing", #music.log, 0) check(station .. " still plays", music.music ~= nil, true) end -- --------------------------------------------------------- the scroll -- -- PrintRadioLine fills the box's top row first and its bottom row second; -- from the third line on, CopyBottomLineToTopLine moves the previous line up. local scroll = Pokegear.Radio.new({ data = radioData, rng = rolls(1) }) scroll:tune("LUCKY_CHANNEL") scroll:step() check("the first line lands on the top row", scroll.top, "REED: Yeehaw! How") check("with nothing under it", scroll.bottom, "") check("and 100 frames on the clock", scroll.delay, 100) -- RadioScroll decrements before it tests, so the 100 frames are spent over -- the next 100 steps and the handover happens on the step after that. for _ = 1, 101 do scroll:step() end check("the delay has to run out first", #scroll.log, 1) scroll:step() check("then the second line lands on the bottom row", scroll.bottom, "y'all doin' now?") check("and the first is still above it", scroll.top, "REED: Yeehaw! How") for _ = 1, 103 do scroll:step() end check("the third line scrolls the second up", scroll.top, "y'all doin' now?") check("and takes the bottom row itself", scroll.bottom, "Whether you're up") -- ---------------------------------------------------------- the tuner -- -- RadioChannels is eight knob positions, each with its own test. A position -- whose test fails is not a station: NoRadioStation wipes the name and plays -- nothing. local gearLandmarks = { landmarks = {}, order = {} } for _, row in ipairs({ { "LANDMARK_NEW_BARK_TOWN", 1, 140, 100 }, { "LANDMARK_ROUTE_29", 2, 128, 100 }, { "LANDMARK_RUINS_OF_ALPH", 9, 76, 76 }, { "LANDMARK_LAKE_OF_RAGE", 37, 100, 20 }, { "LANDMARK_SILVER_CAVE", 45, 20, 20 }, { "LANDMARK_PALLET_TOWN", 46, 60, 100 }, { "LANDMARK_VICTORY_ROAD", 87, 30, 40 }, { "LANDMARK_ROUTE_28", 93, 20, 30 }, { "LANDMARK_FAST_SHIP", 94, 80, 80 }, }) do gearLandmarks.landmarks[row[1]] = { id = row[1], index = row[2], x = row[3], y = row[4], name = row[1] } gearLandmarks.order[row[2] + 1] = row[1] end local function newGear(opts) opts = opts or {} local save = opts.save or {} save.pokegearFlags = save.pokegearFlags or { map = true, radio = true, phone = true } local gearGame = newGame(save) return Pokegear.new(gearGame, { save = save, landmarks = gearLandmarks, currentLandmark = opts.landmark or "LANDMARK_NEW_BARK_TOWN", clock = opts.clock, }) end -- Standing in Johto in the afternoon: the first three frequencies air and the -- Kanto half of the dial is dead. local johtoGear = newGear({ clock = { hour = 14, minute = 0, weekday = 1 } }) local johtoDial = johtoGear:stations() check("the dial is eight frequencies", #johtoDial, 8) check("04.5 is Oak's talk in the afternoon", johtoDial[1].station, "OAKS_POKEMON_TALK") check("07.5 is the music channel", johtoDial[2].station, "POKEMON_MUSIC") check("08.5 is the Lucky Channel", johtoDial[3].station, "LUCKY_CHANNEL") check("13.5 needs the Ruins of Alph", johtoDial[4].station, nil) check("16.5 is Kanto's", johtoDial[5].station, nil) check("and the tuner still stops there", johtoDial[5].frequency, "16.5") -- .PKMNTalkAndPokedexShow reads wTimeOfDay: MORN, and only MORN, swaps the -- talk out for the Pokedex Show. local mornGear = newGear({ clock = { hour = 7, minute = 0, weekday = 1 } }) check("04.5 is the Pokedex Show in the morning", mornGear:stations()[1].station, "POKEDEX_SHOW") -- .RuinsOfAlphRadio is the one station that wants a single landmark. local ruinsGear = newGear({ landmark = "LANDMARK_RUINS_OF_ALPH", clock = { hour = 14, minute = 0, weekday = 1 } }) check("13.5 airs in the Ruins of Alph", ruinsGear:stations()[4].station, "UNOWN_RADIO") -- Kanto: the Johto half goes quiet and Places & People takes over. The POKe -- FLUTE station also wants the EXPN card. local kantoGear = newGear({ landmark = "LANDMARK_PALLET_TOWN", clock = { hour = 14, minute = 0, weekday = 1 } }) local kantoDial = kantoGear:stations() check("04.5 is silent in Kanto", kantoDial[1].station, nil) check("16.5 is Places & People", kantoDial[5].station, "PLACES_AND_PEOPLE") check("18.5 is Let's All Sing", kantoDial[6].station, "LETS_ALL_SING") check("20.0 wants the EXPN card", kantoDial[7].station, nil) local expnGear = newGear({ landmark = "LANDMARK_PALLET_TOWN", save = { pokegearFlags = { map = true, radio = true, phone = true, expn = true } }, clock = { hour = 14, minute = 0, weekday = 1 } }) check("and airs with it", expnGear:stations()[7].station, "POKE_FLUTE_RADIO") -- .EvolutionRadio wants STATUSFLAGS_ROCKET_SIGNAL_F and one of three -- landmarks around the Lake of Rage. local rageGear = newGear({ landmark = "LANDMARK_LAKE_OF_RAGE", save = { flags = { ROCKET_SIGNAL = true } }, clock = { hour = 14, minute = 0, weekday = 1 } }) check("20.5 airs by the Lake of Rage", rageGear:stations()[8].station, "EVOLUTION_RADIO") check("but not without the signal", johtoDial[8].station, nil) -- LoadStation_RocketRadio hands the tuner LetsAllSingName, and -- LoadStation_EvolutionRadio hands it UnownStationName: two stations really do -- broadcast under another one's name. check("Rocket Radio wears Let's All Sing's name", Pokegear.STATION_NAMES.ROCKET_RADIO, "Let's All Sing!") check("the evolution station wears the Unown one", Pokegear.STATION_NAMES.EVOLUTION_RADIO, "?????") -- .InJohto counts the S.S. Aqua as Johto even though LANDMARK_FAST_SHIP sits -- past every Kanto landmark. check("the S.S. Aqua is Johto", newGear({ landmark = "LANDMARK_FAST_SHIP" }):region(), "johto") -- ----------------------------------------------------- the map cursor -- -- PokegearMap_JohtoMap / PokegearMap_KantoMap walk the cursor by landmark -- INDEX between two limits: d, the last landmark of the region, and e, the -- first. Up steps forward and wraps to e; down steps back and wraps to d. local mapInput local function newMapGear(opts) local gear = newGear(opts) mapInput = gear.game.input return gear end local johtoMap = newMapGear({}) check("Johto's limits are Silver Cave and New Bark", select(1, johtoMap:cursorLimits()), 0x2d) check("with New Bark as the first", select(2, johtoMap:cursorLimits()), 0x01) check("and the cursor starts on the player", johtoMap:mapCursorIndex(), 1) mapInput:press("up") johtoMap:moveMapCursor(mapInput) check("up steps to the next landmark", johtoMap:mapCursorIndex(), 2) mapInput:press("down") johtoMap:moveMapCursor(mapInput) check("down steps back", johtoMap:mapCursorIndex(), 1) -- `cp e / jr nz` then the shared `dec [hl]`: only the first landmark wraps, -- and it wraps to d + 1 so the decrement lands on d itself. mapInput:press("down") johtoMap:moveMapCursor(mapInput) check("down from the first wraps to the last", johtoMap:mapCursorIndex(), 0x2d) check("which is Silver Cave", johtoMap:mapLandmark().id, "LANDMARK_SILVER_CAVE") -- `cp d / jr c` then the shared `inc [hl]`: at or past d the cursor is slammed -- to e - 1 so the increment lands on e. mapInput:press("up") johtoMap:moveMapCursor(mapInput) check("up from the last wraps to the first", johtoMap:mapCursorIndex(), 0x01) -- The player icon never moves: PokegearMap_UpdateCursorPosition writes the -- cursor's landmark, and PokegearMap_InitPlayerIcon wrote the player's once. johtoMap.mapCursor = 0x2d check("the icon stays where the player is", johtoMap:playerLandmark().id, "LANDMARK_NEW_BARK_TOWN") check("while the name box follows the cursor", johtoMap:mapLandmark().id, "LANDMARK_SILVER_CAVE") -- The landmark macro stores x + 8 / y + 16 because the sprite lives in OAM; -- the extractor takes both offsets back off, so the cursor sits on the -- landmark's own screen coordinates. check("and the cursor sprite sits on the landmark's own x", johtoMap:mapLandmark().x, 20) -- TownMap_GetKantoLandmarkLimits: before the Hall of Fame the Kanto map only -- walks Victory Road to Route 28, the seven landmarks on the road to Indigo. local kantoMap = newMapGear({ landmark = "LANDMARK_PALLET_TOWN" }) check("Kanto's last landmark is Route 28", select(1, kantoMap:cursorLimits()), 0x5d) check("and its first is Victory Road without the Hall of Fame", select(2, kantoMap:cursorLimits()), 0x57) kantoMap.mapCursor = 0x57 mapInput:press("down") kantoMap:moveMapCursor(mapInput) check("down from Victory Road wraps to Route 28", kantoMap:mapCursorIndex(), 0x5d) mapInput:press("up") kantoMap:moveMapCursor(mapInput) check("and up wraps back", kantoMap:mapCursorIndex(), 0x57) local hofMap = newMapGear({ landmark = "LANDMARK_PALLET_TOWN", save = { flags = { HALL_OF_FAME = true } } }) check("the Hall of Fame opens Kanto back to Pallet Town", select(2, hofMap:cursorLimits()), 0x2e) mapInput:press("down") hofMap:moveMapCursor(mapInput) check("so down from Pallet Town wraps to Route 28", hofMap:mapCursorIndex(), 0x5d) -- Left and right are not cursor moves on this card at all: PokegearMap_ -- ContinueMap pages the gear with them. .right takes the PHONE if it is -- owned and the RADIO if it is not; .left always takes the CLOCK. local pageMap = newMapGear({}) for index, card in ipairs(pageMap.cards) do if card.id == "map" then pageMap.cardIndex = index end end mapInput:press("right") pageMap:moveMapCursor(mapInput) check("right pages to the phone", pageMap:card().id, "phone") mapInput:press("left") pageMap.cardIndex = 2 pageMap:moveMapCursor(mapInput) check("left pages to the clock", pageMap:card().id, "clock") local noPhone = newMapGear({ save = { pokegearFlags = { map = true, radio = true } } }) mapInput:press("right") noPhone:moveMapCursor(mapInput) check("without a phone, right pages to the radio", noPhone:card().id, "radio") -- --------------------------------------------------- the knob, in the card -- -- AnimateTuningKnob.TuningKnob winds wRadioTuningKnob up towards 80 and down -- towards 0 and stops dead at either end -- `ret z` at the bottom and -- `ret nc` at the top. It does not wrap, so neither does the port's row. local knobGear = newMapGear({ clock = { hour = 14, minute = 0, weekday = 1 } }) for index, card in ipairs(knobGear.cards) do if card.id == "radio" then knobGear.cardIndex = index end end knobGear.mode = "card" knobGear:update(0) check("entering the card resolves the frequency", knobGear.radioShow, "OAKS_POKEMON_TALK") mapInput:press("up") knobGear:update(0) check("up winds the knob on", knobGear.station, 2) check("and retunes", knobGear.radioShow, "POKEMON_MUSIC") mapInput:press("down") knobGear:update(0) mapInput:press("down") knobGear:update(0) check("down stops dead at the bottom of the dial", knobGear.station, 1) knobGear.station = #Pokegear.RADIO_CHANNELS mapInput:press("up") knobGear:update(0) check("and up stops dead at the top", knobGear.station, #Pokegear.RADIO_CHANNELS) -- The show only advances while the card is up, and B hands the map's music -- back (ExitPokegearRadio_HandleMusic) and throws the machine away. knobGear.station = 1 knobGear:tuneRadio() for _ = 1, 300 do knobGear:update(0) end check("the show runs while the card is up", #knobGear.radio.log >= 2, true) mapInput:press("b") knobGear:update(0) check("and B leaves the card", knobGear.mode, "strip") check("taking the show with it", knobGear.radio, nil) -- ============================================================== POKeMART -- -- engine/items/mart.asm: StandardMart's jumptable loop, the buy list, the -- quantity selector's wraps and clamps, and SellMenu's half price. What the -- screen LOOKS like is what tests/drivers/gold_menu_shots.lua is for; this -- asserts the state machine and the arithmetic a player would notice. local MartMenu = require("src.ui.gen2.MartMenu") local martItems = { POTION = { id = "POTION", name = "POTION", pocket = "ITEM", index = 18, price = 300, canToss = true, description = "Restores HP\nby 20." }, ANTIDOTE = { id = "ANTIDOTE", name = "ANTIDOTE", pocket = "ITEM", index = 19, price = 100, canToss = true }, NUGGET = { id = "NUGGET", name = "NUGGET", pocket = "ITEM", index = 36, price = 10000, canToss = true }, -- Every KEY ITEM carries CANT_TOSS, which is the flag SellMenu's -- _CheckTossableItem actually refuses on. BICYCLE = { id = "BICYCLE", name = "BICYCLE", pocket = "KEY_ITEM", index = 7, price = 0, canToss = false }, } -- The shape data/generated/marts.lua will have: `lists` is a 1-based array in -- MART_* order and `bargain` is BargainShopData's own item/price rows. local martData = { lists = { { "POTION", "ANTIDOTE" } }, -- MART_CHERRYGROVE bargain = { { item = "NUGGET", price = 4500 } }, } local function newMart(save, opts) local game, input = newGame(save) game.data.items = martItems opts = opts or {} opts.save = save opts.items = martItems opts.marts = martData return MartMenu.new(game, opts), input, game end -- GetMart: only an id below NUM_MARTS is a mart at all, and everything else -- gets DefaultMart's two items. check("a listed mart is its own shelf", #MartMenu.inventory(martData, 0), 2) check("in the ROM's order", MartMenu.inventory(martData, 0)[1], "POTION") check("an id past NUM_MARTS falls back to DefaultMart", MartMenu.inventory(martData, 200)[1], "POKE_BALL") check("as does a mart id the table has no row for", MartMenu.inventory(martData, 5)[1], "POKE_BALL") check("and no marts.lua at all is an empty shelf, not invented stock", #MartMenu.inventory(nil, 0), 0) -- BuySell_MultiplyPrice, then Sell_HalvePrice on the PRODUCT: three of a -- 15-unit item sells for 22, not for three times seven. check("buying multiplies", MartMenu.buyPrice(300, 4), 1200) check("selling halves the product, not the unit", MartMenu.sellPrice(15, 3), 22) check("and a single item is simply half", MartMenu.sellPrice(300, 1), 150) -- .HowMayIHelpYou prints and returns TOPMENU without waiting, so the welcome -- line is still up under the BUY/SELL/QUIT menu. local shopSave = Save.newGame() shopSave.inventory = {} local shop, shopInput = newMart(shopSave) check("a standard mart opens on the top menu", shop.phase, "top") check("with the welcome line under it", shop.topLines[1], "Welcome! How may I") check("BUY is the default option", shop.topIndex, 1) shopInput:press("a") shop:update(0) check("BUY opens the list", shop.phase, "buy") check("the shelf is the mart's", #shop.entries, 2) check("priced out of ItemAttributes", shop.entries[1].price, 300) check("CANCEL sits one past the last row", shop:total(), 3) shop.index = shop:total() check("and is not an item", shop:isCancel(), true) -- A on CANCEL is B (.a_button treats a -1 selection as a cancel), and BuyMenu -- returns into .AnythingElse rather than straight out of the shop. shopInput:press("a") shop:update(0) check("CANCEL leaves the list", shop.phase, "top") check("with the ask-more line", shop.topLines[1], "Can I do anything") -- Back into BUY, and buy two POTIONs. shopInput:press("a") shop:update(0) check("the cursor is back at the top of the list", shop.index, 1) shopInput:press("a") shop:update(0) check("A on a row opens the quantity selector", shop.phase, "buyQuantity") check("starting at one", shop.qty, 1) check("with MAX_ITEM_STACK as the ceiling", shop.qtyMax, 99) -- BuySellToss_InterpretJoypad: up/down wrap, left/right step ten and clamp. shopInput:press("down") shop:update(0) check("down from one wraps to the ceiling", shop.qty, 99) shopInput:press("right") shop:update(0) check("right at the ceiling clamps", shop.qty, 99) shopInput:press("up") shop:update(0) check("up from the ceiling wraps to one", shop.qty, 1) shopInput:press("right") shop:update(0) check("right steps ten", shop.qty, 11) shopInput:press("left") shop:update(0) check("left steps ten back", shop.qty, 1) shopInput:press("left") shop:update(0) check("and never below one", shop.qty, 1) shopInput:press("up") shop:update(0) check("up steps one", shop.qty, 2) shopInput:press("a") shop:update(0) check("A asks to confirm", shop.confirm ~= nil, true) check("the price line names the quantity and the item", shop.confirm.pages[1][1], "2 POTION(S)") check("and the total", shop.confirm.pages[1][2], "will be \xc2\xa5600.") check("YES is the default", shop.confirm.choice, 1) shopInput:press("a") shop:update(0) check("the potions arrive", shopSave.inventory.POTION, 2) check("the money leaves", shopSave.player.money, 2400) check("and the clerk thanks you", shop.message.pages[1][1], "Here you are.") shopInput:press("a") shop:update(0) check("dismissing the thanks returns to the list", shop.phase, "buy") -- NO backs out of the confirmation without spending anything. shopInput:press("a") shop:update(0) shopInput:press("a") shop:update(0) shopInput:press("down") shop:update(0) check("down picks NO", shop.confirm.choice, 2) shopInput:press("a") shop:update(0) check("NO buys nothing", shopSave.inventory.POTION, 2) check("and spends nothing", shopSave.player.money, 2400) check("and drops back into the list", shop.phase, "buy") -- BuyMenuLoop compares money BEFORE it asks the bag for room, so a broke -- player is told about the money and never about the PACK. local brokeSave = Save.newGame() brokeSave.inventory = {} brokeSave.player.money = 100 local broke, brokeInput = newMart(brokeSave) brokeInput:press("a") broke:update(0) -- BUY brokeInput:press("a") broke:update(0) -- POTION brokeInput:press("a") broke:update(0) -- quantity 1 brokeInput:press("a") broke:update(0) -- YES check("a broke player is refused", broke.message.pages[1][1], "You don't have") check("nothing is bought", brokeSave.inventory.POTION, nil) check("and nothing is spent", brokeSave.player.money, 100) -- ReceiveItem failing is the OTHER refusal: AddItemToInventory caps a slot at -- 99, which Bag.add already enforces. local fullSave = Save.newGame() fullSave.inventory = { POTION = 99 } local full, fullInput = newMart(fullSave) fullInput:press("a") full:update(0) fullInput:press("a") full:update(0) fullInput:press("a") full:update(0) fullInput:press("a") full:update(0) check("a full pocket is refused", full.message.pages[1][1], "You can't carry") check("the stack stays at its cap", fullSave.inventory.POTION, 99) check("and the money is untouched", fullSave.player.money, 3000) -- .Quit: the come-again line, then STANDARDMART_EXIT. local byeSave = Save.newGame() local bye, byeInput = newMart(byeSave) local byeClosed = false bye.onClose = function() byeClosed = true end byeInput:press("down") bye:update(0) byeInput:press("down") bye:update(0) check("QUIT is the third row", bye.topIndex, 3) byeInput:press("a") bye:update(0) check("it says goodbye first", bye.message.pages[1][1], "Please come again!") check("and has not closed yet", byeClosed, false) byeInput:press("a") bye:update(0) check("then the shop closes", byeClosed, true) -- B out of the top menu is the same exit (VerticalMenu returns carry). local bye2, bye2Input = newMart(Save.newGame()) bye2Input:press("b") bye2:update(0) check("B quits too", bye2.phase, "outro") -- MenuHeader_Buy shows four entries at a time and .d_up refuses to move at -- scroll zero: the buy list does NOT wrap the way the PACK's does, and its -- scroll ceiling leaves room for the CANCEL row. local longData = { lists = { { "POTION", "ANTIDOTE", "POTION", "ANTIDOTE", "POTION", "ANTIDOTE" } }, } local longGame, longInput = newGame(Save.newGame()) longGame.data.items = martItems local long = MartMenu.new(longGame, { save = longGame.save, items = martItems, marts = longData }) longInput:press("a") long:update(0) check("a six-item shelf plus CANCEL", long:total(), 7) check("starts unscrolled", long.scroll, 0) longInput:press("up") long:update(0) check("up at the top does nothing", long.index, 1) for _ = 1, 4 do longInput:press("down") long:update(0) end check("the cursor walked", long.index, 5) check("and the window followed by one", long.scroll, 1) for _ = 1, 5 do longInput:press("down") long:update(0) end check("down stops on CANCEL rather than wrapping", long.index, 7) check("with the window at its ceiling", long.scroll, 3) -- ------------------------------------------------------------------ SELL local sellSave = Save.newGame() sellSave.inventory = { POTION = 5, BICYCLE = 1, NUGGET = 1 } sellSave.player.money = 1000 local sell, sellInput = newMart(sellSave) sellInput:press("down") sell:update(0) sellInput:press("a") sell:update(0) check("SELL opens the PACK", sell.phase, "sell") check("and the PACK is real", sell.pack ~= nil, true) -- _CheckTossableItem is the gate: a CANT_TOSS item gets MartCantBuyText and -- never reaches a price. sell:offerToSell("BICYCLE", 1) check("a key item is refused", sell.message.pages[1][1], "Sorry, I can't buy") check("and no quantity is asked for", sell.phase, "sell") sellInput:press("a") sell:update(0) -- Nothing in stock is not a sale either. sell:offerToSell("POTION", 0) check("selling none of something is refused too", sell.message.pages[1][1], "Sorry, I can't buy") sellInput:press("a") sell:update(0) sell:offerToSell("POTION", 5) check("a sellable item asks how many", sell.phase, "sellQuantity") check("the ceiling is what you hold", sell.qtyMax, 5) sellInput:press("right") sell:update(0) check("right clamps to what you hold", sell.qty, 5) sellInput:press("up") sell:update(0) check("up from the ceiling wraps to one", sell.qty, 1) sellInput:press("up") sell:update(0) check("and up steps one", sell.qty, 2) sellInput:press("a") sell:update(0) check("the offer is a page of its own", sell.confirm.pages[1][1], "I can pay you") check("half of two potions", sell.confirm.pages[1][2], "\xc2\xa5300.") check("and the question is the next page", #sell.confirm.pages, 2) sellInput:press("a") sell:update(0) check("which the YES/NO box sits on", sell.confirm.page, 2) check("still nothing sold", sellSave.inventory.POTION, 5) sellInput:press("a") sell:update(0) check("YES takes the potions", sellSave.inventory.POTION, 3) check("and pays for them", sellSave.player.money, 1300) check("with a receipt", sell.message.pages[1][1], "Got \xc2\xa5300 for") -- GiveMoney clamps at MaxMoney; nothing can push a wallet past 999999. sellInput:press("a") sell:update(0) sellSave.player.money = 999900 sell:offerToSell("NUGGET", 1) sellInput:press("a") sell:update(0) -- confirm at quantity 1 sellInput:press("a") sell:update(0) -- page 2 sellInput:press("a") sell:update(0) -- YES check("the wallet clamps at MAX_MONEY", sellSave.player.money, 999999) check("and the nugget is gone", sellSave.inventory.NUGGET, nil) -- ------------------------------------------------- PlayTransactionSound -- -- engine/items/mart.asm rings SFX_TRANSACTION in exactly two places, both of -- them past every gate: BuyMenuLoop's .proceed (after the money and bag checks, -- before TakeMoney) and the sell flow's own line after MartBoughtText. So the -- till is the money moving, and a refusal -- broke, full pocket, CANT_TOSS -- -- is silent. Stubbing the instance method rather than Sound keeps this an -- assertion about the call sites, which is what was missing. local tillSave = Save.newGame() tillSave.inventory = {} tillSave.player.money = 3000 local till, tillInput = newMart(tillSave) local rings = 0 till.playTransaction = function() rings = rings + 1 end tillInput:press("a") till:update(0) -- BUY tillInput:press("a") till:update(0) -- POTION tillInput:press("a") till:update(0) -- quantity 1 check("nothing rings before the confirmation is answered", rings, 0) tillInput:press("a") till:update(0) -- YES check("a completed purchase rings the till", rings, 1) tillInput:press("a") till:update(0) -- dismiss the thanks -- Same shop, no money left: the refusal must not ring. tillSave.player.money = 0 tillInput:press("a") till:update(0) -- POTION tillInput:press("a") till:update(0) -- quantity 1 tillInput:press("a") till:update(0) -- YES check("a refused purchase is silent", rings, 1) local tillSellSave = Save.newGame() tillSellSave.inventory = { POTION = 5, BICYCLE = 1 } local tillSell, tillSellInput = newMart(tillSellSave) local sellRings = 0 tillSell.playTransaction = function() sellRings = sellRings + 1 end tillSellInput:press("down") tillSell:update(0) tillSellInput:press("a") tillSell:update(0) -- SELL tillSell:offerToSell("BICYCLE", 1) check("a CANT_TOSS item is refused without a sound", sellRings, 0) tillSellInput:press("a") tillSell:update(0) -- clear the refusal tillSell:offerToSell("POTION", 2) tillSellInput:press("a") tillSell:update(0) -- accept the quantity, still one tillSellInput:press("a") tillSell:update(0) -- second page tillSellInput:press("a") tillSell:update(0) -- YES check("a completed sale rings it once", sellRings, 1) check("and the potion really left", tillSellSave.inventory.POTION, 4) -- --------------------------------------------------------- other dialogs -- -- HerbShop / BargainShop / Pharmacist never show a top menu: intro, BuyMenu, -- come again. local herbSave = Save.newGame() local herb, herbInput = newMart(herbSave, { martType = 1, martId = 33 }) check("the herb shop opens on its intro", herb.phase, "intro") check("which is five pages", #herb.message.pages, 5) check("starting with her greeting", herb.message.pages[1][1], "Hello, dear.") for _ = 1, 5 do herbInput:press("a") herb:update(0) end check("and lands in the buy list", herb.phase, "buy") herbInput:press("b") herb:update(0) check("B out of it goes straight to goodbye", herb.phase, "outro") check("in her own words", herb.message.pages[1][1], "Come again, dear.") -- BargainShop carries its own prices and sells one of each, tracked by -- wBargainShopFlags. local dealSave = Save.newGame() dealSave.inventory = {} dealSave.player.money = 5000 local deal, dealInput = newMart(dealSave, { martType = 2, martId = 0 }) for _ = 1, 3 do dealInput:press("a") deal:update(0) end check("the bargain shop lands in its list", deal.phase, "buy") check("with one row", #deal.entries, 1) check("at BargainShopData's price, not the item's", deal.entries[1].price, 4500) dealInput:press("a") deal:update(0) check("and no quantity selector at all", deal.confirm ~= nil, true) check("its price line is its own", deal.confirm.pages[1][1], "NUGGET costs") dealInput:press("a") deal:update(0) check("the nugget arrives", dealSave.inventory.NUGGET, 1) check("the money leaves", dealSave.player.money, 500) check("and the shelf remembers", dealSave.bargainShop.NUGGET, true) dealInput:press("a") deal:update(0) dealInput:press("a") deal:update(0) check("a second try is sold out", deal.message.pages[1][1], "You bought that") check("nothing else is bought", dealSave.inventory.NUGGET, 1) -- Pharmacist wording, so a repointed text table cannot silently swap kinds. local drugSave = Save.newGame() local drug = newMart(drugSave, { martType = 3, martId = 4 }) check("the pharmacy has its own intro", drug.message.pages[1][1], "What's up? Need") check("and its own goodbye", MartMenu.TEXTS.PHARMACY.comeAgain[1][1], "All right.") check("and its own price wording", MartMenu.TEXTS.PHARMACY.finalPrice(1, "POTION", 300)[1][2], "will cost \xc2\xa5300.") -- PRINTNUM_MONEY floats the ¥ in front of the first significant digit inside -- a six-digit field, so the string is always seven tiles wide. check("a small amount is space padded", MartMenu.moneyText(300), " \xc2\xa5300") check("a full field keeps every digit", MartMenu.moneyText(999999), "\xc2\xa5999999") check("and zero still prints one digit", MartMenu.moneyText(0), " \xc2\xa50") -- --------------------------------------------------------------- mod screens -- -- Every Gold screen is reached through a src/ui/Screens.lua id, so a mod -- replaces one by registering a factory under that id, exactly the way it does -- in the Gen 1 port. Two properties are the whole contract: -- -- * with nothing registered, an id resolves to EXACTLY the module the engine -- used to require -- routing the pushes through Screens moved no screen, -- and -- * a registered record wins over that module. -- -- The ids carry a "Gen2" prefix because a dozen Gold screens share a module -- name with a Gen 1 screen (PartyMenu, StartMenu, TitleState, ...); the last -- assertion below is that the two namespaces really are separate. local Screens = require("src.ui.Screens") -- A stack double. Screens.push lands the instance on game.stack and nothing -- here draws or updates it, so a bare collector is the whole surface needed. local screenStack = { pushed = {} } function screenStack:push(state) self.pushed[#self.pushed + 1] = state end local screenGame = { data = {}, stack = screenStack } -- The cache is module-global and this file is dofile'd alongside the rest of -- the tier, so bracket the whole block with invalidations. Screens.invalidate() local registered = {} for _, id in ipairs(Screens.GEN2_IDS) do registered[id] = true end for _, id in ipairs({ "Gen2TitleState", "Gen2MainMenu", "Gen2OakSpeech", "Gen2StartMenu", "Gen2PartyMenu", "Gen2SummaryMenu", "Gen2PackMenu", "Gen2MartMenu", "Gen2BattleState", "Gen2EvolutionAnim", "Gen2PcMenu", "Gen2BoxMenu", "Gen2NamingScreen", "Gen2Pokegear" }) do check("the id list carries " .. id, registered[id], true) end for _, id in ipairs(Screens.GEN2_IDS) do -- "Gen2PartyMenu" -> src.ui.gen2.PartyMenu, the module the push sites used -- to require by hand. check("no mod: " .. id .. " resolves to its builtin", Screens.get(screenGame, id), require("src.ui.gen2." .. id:sub(5))) end -- A registry record wins, Screens.push stamps the id on the instance and lands -- it on the stack -- the same three things tests/mod_ui_tests.lua asserts for -- the Gen 1 ids. Screens.invalidate() screenGame.data.screens = { Gen2PartyMenu = { new = function() return { modded = "party" } end }, } local moddedParty = Screens.push(screenGame, "Gen2PartyMenu", {}) check("a registered record replaces the Gold screen", moddedParty.modded, "party") check("and the push stamps the id", moddedParty.screenId, "Gen2PartyMenu") check("and lands the instance on the stack", screenStack.pushed[1], moddedParty) -- Screens.build is that same resolution without the stack: the mart holds its -- sell-mode PACK rather than pushing it (src/ui/gen2/MartMenu.lua enterSell), -- and an override has to reach it there too. Screens.invalidate() screenGame.data.screens = { Gen2PackMenu = { new = function() return { modded = "pack" } end }, } local builtPack = Screens.build(screenGame, "Gen2PackMenu", {}) check("build honours the same registry", builtPack.modded, "pack") check("and stamps the id", builtPack.screenId, "Gen2PackMenu") check("without touching the stack", #screenStack.pushed, 1) -- The prefix is load bearing: replacing Gold's party menu must leave Red's -- alone, and vice versa. check("the Gen 1 id of the same name is untouched", Screens.get(screenGame, "PartyMenu"), require("src.ui.PartyMenu")) -- The registry only reaches a screen the PUSH SITE looked up by id, and -- src/core/Game2.lua is the one file that opens the boot cinema, the -- START menu and every one of its submenus. It used to require those modules -- and push them by hand, which quietly exempted fourteen ids from the contract -- above, so this reads the source and asserts the exemption is gone: no -- `stack:push(.new(` for anything under src/ui/gen2/. A textual check -- is the honest one here -- constructing a Game2 needs love. local game2Source = (function() local f = io.open("src/core/Game2.lua", "r") if not f then return nil end local body = f:read("*a") f:close() return body end)() check("Game2's source is readable", game2Source ~= nil, true) if game2Source then check("it resolves screens through the registry", game2Source:find('require("src.ui.Screens")', 1, true) ~= nil, true) local handRolled = {} for name in game2Source:gmatch("stack:push%(([%w_]+)%.new%(") do -- TextBox is src/render, shared with the Gen 1 path and not a screen id. if name ~= "TextBox" then handRolled[#handRolled + 1] = name end end check("and pushes no Gold screen by hand", table.concat(handRolled, ","), "") for _, id in ipairs({ "Gen2CopyrightSplash", "Gen2GameFreakPresents", "Gen2GoldSilverIntro", "Gen2TitleState", "Gen2MainMenu", "Gen2OakSpeech", "Gen2OptionsMenu", "Gen2StartMenu", "Gen2PokedexMenu", "Gen2PartyMenu", "Gen2PackMenu", "Gen2Pokegear", "Gen2TrainerCard", "Gen2SaveMenu" }) do check("it opens " .. id .. " by id", game2Source:find('"' .. id .. '"', 1, true) ~= nil, true) end end screenGame.data.screens = nil Screens.invalidate() -- --------------------------------------------------------------------------- -- The scripted static menu (src/ui/gen2/ScriptMenu.lua) -- -- `loadmenu` then `verticalmenu` / `_2dmenu`. Everything below is -- GetMenuTextStartCoord and _2DMenu_'s own arithmetic. The two headers are -- transcribed from pokegold (the dept-store vending machine and Earl's -- blackboard, the only `_2dmenu` in the game); the block at the end reads the -- cache and asserts the extractor produced the same bytes, so a re-import that -- reads a MenuHeader wrong fails here rather than by a menu drawn askew. -- Wrapped in a function rather than a `do` block: the main chunk is close -- to Lua's 200-local ceiling and these would push it over. local function scriptMenuChecks() local ScriptMenu = require("src.ui.gen2.ScriptMenu") -- CeladonDeptStore6F's vending machine: `menu_coords 0, 2, 19, 11` with -- STATICMENU_CURSOR and no STATICMENU_NO_TOP_SPACING. Border + 1 is (1,3), -- the missing NO_TOP_SPACING adds a row and the cursor a column: (2,4). local vending = { flags = 0x40, top = 2, left = 0, bottom = 11, right = 19, cursor = 1, dataFlags = 0x80, items = { "FRESH WATER", "SODA POP", "LEMONADE", "CANCEL" }, } local x, y = ScriptMenu.startCoord(vending) check("a cursor menu with top spacing starts at x", x, 2) check("and at y", y, 4) -- YesNoMenuHeader sets both bits, which is what puts YES on the row straight -- under the border rather than one below it. local both = { top = 7, left = 14, dataFlags = 0x80 + 0x40 } local bx, by = ScriptMenu.startCoord(both) check("NO_TOP_SPACING keeps the first row against the border", by, 8) check("and the cursor column is still reserved", bx, 16) local none = { top = 0, left = 0, dataFlags = 0 } local nx, ny = ScriptMenu.startCoord(none) check("no cursor, no spacing flag: x", nx, 1) check("no cursor, no spacing flag: y", ny, 2) -- Earl's blackboard, the only `_2dmenu` in the game: `dn 3, 2` is three rows -- of two, spacing 5. local blackboard = { flags = 0x40, top = 0, left = 0, bottom = 8, right = 11, cursor = 1, dataFlags = 0x80, grid = { rows = 3, cols = 2, spacing = 5 }, gridItems = { "PSN", "PAR", "SLP", "BRN", "FRZ", "QUIT" }, } local items, rows, cols, spacing = ScriptMenu.layout(blackboard, "2d") check("the grid is three rows", rows, 3) check("of two columns", cols, 2) check("five tiles apart", spacing, 5) check("with six labels", #items, 6) -- A vertical menu reads the SAME header as n rows of one column, which is -- why the answer arithmetic below collapses to the row index for it. local _, vRows, vCols = ScriptMenu.layout(vending, "vertical") check("the vending machine is four rows", vRows, 4) check("of one column", vCols, 1) -- _2DMenu_: `(cursorY - 1) * cols + cursorX`, one-based both ways. check("2D row 1 col 1 is choice 1", ScriptMenu.choiceIndex(1, 1, 2), 1) check("row 1 col 2 is choice 2", ScriptMenu.choiceIndex(1, 2, 2), 2) check("row 3 col 2 is choice 6 (QUIT)", ScriptMenu.choiceIndex(3, 2, 2), 6) check("a one-column menu answers its row", ScriptMenu.choiceIndex(3, 1, 1), 3) -- The cursor and the labels: rows are TWO apart in both menus -- (PlaceMenuStrings and Place2DMenuItemStrings both `add hl, 2 * SCREEN_WIDTH`), -- columns `spacing` apart, and the cursor sits one column left of the label. local menu = ScriptMenu.new({ data = {} }, { header = blackboard, style = "2d" }) local ix, iy = menu:itemPosition(1) check("blackboard item 1 x", ix, 2) check("blackboard item 1 y", iy, 2) local jx, jy = menu:itemPosition(4) -- row 2, col 2 = BRN check("BRN sits a column across", jx, 7) check("and a row pair down", jy, 4) -- Pressing A answers the 1-based index; B answers 0, which is the cancel arm -- every `ifequal` ladder falls through to. local pressed = {} local input = { wasPressed = function(_, name) return pressed[name] == true end, } local picked local vm = ScriptMenu.new({ data = {}, input = input }, { header = vending, style = "vertical", onChoose = function(index) picked = index end }) check("the cursor opens on the header's own default", vm.row, 1) pressed = { down = true } vm:update(0) pressed = { down = true } vm:update(0) check("down twice moves two rows", vm.row, 3) pressed = { down = true } vm:update(0) pressed = { down = true } vm:update(0) check("and it stops at the last item rather than wrapping", vm.row, 4) pressed = { a = true } vm:update(0) check("A answers the 1-based index", picked, 4) pressed = { a = true } vm:update(0) check("and a finished menu answers once", picked, 4) picked = nil local bm = ScriptMenu.new({ data = {}, input = input }, { header = vending, style = "vertical", onChoose = function(index) picked = index end }) pressed = { b = true } bm:update(0) check("B answers 0, the cancel arm", picked, 0) -- STATICMENU_DISABLE_B: the one flag that makes a menu inescapable. picked = nil local locked = ScriptMenu.new({ data = {}, input = input }, { header = { top = 0, left = 0, bottom = 5, right = 9, dataFlags = 0x81, items = { "A", "B" } }, style = "vertical", onChoose = function(index) picked = index end }) pressed = { b = true } locked:update(0) check("STATICMENU_DISABLE_B ignores B", picked, nil) -- The same two headers, out of the cache this time. Every `loadmenu` in -- the game must carry a header with items, or that site takes the cancel -- arm the way all seventeen of them did before the extractor followed the -- pointer. 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("cache absent (SKIP extracted menu headers)", true, true) return end local sites, grids = 0, 0 for key, cmds in pairs(scriptsFile()) do if type(cmds) == "table" and key ~= "movements" then for i, cmd in ipairs(cmds) do if cmd.op == "loadmenu" then sites = sites + 1 local header = cmd.menu check(("%s: loadmenu carries a header"):format(key), type(header) == "table", true) local list = header and (header.items or header.gridItems) check(("%s: with at least one item"):format(key), type(list) == "table" and #list > 0, true) -- The command after it is what decides how the header is read, and -- it is always one of the two static menus. local next_ = cmds[i + 1] and cmds[i + 1].op check(("%s: opens a static menu"):format(key), next_ == "verticalmenu" or next_ == "_2dmenu", true) if next_ == "_2dmenu" then grids = grids + 1 check(("%s: the 2D menu is 3x2"):format(key), header.grid and header.grid.rows == 3 and header.grid.cols == 2, true) check(("%s: five spacing"):format(key), header.grid.spacing, 5) check(("%s: last item is QUIT"):format(key), header.gridItems[#header.gridItems], "QUIT") end end end end end check("every loadmenu site in the cache was seen", sites, 17) -- Earl's blackboard is reached from two entry points, so its command is -- disassembled twice; the game has exactly one `_2dmenu` source site. check("and both entries into the one _2dmenu", grids, 2) end scriptMenuChecks() -- --------------------------------------------------------- trainer card -- -- TrainerCard_Page3_Joypad hands TrainerCard_Page2_3_AnimateBadges the exact -- same TrainerCard_JohtoBadgesOAM pointer page 2 does, and that table's own -- header word is `dw wJohtoBadges` -- so the "Kanto badges" page never once -- reads wKantoBadges, it draws the Johto flags under Kanto's caption and gym -- leader faces (which are themselves LeaderGFX2/BadgeGFX2, byte-identical -- INCBINs of LeaderGFX/BadgeGFX). drawBadgeSprites is exercised directly -- with a stub sheet so the gating itself is proven, and the source is read -- back the way tests/gen2_menus_test.lua already checks Game2, since -- driving TrainerCard.new all the way to a styled draw needs real image -- assets this harness does not have. local function trainerCardChecks() local TrainerCard = require("src.ui.gen2.TrainerCard") local draws = 0 local realDraw = love.graphics.draw love.graphics.draw = function(...) draws = draws + 1 end local stubSheet = {} stubSheet.__index = stubSheet function stubSheet:available() return true end function stubSheet:quad() return { getViewport = function() return 0, 0 end } end function stubSheet:image() return "stub" end local fake = { frames = 0, badges = setmetatable({}, stubSheet), gfx = { badgeOam = { { y = 0, x = 0, palette = 0, frames = { 0, 0, 0, 0, 0, 0, 0, 0 }, } } }, } -- BADGE_OAM_ORDER[1] is ZEPHYR: owned by name, Johto-keyed, is the shape -- player.badges takes. draws = 0 TrainerCard.drawBadgeSprites(fake, { ZEPHYR = true }, TrainerCard.JOHTO_BADGES) check("a Johto-keyed owned table draws the badge", draws > 0, true) -- The bug this item fixes: page 3 used to look the badge up by Kanto names -- against a Kanto-keyed table, which the Johto-named OAM order can never -- match, so nothing ever drew. Confirm that shape still draws nothing -- -- it is the wrong table for either page now. draws = 0 TrainerCard.drawBadgeSprites(fake, { BOULDER = true }, TrainerCard.KANTO_BADGES) check("a Kanto-keyed table never matches the Johto OAM order", draws, 0) love.graphics.draw = realDraw local source = (function() local f = io.open("src/ui/gen2/TrainerCard.lua", "r") if not f then return nil end local body = f:read("*a") f:close() return body end)() check("TrainerCard.lua is readable", source ~= nil, true) if source then check("page 3 draws badges off player.badges, not kantoBadges", source:find("self:drawBadges(JOHTO_BADGES, player.badges or {})", 1, true) ~= nil, true) check("page 3 does not gate on player.kantoBadges", source:find("drawBadges(%w+, player%.kantoBadges") ~= nil, false) check("the plain fallback also reads player.badges unconditionally", source:find("local held = player.badges or {}", 1, true) ~= nil, true) end end trainerCardChecks() -- ------------------------------------------------------------ Bag pockets -- Gen 2's four pockets fill independently (item_data_constants.asm): a full -- ITEM pocket does not keep a KEY_ITEM or an HM out. Modelling one 20-slot -- bag filled with TMs and key items refused HM07 WATERFALL at the Ice Path. local function bagPocketChecks() local Bag = require("src.inventory.Bag") local data = { items = { POTION = { id = "POTION", pocket = "ITEM", index = 1 }, POKE_BALL = { id = "POKE_BALL", pocket = "BALL", index = 2 }, TM_ROCK_SMASH = { id = "TM_ROCK_SMASH", pocket = "TM_HM", index = 3 }, HM_WATERFALL = { id = "HM_WATERFALL", pocket = "TM_HM", index = 4 }, CARD_KEY = { id = "CARD_KEY", pocket = "KEY_ITEM", index = 5 }, } } -- Fill the ITEM pocket to its cap with 20 distinct junk ids. local save = { inventory = {} } for i = 1, 20 do local id = "JUNK_" .. i data.items[id] = { id = id, pocket = "ITEM", index = 100 + i } save.inventory[id] = 1 end check("ITEM pocket at cap", Bag.slots(save, data, "ITEM"), 20) check("a full ITEM pocket refuses another ITEM", Bag.add(save, "POTION", 1, data), false) check("but still takes a BALL", Bag.add(save, "POKE_BALL", 5, data), true) check("and a KEY_ITEM", Bag.add(save, "CARD_KEY", 1, data), true) check("and an HM", Bag.add(save, "HM_WATERFALL", 1, data), true) check("the HM went to the TM/HM pocket", Bag.slots(save, data, "TM_HM"), 1) -- The TM/HM pocket holds far more than 20 (one of each TM plus HMs). local tm = { inventory = {} } local tmData = { items = {} } for i = 1, 30 do local id = "TM_" .. i tmData.items[id] = { id = id, pocket = "TM_HM", index = i } check("TM #" .. i .. " fits", Bag.add(tm, id, 1, tmData), true) end check("30 TMs held, where a 20-bag would have stopped at 20", Bag.slots(tm, tmData, "TM_HM"), 30) -- No pocket field (Gen 1 cache) resolves to ITEM, so the single-bag limit -- is exactly what it was. local g1 = { inventory = {} } local g1Data = { items = {} } for i = 1, 20 do local id = "I" .. i g1Data.items[id] = { id = id, index = i } -- no pocket Bag.add(g1, id, 1, g1Data) end check("Gen 1 (no pockets) still caps at 20", Bag.add(g1, "I21", 1, g1Data), false) end bagPocketChecks() -- --------------------------------------------------------------------------- -- SaveMenu's write chime (engine/menus/save.asm:110, `ld de, SFX_SAVE / call -- PlaySFX` right after ResumeGameLogic). -- -- SFX_SAVE is an INDEX into the sfx pointer table, so a wrong id plays the -- wrong sound rather than nothing, and no assertion here can see the mistake -- from the number alone. The check is therefore the resolution: writeNow -> -- SaveMenu:playSfx -> sfxOrder[id + 1], against the shipped Gold cache, must -- name Sfx_Save. $1f used to sit there, which is SFX_ENTER_DOOR. -- Wrapped in a function for the same 200-local reason as the blocks above. local function saveSfxChecks() local cacheDir = os.getenv("GOLD_CACHE") if not cacheDir then cacheDir = (os.getenv("HOME") or "") .. "/Library/Application Support/LOVE/gold-dev/gold" end local audioFile = loadfile(cacheDir .. "/data/generated/audio.lua") if not audioFile then check("cache absent (SKIP the save chime)", true, true) return end local audio = audioFile() check("the cache carries an sfx order", type(audio.sfxOrder) == "table", true) -- Sound.play is the far side of SaveMenu:playSfx; swap it for a recorder so -- the label the id resolved to is readable without an audio device. local Sound = require("src.core.Sound") local realPlay, rang = Sound.play, nil Sound.play = function(_, name) rang = name end local SaveMenu = require("src.ui.gen2.SaveMenu") local menu = SaveMenu.new({ data = { audio = audio } }, { save = {}, existed = false, writer = function() return true end }) menu:writeNow() Sound.play = realPlay check("saving rings SFX_SAVE", rang, "Sfx_Save") end saveSfxChecks() -- ------------------------------------------------- the mod row contract -- -- Gold's screens raise the same three hooks the Gen 1 ones do, which is only -- half a contract: a row a mod adds has to DO something on A too. Each of -- these three is one line in the screen's dispatch, and the failure without it -- is invisible (the row draws, the press is eaten, nothing happens). local function modRowChecks() local Runtime = require("src.mods.Runtime") local Hooks = require("src.mods.Hooks") local hooks = Hooks.new() -- restored below: run_tests.lua dofiles every suite into ONE process, so a -- blanked Runtime here takes out every suite listed after this file local prevEvents, prevHooks, prevErrors = Runtime.events, Runtime.hooks, Runtime.errors Runtime.install(prevEvents, hooks, prevErrors or {}) -- ui.options.rows: activate on A, step on Left/Right, value on the panel local fired, stepped = 0, 0 hooks:wrap("ui.options.rows", function(nextFn, game, rows) rows = nextFn(game, rows) rows[#rows + 1] = { id = "modrow", label = "MOD ROW", value = function() return "ON" end, step = function() stepped = stepped + 1 end, activate = function() fired = fired + 1 end } return rows end) local og, oi = newGame(nil) local om = OptionsMenu.new(og, { options = Save.defaultOptions() }) check("gen2 OPTION takes the hook's row", om.rows[#om.rows].id, "modrow") om.index = #om.rows oi:press("a") om:update(0) check("A on a mod row calls activate", fired, 1) oi:press("right") om:update(0) check("Right on a mod row calls step", stepped, 1) -- ui.start_menu.items: an entry with onSelect and no value hooks.chains = {} local chose = 0 hooks:wrap("ui.start_menu.items", function(nextFn, game, items) items = nextFn(game, items) items[#items + 1] = { label = "MOD", onSelect = function() chose = chose + 1 end } return items end) local ssave = Save.newGame({ playerName = "GOLD" }) local sg, si = newGame(ssave) local sm = StartMenu.new(sg, { save = ssave }) check("gen2 START takes the hook's item", sm.items[#sm.items].label, "MOD") sm.list.index = #sm.items si:press("a") sm.list:update(si) check("A on a mod item calls onSelect", chose, 1) -- ui.party.submenu: an entry with onSelect and no vanilla id hooks.chains = {} local picked = 0 hooks:wrap("ui.party.submenu", function(nextFn, game, items, mon, ctx) items = nextFn(game, items, mon, ctx) items[#items + 1] = { label = "WALK", onSelect = function() picked = picked + 1 end } return items end) local PartyMenu = require("src.ui.gen2.PartyMenu") local psave = Save.newGame({ playerName = "GOLD" }) psave.party = { { species = "CYNDAQUIL", level = 5, hp = 20, maxHp = 20, moves = {}, nickname = "CYNDA" } } local pg, pi = newGame(psave) local pm = PartyMenu.new(pg, { save = psave }) local items = pm:submenuItems(psave.party[1]) check("gen2 party submenu takes the hook's entry", items[#items].label, "WALK") pm.submenu = { items = items, index = #items, mon = psave.party[1], slot = 1 } pi:press("a") pm:updateSubmenu(pi) check("A on a mod entry calls onSelect", picked, 1) hooks.chains = {} Runtime.install(prevEvents, prevHooks, prevErrors) end modRowChecks() print(("gen2 menus: %d checks, %d failures"):format(checks, failures)) -- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an -- exit here takes the whole tier down with it and silently skips every -- suite listed after this one (see tests/harness.lua's T.suite note). if failures > 0 then error(("%d assertion(s) failed"):format(failures), 0) end