-- Driver: watch a POTION fill the party menu's HP bar (#252). -- pokered engine/items/item_effects.asm .doneHealing runs UpdateHPBar2 with -- the party menu still drawn; status cures branch off and never touch the bar. -- Not under POKEPORT_SPEED: SFX_HEAL_HP rides the real-time audio clock. -- POKEPORT_DRIVER=tests/drivers/party_heal_bug252_test.lua \ -- POKEPORT_IDENTITY=bug252 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . return function(game) local U = dofile("tests/drivers/util.lua") local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" local Pokemon = require("src.pokemon.Pokemon") local Bag = require("src.inventory.Bag") local ItemEffects = require("src.inventory.ItemEffects") local PartyMenu = require("src.ui.PartyMenu") local TextBox = require("src.render.TextBox") local pass, fail = 0, 0 local function check(label, ok) if ok then pass = pass + 1 else fail = fail + 1 end U.log(ok and "PASS" or "FAIL", label) return ok end local function top() return game.stack:top() end local function isPicker(s) return s ~= nil and (s.screenId == "PartyMenu" or getmetatable(s) == PartyMenu) end local function isBox(s) return getmetatable(s) == TextBox end local function inStack(pred) for _, s in ipairs(game.stack.states or {}) do if pred(s) then return true end end return false end -- ---- preconditions ------------------------------------------------------ -- A lost item id, a healsHP gate that dropped an item, or a use() that stops -- handing back the pre-heal HP all read as "the animation is broken". U.log("======== #252 party-menu HP fill: machine checks ========") check("ItemEffects.healsHP exists", type(ItemEffects.healsHP) == "function") check("PartyMenu:animateTo exists", type(PartyMenu.animateTo) == "function") check("PartyMenu:close exists", type(PartyMenu.close) == "function") if type(ItemEffects.healsHP) == "function" then -- .doneHealing is reached by exactly the .healHP items; FULL_HEAL and the -- single-status cures jump to the no-bar branch for _, id in ipairs({ "POTION", "SUPER_POTION", "HYPER_POTION", "MAX_POTION", "FULL_RESTORE", "REVIVE", "MAX_REVIVE" }) do check(id .. " takes the .healHP (animated) path", ItemEffects.healsHP(id) == true) end for _, id in ipairs({ "ANTIDOTE", "PARLYZ_HEAL", "AWAKENING", "BURN_HEAL", "ICE_HEAL", "FULL_HEAL", "ETHER" }) do check(id .. " does NOT animate the bar (status cure / PP)", ItemEffects.healsHP(id) ~= true) end end for _, id in ipairs({ "POTION", "MAX_POTION", "REVIVE", "ANTIDOTE" }) do check(id .. " resolves in the item table", game.data.items[id] ~= nil) end -- use() must hand back the PRE-heal HP so the fill has a start (wHPBarOldHP) do local scratch = Pokemon.new(game.data, "BULBASAUR", 20) scratch.hp = 3 local scratchSave = { party = { scratch }, inventory = {}, flags = {}, pokedex = { seen = {}, owned = {} } } local result, msgs, extra = ItemEffects.use(game.data, scratchSave, "POTION", scratch) check("POTION on a hurt mon reports consumed", result == "consumed") check("...and hands back extra.healedFrom = the pre-heal HP", type(extra) == "table" and extra.healedFrom == 3) check("...with the restored-HP message", type(msgs) == "table" and type(msgs[1]) == "string" and msgs[1]:find("restored", 1, true) ~= nil) local _, _, cureExtra = ItemEffects.use(game.data, scratchSave, "ANTIDOTE", scratch) check("ANTIDOTE hands back no healedFrom (no fill)", cureExtra == nil or cureExtra.healedFrom == nil) end -- ---- the fixture -------------------------------------------------------- -- CHARIZARD L50 sits near 150 max HP, so a MAX_POTION from 1 HP is the full -- 96-frame fill across the whole 48-pixel bar. local lead = Pokemon.new(game.data, "CHARIZARD", 50) local fainted = Pokemon.new(game.data, "PIKACHU", 30) local poisoned = Pokemon.new(game.data, "SNORLAX", 40) lead.hp = 1 fainted.hp = 0 poisoned.status = "PSN" game.save.party = { lead, fainted, poisoned } game.save.player.name = "RED" for _, row in ipairs({ { "MAX_POTION", 9 }, { "POTION", 9 }, { "REVIVE", 9 }, { "ANTIDOTE", 9 } }) do Bag.add(game.save, row[1], row[2]) end U.log(("lead: %s %d/%d HP"):format(lead.species, lead.hp, lead.stats.hp)) U.teleport(game, "PALLET_TOWN", 10, 8, "down") U.wait(10) -- ---- menu navigation --------------------------------------------------- local function cursorTo(menu, want) for _ = 1, 40 do if not menu or menu.index == want then return menu and menu.index == want end U.tap(game, menu.index < want and "down" or "up") U.wait(3) end return menu.index == want end -- START -> ITEM -> -> USE, leaving the party picker open. Returns the -- picker, or nil plus a reason. local function openPickerFor(id) U.tap(game, "start") U.wait(10) local menu = top() if not (menu and menu.screenId == "StartMenu") then return nil, "start menu never opened" end -- the ITEM row shifts with POKéDEX / LINK / MODS, so never hardcode it local itemRow for i, it in ipairs(menu.items or {}) do if it.label == "ITEM" then itemRow = i break end end if not itemRow or not cursorTo(menu, itemRow) then return nil, "no ITEM row" end U.tap(game, "a") U.wait(10) local bag = top() if not (bag and bag.screenId == "BagMenu") then return nil, "bag never opened" end local bagRow for i, r in ipairs(bag.items or {}) do if r.value == id then bagRow = i break end end if not bagRow or not cursorTo(bag, bagRow) then return nil, id .. " not in bag" end U.tap(game, "a") U.wait(10) -- outside battle every usable item offers USE / TOSS first; USE is row 1 local ut = top() if ut and ut.items and ut.items[1] and ut.items[1].label == "USE" then if not cursorTo(ut, 1) then return nil, "USE row unreachable" end U.tap(game, "a") U.wait(10) end local picker = top() if not isPicker(picker) then return nil, "party picker never opened" end return picker end local function backToOverworld() for _ = 1, 30 do if top() == game.overworld then return true end U.tap(game, "b") U.wait(6) end return top() == game.overworld end -- ======== scripted run: MAX_POTION on the 1 HP lead ====================== U.log("======== #252 scripted run: MAX POTION on a 1 HP CHARIZARD ========") local picker, why = openPickerFor("MAX_POTION") check("party picker opened for MAX POTION" .. (why and (" (" .. why .. ")") or ""), picker ~= nil) if picker then check("cursor sits on the hurt lead", cursorTo(picker, 1)) U.shot(game, DIR .. "/bug252_picker_before.png") local hpBefore = lead.hp U.tap(game, "a") -- choose the lead -- THE DEFECT: the picker used to pop here, before the item had even run. check("the party menu is STILL the top state after the A press", isPicker(top())) check("the fill is running (picker.heal is set)", type(picker.heal) == "table") if type(picker.heal) == "table" then check("the fill starts from the pre-heal HP (wHPBarOldHP)", math.floor(picker.heal.shown + 0.5) == hpBefore) end -- Sample the climb and prove input is ignored for its duration -- (UpdateHPBar2 blocks). U.frame() is the real yield count: the taps below -- each burn a frame, so an iteration counter would under-report. local startFrame, samples, blocked = U.frame(), {}, true local iter, shot1, shot2 = 0, false, false for _ = 1, 400 do if not picker.heal then break end local shown = picker.heal.shown samples[#samples + 1] = shown local frac = shown / math.max(1, lead.stats.hp) if not shot1 and frac > 0.33 then shot1 = true U.shot(game, DIR .. "/bug252_fill_third.png") elseif not shot2 and frac > 0.66 then shot2 = true U.shot(game, DIR .. "/bug252_fill_two_thirds.png") else -- mash B and A: neither may do anything while the bar is filling U.tap(game, (iter % 2 == 0) and "b" or "a") end if picker.heal and not isPicker(top()) then blocked = false end iter = iter + 1 U.wait(1) end local frames = U.frame() - startFrame U.log(("fill ran ~%d frames (%.2f s at 60 Hz)"):format(frames, frames / 60)) check("the fill took more than half a second (it animates, not snaps)", frames > 30) check("the fill is not absurdly long (< 3 s)", frames < 180) check("A and B did nothing while the bar filled", blocked) local rose = #samples >= 2 and samples[#samples] > samples[1] check("the drawn HP climbed over those frames", rose) if #samples >= 2 then U.log(("shown HP walked %.1f -> %.1f of %d") :format(samples[1], samples[#samples], lead.stats.hp)) end check("the mon really is at full HP now", lead.hp == lead.stats.hp) -- .showHealingItemMessage: the message prints with the menu still drawn for _ = 1, 60 do if isBox(top()) then break end U.wait(1) end check("the message box opened", isBox(top())) check("...over the STILL-drawn party menu", inStack(isPicker)) U.wait(60) -- let the line type out, so the shot shows the text not an empty box U.shot(game, DIR .. "/bug252_message_over_party.png") -- TextBox pops BEFORE it fires onDone, which is what makes -- PartyMenu:close's identity check land on the picker for _ = 1, 30 do if not inStack(isPicker) then break end U.tap(game, "a") U.wait(8) end check("the picker is gone once the message is dismissed", not inStack(isPicker)) local back = top() check("and we are back on the ITEM list", back ~= nil and back.screenId == "BagMenu") U.shot(game, DIR .. "/bug252_back_on_bag.png") end backToOverworld() -- ======== contrast: ANTIDOTE must NOT animate =========================== U.log("======== #252 contrast: ANTIDOTE (no bar fill at all) ========") local cure = openPickerFor("ANTIDOTE") if check("party picker opened for ANTIDOTE", cure ~= nil) then check("keepOpen is off for a status cure", cure.keepOpen ~= true) cursorTo(cure, 3) -- the poisoned SNORLAX U.tap(game, "a") U.wait(6) check("no fill was started for a status cure", cure.heal == nil) check("the picker popped itself, like every non-medicine item", not inStack(isPicker)) U.shot(game, DIR .. "/bug252_antidote_message.png") check("PSN was cured", poisoned.status == nil) end backToOverworld() -- ---- verdict, then re-arm and hand off ---------------------------------- U.log(("======== machine checks: %d passed, %d failed ========"):format(pass, fail)) lead.hp = 1 fainted.hp = 0 poisoned.status = "PSN" local rearmed = openPickerFor("MAX_POTION") if rearmed then cursorTo(rearmed, 1) end U.log("The bag, USE and the party picker are re-opened with the cursor on a") U.log("1 HP CHARIZARD and a MAX POTION chosen. Press A, watch slot 1's bar:") U.log("the list stays up, the bar lengthens over ~1.5s with the number, and") U.log("buttons do nothing until it lands. #252 was the list snapping shut.") U.log("Spare items are in the bag if you want to run it again.") while true do coroutine.yield() end end