-- The party list's field behaviour: SwitchPartyMons (engine/pokemon/ -- mon_menu.asm + engine/pokemon/switchpartymons.asm) and the EGG rules -- (engine/pokemon/party_menu.asm PartyMenuCheckEgg, engine/pokemon/ -- mon_submenu.asm GetMonSubmenuItems .egg, engine/pokemon/stats_screen.asm -- EggStatsScreen). -- -- ROM-free and draw-free, the same shape as tests/gen2_summary_test.lua: the -- list's rows and the egg summary's page are built as data before anything is -- drawn, so the suite asserts the data and drives the input loops with a stub -- pad. What a test cannot say -- whether the screens LOOK right -- is what -- tests/drivers/gold_party_submenu.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. 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, so Font.encode would warn once per unknown glyph. require("src.core.Logger").warn = function() end -- StatsScreen_PlaceFrontpic ends in PlayMonCry; count the calls so the egg's -- silence is an assertion rather than a hope. local cries = 0 package.loaded["src.core.Sound"] = package.loaded["src.core.Sound"] or {} package.loaded["src.core.Sound"].playCry = function() cries = cries + 1 end local Mail = require("src.core.gen2.Mail") local Mon = require("src.battle.gen2.Mon") local PartyMenu = require("src.ui.gen2.PartyMenu") local SummaryMenu = require("src.ui.gen2.SummaryMenu") 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 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 -- ------------------------------------------------------------- fixture data local DATA = { pokemon = { growthRates = { GROWTH_MEDIUM_SLOW = { numerator = 6, denominator = 5, squared = -15, linear = 100, constant = 140, }, }, CYNDAQUIL = { id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155, index = 155, growthRate = "GROWTH_MEDIUM_SLOW", types = { "FIRE", "FIRE" }, baseStats = { hp = 39, attack = 52, defense = 43, speed = 65, specialAttack = 60, specialDefense = 50, }, }, TOTODILE = { id = "TOTODILE", name = "TOTODILE", dex = 158, index = 158, growthRate = "GROWTH_MEDIUM_SLOW", types = { "WATER", "WATER" }, baseStats = { hp = 50, attack = 65, defense = 64, speed = 43, specialAttack = 44, specialDefense = 48, }, }, TOGEPI = { id = "TOGEPI", name = "TOGEPI", dex = 175, index = 175, growthRate = "GROWTH_MEDIUM_SLOW", types = { "NORMAL", "NORMAL" }, eggSteps = 20, baseStats = { hp = 35, attack = 20, defense = 65, speed = 20, specialAttack = 40, specialDefense = 65, }, }, }, moves = { TACKLE = { id = "TACKLE", name = "TACKLE", pp = 35 }, SURF = { id = "SURF", name = "SURF", pp = 15 }, }, items = { BERRY = { id = "BERRY", name = "BERRY", pocket = "ITEM" }, FLOWER_MAIL = { id = "FLOWER_MAIL", name = "FLOWER MAIL", pocket = "ITEM" }, }, gen2MenuGfx = {}, -- A cut-down icons.lua: enough for iconIdFor's two arms. gen2Icons = { species = { CYNDAQUIL = "ICON_FOX", TOTODILE = "ICON_MONSTER", TOGEPI = "ICON_MONSTER" }, icons = { ICON_FOX = { id = "ICON_FOX", image = "x/fox.png" }, ICON_MONSTER = { id = "ICON_MONSTER", image = "x/monster.png" }, ICON_EGG = { id = "ICON_EGG", image = "x/egg.png" }, }, }, } local function newGame(save) return { input = newInput(), save = save, data = DATA, stack = { _items = {}, push = function(self, s) self._items[#self._items + 1] = s end, pop = function(self) return table.remove(self._items) end, top = function(self) return self._items[#self._items] end, }, } end local function mon(species, level, opts) opts = opts or {} local built = Mon.new(DATA, species, level, { dvs = { attack = 15, defense = 15, speed = 15, special = 15 }, moves = opts.moves, }) for key, value in pairs(opts.fields or {}) do built[key] = value end return built end -- The shape GiveEgg (engine/pokemon/move_mon.asm) leaves in the slot: the -- real species under an isEgg mark, "EGG" for a nickname on the day-care -- path but NOT on the giveegg one, the counter in cycles, and zero HP. local function egg(cycles) local built = mon("TOGEPI", 5) built.isEgg = true built.eggSteps = cycles or 20 built.hp = 0 return built end local function newSave() return { player = { name = "GOLD", id = 12345 }, party = { mon("CYNDAQUIL", 12, { moves = { { id = "TACKLE", pp = 30, maxPp = 35 } }, fields = { nickname = "CYNDAQUIL", item = "BERRY" }, }), mon("TOTODILE", 10, { moves = { { id = "SURF", pp = 15, maxPp = 15 } }, fields = { nickname = "TOTODILE" }, }), }, } end -- ----------------------------------------------------------- the list rows -- WritePartyMenuTilemap's quality routines, as row data. local save = newSave() local row = PartyMenu.rowFor(save.party[1]) check("a mon's row shows its nickname", row.name, "CYNDAQUIL") check("and its HP digits", row.hp ~= nil, true) check("and its level", row.level, "12") check("a healthy mon has no status", row.status, nil) local fnt = mon("TOTODILE", 10, { fields = { hp = 0 } }) check("a fainted mon reads FNT", PartyMenu.rowFor(fnt).status, "FNT") -- PartyMenuCheckEgg: every quality routine skips an EGG's row, and the name -- is String_Egg -- never the species hiding inside. local eggRow = PartyMenu.rowFor(egg()) check("an egg's row reads EGG", eggRow.name, "EGG") check("an egg has no HP digits", eggRow.hp, nil) check("an egg has no level", eggRow.level, nil) check("an egg is not FNT", eggRow.status, nil) -- ReadMonMenuIcon: MonMenuIcons for a species, ICON_EGG for an egg. local game = newGame(save) local party = PartyMenu.new(game, { party = save.party, submenu = true }) check("a species icon reads MonMenuIcons", party:iconIdFor(save.party[1]), "ICON_FOX") check("an egg draws ICON_EGG", party:iconIdFor(egg()), "ICON_EGG") -- ------------------------------------------------------------- egg submenu -- GetMonSubmenuItems' .egg arm: STATS, SWITCH, CANCEL and nothing else. local eggItems = party:submenuItems(egg()) check("an egg's submenu has three rows", #eggItems, 3) check("STATS first", eggItems[1].id, "STATS") check("SWITCH second", eggItems[2].id, "SWITCH") check("CANCEL third", eggItems[3].id, "CANCEL") -- GiveTakePartyMonItem's first test is `cp EGG`: the held-item menu refuses. game = newGame(save) party = PartyMenu.new(game, { party = save.party, submenu = true }) party:openHeldItemMenu(1, egg()) check("the held-item menu refuses an egg", #game.stack._items, 0) -- ------------------------------------------------------- SwitchPartyMons -- The full flow through the pad: A opens the submenu, SWITCH holds the slot, -- the second A swaps both the mons and their sPartyMail structs. save = newSave() Mail.set(save, 2, Mail.entry("FLOWER_MAIL", "hello", "GOLD", 12345)) game = newGame(save) party = PartyMenu.new(game, { party = save.party, submenu = true, save = save }) game.input:press("a") party:update(0) check("a opens the submenu", party.submenu ~= nil, true) game.input:press("down") party:update(0) check("down reaches SWITCH", party.submenu.items[party.submenu.index].id, "SWITCH") game.input:press("a") party:update(0) check("SWITCH closes the submenu", party.submenu, nil) check("and holds the slot", party.switchFrom, 1) -- InitPartyMenuNoCancel: the cursor is capped at the last mon. check("the CANCEL row is gone while switching", party:count(), 2) game.input:press("down") party:update(0) check("down picks the other end", party.index, 2) game.input:press("a") party:update(0) check("a releases the hold", party.switchFrom, nil) check("the lead is now TOTODILE", save.party[1].nickname, "TOTODILE") check("and slot 2 is CYNDAQUIL", save.party[2].nickname, "CYNDAQUIL") -- _SwitchPartyMons' .SwapMonAndMail: the letter rides with its mon. check("the mail moved with its mon", Mail.get(save, 1) ~= nil, true) check("and left its old slot", Mail.get(save, 2), nil) check("the CANCEL row is back", party:count(), 3) -- B backs out of the hold without moving anything (.DontSwitch). save = newSave() game = newGame(save) party = PartyMenu.new(game, { party = save.party, submenu = true, save = save }) party:beginSwitch(1) check("beginSwitch holds the slot", party.switchFrom, 1) game.input:press("b") party:update(0) check("b releases the hold", party.switchFrom, nil) check("and nothing moved", save.party[1].nickname, "CYNDAQUIL") -- Picking the held slot again is the `.skip` arm: nothing moves. party:beginSwitch(1) party.index = 1 game.input:press("a") party:update(0) check("the same slot swaps nothing", save.party[1].nickname, "CYNDAQUIL") check("and the hold is released", party.switchFrom, nil) -- `cp 2 / jr c, .DontSwitch`: one mon is nothing to switch with. local lone = { mon("CYNDAQUIL", 12) } game = newGame({ party = lone }) party = PartyMenu.new(game, { party = lone, submenu = true }) party:beginSwitch(1) check("a lone mon cannot enter switch mode", party.switchFrom, nil) -- A list opened over a table that is NOT the save's party (a battle copy) -- reorders itself without touching sPartyMail. save = newSave() Mail.set(save, 2, Mail.entry("FLOWER_MAIL", "hello", "GOLD", 12345)) local copy = { save.party[1], save.party[2] } game = newGame(save) party = PartyMenu.new(game, { party = copy, submenu = true, save = save }) party:beginSwitch(1) party.index = 2 game.input:press("a") party:update(0) check("the copy reordered", copy[1].nickname, "TOTODILE") check("but the save's own party did not", save.party[1].nickname, "CYNDAQUIL") check("and the mail stayed put", Mail.get(save, 2) ~= nil, true) -- ------------------------------------------------------------- egg summary local at = SummaryMenu.at -- EggStatsScreen, coordinate for coordinate. save = newSave() save.party[3] = egg(20) cries = 0 game = newGame(save) local screen = SummaryMenu.new(game, { party = save.party, index = 3, save = save }) check("an egg opens without a cry", cries, 0) local page = screen:placements() check("EGG at hlcoord 8,1", at(page, 8, 1), "EGG") check("№. at hlcoord 8,3", at(page, 8, 3), "№.") check("????? for the ID", at(page, 11, 3), "?????") check("OT/ at hlcoord 8,5", at(page, 8, 5), "OT/") check("????? for the OT", at(page, 11, 5), "?????") -- No upper half: nothing names the species or its dex slot. check("no dex number", at(page, 10, 0), nil) check("no nickname row", at(page, 8, 2), nil) -- The flavour ladder is `cp $6 / cp $b / cp $29` on the remaining cycles. local function flavorLine(cycles) local s = SummaryMenu.new(newGame(save), { mon = egg(cycles), save = save }) return at(s:eggPlacements(), 1, 9) end check("under 6 cycles it is about to hatch", flavorLine(5), "It's making sounds") check("6 cycles is only close", flavorLine(6), "It moves around") check("10 cycles is still close", flavorLine(10), "It moves around") check("11 cycles needs more time", flavorLine(11), "Wonder what's") check("40 cycles needs more time", flavorLine(40), "Wonder what's") check("41 cycles needs a lot more", flavorLine(41), "This EGG needs a") -- The close string is four lines, two rows apart (`next` steps two). local closePage = SummaryMenu.new(newGame(save), { mon = egg(8), save = save }):eggPlacements() check("close line 2 on row 11", at(closePage, 1, 11), "inside sometimes.") check("close line 4 on row 15", at(closePage, 1, 15), "to hatching.") -- EggStats_JoypadLoop: A and B both exit, and left/right turn no pages. local closed = false game = newGame(save) screen = SummaryMenu.new(game, { party = save.party, index = 3, save = save }) screen.onClose = function() closed = true end game.input:press("right") screen:update(0) check("right turns no page on an egg", screen.page, SummaryMenu.PINK_PAGE) check("and does not close", closed, false) game.input:press("a") screen:update(0) check("a exits the egg screen", closed, true) closed = false game = newGame(save) screen = SummaryMenu.new(game, { party = save.party, index = 3, save = save }) screen.onClose = function() closed = true end game.input:press("b") screen:update(0) check("b exits the egg screen too", closed, true) -- EggStats_UpAction: up walks to the mon above, whose real page comes back. game = newGame(save) screen = SummaryMenu.new(game, { party = save.party, index = 3, save = save }) cries = 0 game.input:press("up") screen:update(0) check("up walks off the egg", screen.index, 2) check("onto the mon's own page", at(screen:placements(), 8, 2), "TOTODILE") check("and plays that mon's cry", cries, 1) -- ...and walking back DOWN onto the egg swaps the egg page in, silently. game.input:press("down") screen:update(0) check("down lands on the egg again", screen.index, 3) check("as the egg page", at(screen:placements(), 8, 1), "EGG") check("with no cry", cries, 1) -- MoveScreenLoop's .cycle loops step over EGG slots: with the party -- CYNDAQUIL / EGG / TOTODILE, right from slot 1 lands on slot 3. save = newSave() save.party = { save.party[1], egg(20), save.party[2] } game = newGame(save) screen = SummaryMenu.new(game, { party = save.party, index = 1, save = save }) screen.page = SummaryMenu.GREEN_PAGE game.input:press("select") screen:update(0) check("select opens the move detail", screen.moveDetail, true) game.input:press("right") screen:update(0) check("right skips the egg slot", screen.index, 3) check("and stays in the move detail", screen.moveDetail, true) game.input:press("left") screen:update(0) check("left skips it coming back", screen.index, 1) -- --------------------------------------------------- the START menu wiring -- src/core/Game2.lua's POKéMON row must ask for the submenu: the field list -- is the one flavour that opens PokemonActionSubmenu on A -- (engine/pokemon/mon_menu.asm) rather than answering to a caller. A -- textual check is the honest one here -- constructing a Game2 needs love -- -- and tests/drivers/gold_party_submenu.lua drives the real thing. 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 local branch = game2Source:match( 'elseif id == "pokemon" then(.-)elseif') check("the START menu's party list opens the action submenu", branch ~= nil and branch:find("submenu = true", 1, true) ~= nil, true) end -- ------------------------------------------------------ wPartyMenuCursor -- -- InitPartyMenuWithCancel seeds wMenuCursorY from wPartyMenuCursor and only -- falls back to row 1 when it is zero or no longer inside the party -- (engine/pokemon/party_menu.asm:546), and PartyMenuSelect writes the picked -- row back before the B test (:600) -- so the list reopens where it was left, -- and only the CANCEL row leaves the byte alone. do local cursorSave = newSave() local cursorGame = newGame(cursorSave) local list = PartyMenu.new(cursorGame, { party = cursorSave.party }) check("a fresh session opens on the first mon", list.index, 1) list.index = 2 list:storeCursor() check("PartyMenuSelect records the row", cursorGame.partyMenuCursor, 2) local reopened = PartyMenu.new(cursorGame, { party = cursorSave.party }) check("and the list reopens on it", reopened.index, 2) -- The CANCEL row is one past the party and jumps out before the store. reopened.index = #cursorSave.party + 1 reopened:storeCursor() check("CANCEL leaves the byte where it was", cursorGame.partyMenuCursor, 2) -- A byte pointing past a party that shrank falls back to row 1. cursorGame.partyMenuCursor = 5 local shrunk = PartyMenu.new(cursorGame, { party = { cursorSave.party[1] } }) check("a row past the party clamps to the first", shrunk.index, 1) end -- ---------------------------------------------------- the held-item marker -- -- .SpawnItemIcon (engine/gfx/mon_icons.asm): a non-zero MON_ITEM swaps the -- icon's frameset for _WITH_MAIL or _WITH_ITEM, whose OAM sets replace the -- BOTTOM-LEFT quadrant with HeldItemIcons tile $08 (mail.2bpp) or $09 -- (item.2bpp). The sheet is INCBIN'd mail first, so row 0 is mail. do -- A save of its own: the blocks above reshuffle `save.party` and drop an -- egg into slot 2. local held = newSave() check("an empty hand gets no marker", PartyMenu.heldMarkerRow(held.party[2]), nil) check("a berry marks the icon with the item tile", PartyMenu.heldMarkerRow(held.party[1]), 1) local mailer = mon("TOTODILE", 10, { fields = { item = "FLOWER_MAIL" } }) check("ItemIsMail picks the mail tile instead", PartyMenu.heldMarkerRow(mailer), 0) -- `ld a, [hl] / and a / ret z`: a zero item byte is an empty hand. check("a zero item byte is an empty hand", PartyMenu.heldMarkerRow({ item = 0 }), nil) -- The draw itself: three of the icon's four 8x8 quadrants plus the marker in -- place of the fourth, and one plain 16x16 quad when there is nothing to -- show. Images are preloaded into iconCache so nothing reaches love.image. local iconImage = { getDimensions = function() return 16, 32 end } local markerImage = { getDimensions = function() return 8, 16 end } local marked = PartyMenu.new(newGame(held), { party = held.party, icons = { species = DATA.gen2Icons.species, icons = DATA.gen2Icons.icons, heldItem = { image = "x/held.png", width = 8, height = 8 }, }, }) marked.iconCache["x/fox.png"] = iconImage marked.iconCache["x/monster.png"] = iconImage marked.iconCache["x/held.png"] = markerImage local calls = {} local realDraw = love.graphics.draw love.graphics.draw = function(image, _, x, y) calls[#calls + 1] = { image = image, x = x, y = y } end marked:drawIcon(held.party[1], 0, 0) check("a held item draws three icon quadrants plus the marker", #calls, 4) check("the marker comes from the HeldItemIcons sheet", calls[4] and calls[4].image, markerImage) check("and lands in the bottom-left quadrant", calls[4] and calls[4].x == 0 and calls[4].y == 8, true) check("the bottom-left species tile is replaced, not covered", calls[3] and calls[3].x == 8 and calls[3].y == 8, true) calls = {} marked:drawIcon(held.party[2], 0, 0) check("an empty hand is still one 16x16 draw", #calls, 1) -- A cache imported before the extractor read HeldItemIcons has no `heldItem` -- row: the list must draw exactly what it drew before, not error. local plain = PartyMenu.new(newGame(held), { party = held.party }) plain.iconCache["x/fox.png"] = iconImage calls = {} plain:drawIcon(held.party[1], 0, 0) check("no marker sheet in the cache falls back to the plain icon", #calls, 1) love.graphics.draw = realDraw end print(("gen2 party menu: %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