From f62b1268c888bfce5bf591973e6a0c8364e788a6 Mon Sep 17 00:00:00 2001 From: sanjinpepic Date: Sun, 16 Aug 2026 20:11:39 +0200 Subject: [PATCH] Add an item.use hook around BagMenu's item-use dispatch useOn was a plain Lua local: every result ItemEffects.use returned fell through to one unconditional showMessages with no seam a mod could reach, unlike menu.lua/boxmark.lua/formview.lua's screens, which wrap their own default behavior as a table field or a Runtime hook. A mod could not suppress a message, delay it behind a screen of its own, or substitute a different outcome for one item id -- exactly the gap noted against Ultra Burst's item-driven fusion, which had nowhere left to attach a bespoke animation once TextBox.new turned out to be the only other reachable seam. This wraps the whole dispatch in a Runtime.call("item.use", ...) hook, the same mechanism "battle.overlay", "ui.party.submenu" and the rest of src/ui already use, rather than exporting BagMenu.useOn as a table field. A hook is the smaller commitment: it is additive (a fresh Runtime.call site needs no schema or manifest change and costs nothing unsubscribed -- see tests/engine/gate_hooks.lua's null-object case) and a mod can still run the vanilla flow unchanged by calling the handed-in vanilla function, whereas a table field would fix useOn's exact signature as public API the moment it shipped. If the maintainer would rather match the sibling screens' convention directly, exporting BagMenu.useOn is the alternative and does not conflict with this hook existing alongside it. vanillaUseOn keeps the original function body; useOn is now the thin wrapper mods observe through, and every internal caller in this file still goes through useOn so the hook fires on every path into it. --- src/ui/BagMenu.lua | 17 +++++- tests/engine/item_use_hook.lua | 101 +++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 tests/engine/item_use_hook.lua diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index e8cb6630..5d6807f1 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -4,6 +4,7 @@ local ItemEffects = require("src.inventory.ItemEffects") local ListMenu = require("src.ui.ListMenu") +local Runtime = require("src.mods.Runtime") local TextBox = require("src.render.TextBox") local BagMenu = {} @@ -46,7 +47,16 @@ end -- the stack, so every exit that prints has to close it afterwards. For -- every other item the picker popped itself first and closePicker's identity -- check makes it a no-op (#252). -local function useOn(game, battle, id, target, list, moveIndex, picker) +-- +-- Every result string used to fall through to this one unconditional +-- function with no seam around it: a mod could not suppress a message, +-- delay it behind a screen of its own, or replace the outcome for one item +-- id. The "item.use" hook wraps the whole dispatch (not a name per +-- result -- a mod deciding what a Poké Doll or a stone does needs the +-- SAME reach a vanilla `if result == ...` branch has, not a narrower one), +-- the way "battle.overlay" and "ui.party.submenu" already wrap a +-- screen's own default behavior elsewhere in src/ui. +local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) local result, payload, extra = ItemEffects.use(game.data, game.save, id, target, battle, moveIndex, game.overworld) local function closePicker() @@ -375,6 +385,11 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) showMessages(game, payload, closePicker) -- failed end +local function useOn(game, battle, id, target, list, moveIndex, picker) + return Runtime.call("item.use", vanillaUseOn, + game, battle, id, target, list, moveIndex, picker) +end + local function pickTargetAndUse(game, battle, id, list) -- pick a target from the party -- the ETHERs and PP UP open the move menu after picking a mon diff --git a/tests/engine/item_use_hook.lua b/tests/engine/item_use_hook.lua new file mode 100644 index 00000000..9107a32c --- /dev/null +++ b/tests/engine/item_use_hook.lua @@ -0,0 +1,101 @@ +-- Public mod-API coverage for the "item.use" hook (src/ui/BagMenu.lua). +-- +-- Before this hook existed, every result ItemEffects.use returned fell +-- through to one unconditional call with nothing wrapped around it: a mod +-- could not suppress a message, delay it behind a screen of its own, or +-- replace what a specific item id does after the bag decides to use it. +-- This exercises the seam end to end through the public mod API -- a real +-- BagMenu list, a real USE selection -- rather than calling the hook +-- machinery directly. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Bag = require("src.inventory.Bag") + +-- Real TextBoxes want a Font atlas; this only cares that useOn reaches the +-- no-effect fallthrough, so the same stand-in tests/parity_rare_candy_menu.lua +-- uses for a ROM-backed run works here too. +local realTextBox = package.loaded["src.render.TextBox"] +package.loaded["src.render.TextBox"] = { + new = function(_, text, done) return { textBox = true, text = text, done = done } end, +} +package.loaded["src.ui.BagMenu"] = nil +local BagMenu = require("src.ui.BagMenu") + +local FIXTURE = { + ["mods/item_hook_probe/manifest.json"] = [[{ + "id": "item_hook_probe", + "name": "Item Hook Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/item_hook_probe/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("item.use", + function(vanilla, game, battle, id, target, list, moveIndex, picker) + mod.exports.calls = (mod.exports.calls or 0) + 1 + mod.exports.id = id + mod.exports.battle = battle + mod.exports.target = target + return vanilla(game, battle, id, target, list, moveIndex, picker) + end) + ]], +} + +local function newStack() + local stack = { states = {} } + function stack:push(s) self.states[#self.states + 1] = s end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + return stack +end + +local run = T.sdk.loadMods({ "mods/item_hook_probe" }, { + fs = T.sdk.memfs(FIXTURE), +}) +T.eq(#run.errors, 0, + "the probe mod loads clean (" .. tostring(run.errors[1]) .. ")") + +local game = { + data = run.data, + stack = newStack(), + save = { + player = { name = "RED" }, inventory = {}, money = 0, + options = { battleStyle = "set", battleAnim = "on" }, + pokedex = { seen = {}, owned = {} }, flags = {}, + }, +} +Bag.add(game.save, "FIX_POTION", 1) + +local list = BagMenu.new(game, {}) +game.stack:push(list) +local row +for i, r in ipairs(list.items) do + if r.value == "FIX_POTION" then row = i end +end +T.check(row ~= nil, "the fixture item is in the bag") +list.index = row +list.onChoose(list.items[row], list) + +-- out of battle the bag offers USE / TOSS first (start_sub_menus.asm) +local sub = game.stack:top() +T.check(sub ~= nil and sub.items and sub.items[1] and sub.items[1].onSelect, + "the USE/TOSS submenu opened") +sub.items[1].onSelect() + +local out = run.loader.exports.item_hook_probe or {} +T.eq(out.calls, 1, "the hook fires exactly once for a bag item use") +T.eq(out.id, "FIX_POTION", "the hook sees the item id") +T.eq(out.battle, nil, "the hook sees the field-use battle argument (nil)") + +local top = game.stack:top() +T.check(type(top) == "table" and top.textBox == true, + "vanilla still ran: the no-effect message box landed on the stack") + +run.release() +package.loaded["src.render.TextBox"] = realTextBox +package.loaded["src.ui.BagMenu"] = nil + +T.finish()