Files
gen1recomp/tests/gen2_party_menu_test.lua
T
bryanthaboi ae6cac89e1 G2 support
2026-08-11 11:53:30 -04:00

546 lines
20 KiB
Lua

-- 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, "<LV>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("<ID>№. at hlcoord 8,3", at(page, 8, 3), "<ID>№.")
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