From 407f649e9dd97d500b14d6ea5dae7e4ef671829a Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 14 Aug 2026 17:57:59 +0200 Subject: [PATCH] fix(mod-api): harden deferred trainer preparation --- docs/modding.md | 9 ++- docs/rfcs/0010-trainer-battle-party-scope.md | 8 ++ src/battle/BattleState.lua | 2 +- src/ui/BagMenu.lua | 1 + src/world/OverworldController.lua | 29 ++++++- tests/engine/trainer_battle_party_scope.lua | 30 ++++++- tests/engine/trainer_talk_sting_bug764.lua | 85 ++++++++++++++++++++ tests/modkit/cases/trainer_before_battle.lua | 21 +++++ 8 files changed, 176 insertions(+), 9 deletions(-) diff --git a/docs/modding.md b/docs/modding.md index 17d08cea..4aeb5616 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -436,7 +436,7 @@ mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue) continue({ playerPartyIndices = indices }) end, onCancel = function() - continue() + continue({ cancel = true }) end, }) return true @@ -444,7 +444,12 @@ end) ``` Return `true` only when retaining `continue` for a later callback. Calling -`continue()` uses the full save party; passing +`continue({ cancel = true })` ends the encounter without constructing a battle; +the normal encounter completion callback returns control to the overworld and +no trainer-defeated state is written. A cancelled sight encounter is suppressed +at the current player cell so it cannot immediately reopen; moving one cell or +talking to the trainer permits a new challenge. Calling `continue()` uses the +full save party; passing `{ playerPartyIndices = { 2, 4, 5 } }` uses those ordered, one-based party members for initial send, switching and forced replacement, exhaustion, experience traversal, and battle party displays. The continuation is one-shot. diff --git a/docs/rfcs/0010-trainer-battle-party-scope.md b/docs/rfcs/0010-trainer-battle-party-scope.md index 507e8459..9d6ffda4 100644 --- a/docs/rfcs/0010-trainer-battle-party-scope.md +++ b/docs/rfcs/0010-trainer-battle-party-scope.md @@ -36,6 +36,7 @@ Add the guarded hook: mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue) -- context = { trainerClass, partyIndex, mapId, npcId } -- Return true only when the battle has been deferred. + -- continue({ cancel = true }) returns without constructing a battle. -- Call continue() for the full save party, or: -- continue({ playerPartyIndices = { 2, 4, 5 } }) end) @@ -57,6 +58,13 @@ exhaustion/blackout checks, participant and EXP.ALL traversal, party counts, and party-ball presentation. Checkpoints preserve the index list and rebuild the same view before restoring battlers. +`{ cancel = true }` ends a deferred encounter through its normal completion +callback without constructing a battle or writing trainer-defeated state. A +cancelled sight encounter is suppressed while the player remains on the same +cell, preventing immediate reacquisition; moving or directly talking permits a +new challenge. Cancellation is also one-shot; if supplied alongside a party +index list, cancellation wins. + The API sets no maximum, chooses no members, identifies no boss, and contains no scaling or challenge policy. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 78655ee2..d2fc950f 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -738,7 +738,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex, opts) -- data/scripts/victories.lua on class#party, so keep it on the battle (#782). self.partyIndex = partyIndex or 1 self.playerParty, self.playerPartyIndices = scopedPlayerParty(game, - opts and opts.playerPartyIndices) + type(opts) == "table" and opts.playerPartyIndices or nil) self.trainer = game.data.trainers[oppClass] assert(self.trainer, "unknown trainer class " .. tostring(oppClass)) -- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 25440f22..e8cb6630 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -383,6 +383,7 @@ local function pickTargetAndUse(game, battle, id, list) local def = game.data.items[id] local opts = { pickOnly = true, + battle = battle, -- HP medicine animates its bar with the picker still up (#252). Only -- out of battle: the in-battle tail closes the bag list underneath -- first, which needs the picker already gone. diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index e2110a78..cf70c3e2 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -230,6 +230,7 @@ function OverworldState:enter(mapId, x, y, facing, opts) -- a fresh entry, or a stale flag can freeze player input forever self.engaging = false self.emote = nil + self.cancelledTrainerSight = nil -- volatile WRAM state in pokered; never serialize across save/load self.wildEncounterGraceSteps = 0 -- survives save/load: a loaded game may start inside a building whose @@ -3095,7 +3096,8 @@ end -- Public pre-trainer gate. A mod may retain continueBattle while a registered -- preparation screen is on top, then resume once with an optional ordered -- save-party index scope. The hook is cold on a no-mod boot. -function OverworldState.prepareTrainerBattle(game, context, startBattle) +function OverworldState.prepareTrainerBattle(game, context, startBattle, + cancelBattle) if not Runtime.wantsHook("trainer.before_battle") then startBattle() return false @@ -3104,7 +3106,11 @@ function OverworldState.prepareTrainerBattle(game, context, startBattle) local function continueBattle(options) if started then return false end started = true - startBattle(options) + if type(options) == "table" and options.cancel == true then + if cancelBattle then cancelBattle() end + else + startBattle(options) + end return true end local deferred = Runtime.call("trainer.before_battle", @@ -3141,6 +3147,7 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText local BattleState = require("src.battle.BattleState") local function startBattle(options) + self.cancelledTrainerSight = nil -- TalkToTrainer (home/trainers.asm:88) prints the before-battle text -- FIRST and only then runs `call EngageMapTrainer` / `jp -- StartTrainerBattle`, so a trainer challenged on foot gets the sting @@ -3203,7 +3210,16 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText partyIndex = d.trainerParty or 1, mapId = self.map.id, npcId = npc.id, - }, startBattle) + }, startBattle, function() + if self.player then + self.cancelledTrainerSight = { + npcId = npc.id, + playerX = self.player.cellX, + playerY = self.player.cellY, + } + end + if onDone then onDone() end + end) end if skipBattleText then prepareBattle() @@ -3406,11 +3422,18 @@ function OverworldState:checkTrainerSight() if self.player.moving or self.engaging then return end if Game.stack:top() ~= self then return end local p = self.player + local cancelled = self.cancelledTrainerSight + if cancelled and (cancelled.playerX ~= p.cellX + or cancelled.playerY ~= p.cellY) then + self.cancelledTrainerSight = nil + cancelled = nil + end for _, npc in ipairs(self.npcs) do local d = npc.def -- CheckFightingMapTrainers engages ANY aligned trainer sprite, -- walkers included (they sight between steps) if d.trainerClass and not npc.moving + and not (cancelled and cancelled.npcId == npc.id) and not self:trainerDefeated(npc) and not mapScripts.talkScript(self.map.id, d.text) and trainerSpriteOnScreen(npc, p) then diff --git a/tests/engine/trainer_battle_party_scope.lua b/tests/engine/trainer_battle_party_scope.lua index a424fa35..14ac2e81 100644 --- a/tests/engine/trainer_battle_party_scope.lua +++ b/tests/engine/trainer_battle_party_scope.lua @@ -6,12 +6,16 @@ love = love or require("tests.love_stub") local T = require("tests.harness").suite("trainer battle party scope") local BattleState = require("src.battle.BattleState") +local BagMenu = require("src.ui.BagMenu") local Fixtures = require("tests.modkit").fixtures local PartyMenu = require("src.ui.PartyMenu") local Pokemon = require("src.pokemon.Pokemon") local SaveData = require("src.core.SaveData") +local Bag = require("src.inventory.Bag") local Data = Fixtures.fresh() +Data.items.POTION = { id = "POTION", index = 99, name = "POTION", + price = 300, tossable = true } local function makeGame() local save = SaveData.newGame() @@ -20,9 +24,11 @@ local function makeGame() Pokemon.new(Data, "FIXMON_B", 11), Pokemon.new(Data, "FIXMON_C", 12), } - return { data = Data, save = save, stack = { - push = function() end, pop = function() end, top = function() end, - } } + local stack = { states = {} } + function stack:push(value) self.states[#self.states + 1] = value end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + return { data = Data, save = save, stack = stack } end local game = makeGame() @@ -47,6 +53,18 @@ local menu = PartyMenu.new(game, { battle = battle }) T.check(menu.party == battle.playerParty, "battle party menus traverse only the local eligible view") +Bag.add(game.save, "POTION", 1) +local bag = BagMenu.new(game, { battle = battle }) +local potion +for _, row in ipairs(bag.items) do + if row.value == "POTION" then potion = row; break end +end +T.check(potion ~= nil, "the fixture potion is available for target selection") +bag.onChoose(potion, bag) +local targetPicker = game.stack:top() +T.check(targetPicker and targetPicker.party == battle.playerParty, + "in-battle item target selection traverses only eligible members") + second.hp = 0 battle.player.mon.hp = 0 battle:playerMonFainted() @@ -94,6 +112,12 @@ local duplicate = BattleState.newTrainer(duplicateGame, T.eq(duplicate.playerParty, nil, "duplicate members make the entire scope fall back") +local malformedOptionsGame = makeGame() +local malformedOptions = BattleState.newTrainer(malformedOptionsGame, + "OPP_FIX_YOUNGSTER", 1, 7) +T.eq(malformedOptions.playerParty, nil, + "a malformed options value degrades to the vanilla full-party path") + local linkGame = makeGame() local linkBattle = BattleState.newTrainer(linkGame, "OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 3 } }) diff --git a/tests/engine/trainer_talk_sting_bug764.lua b/tests/engine/trainer_talk_sting_bug764.lua index 838f5f05..7587cf9e 100644 --- a/tests/engine/trainer_talk_sting_bug764.lua +++ b/tests/engine/trainer_talk_sting_bug764.lua @@ -13,6 +13,8 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") local OW = require("src.world.OverworldController") +local Hooks = require("src.mods.Hooks") +local Runtime = require("src.mods.Runtime") local function setUpvalue(fn, name, val) local i = 1 @@ -96,6 +98,89 @@ T.eq(rivalCount, 0, "rival classes play no encounter sting here") local _, seenCount = stingFor("OPP_LASS", true) T.eq(seenCount, 0, "self.engaging suppresses a second sting") +-- A deferred preparation may cancel instead of constructing a battle. For a +-- sight trainer, that must leave a one-position latch: otherwise the still +-- undefeated adjacent trainer sees the stationary player again next frame and +-- immediately reopens the preparation screen. +local oldEvents, oldHooks, oldErrors = Runtime.events, Runtime.hooks, + Runtime.errors +local cancelHooks = Hooks.new() +Runtime.install(oldEvents, cancelHooks, oldErrors) +cancelHooks:wrap("trainer.before_battle", function(_, _, _, continue) + continue({ cancel = true }) + return true +end, 0, "cancel_probe") +local cancelNpc = { id = "npc#cancel", cellX = 0, cellY = -1, + facing = "down", moving = false, def = { trainerClass = "OPP_LASS", + trainerParty = 1, index = 1 } } +fakeSelf.player = { cellX = 0, cellY = 0, moving = false } +fakeSelf.map.id = "FIX_ROUTE" +fakeSelf.engaging = false +pushed, plays = {}, {} +local completed = 0 +fakeSelf:engageTrainer(cancelNpc, function() completed = completed + 1 end) +pushed[1].onDone() +T.eq(completed, 1, "cancel completes the deferred encounter without a battle") +T.same(fakeSelf.cancelledTrainerSight, { + npcId = "npc#cancel", playerX = 0, playerY = 0, +}, "cancel suppresses immediate sight re-entry at the current player cell") + +local approaches = 0 +fakeSelf.npcs = { cancelNpc } +fakeSelf.trainerDefeated = function() return false end +fakeSelf.startTrainerApproach = function() approaches = approaches + 1 end +fakeGame.stack.top = function() return fakeSelf end +fakeGame.data.trainerHeader = function() return { range = 2 } end +T.check(setUpvalue(OW.checkTrainerSight, "mapScripts", { + talkScript = function() return nil end, +}), "mapScripts upvalue on checkTrainerSight") +fakeSelf:checkTrainerSight() +T.eq(approaches, 0, + "a cancelled adjacent trainer cannot reacquire the stationary player") +fakeSelf.player.cellX = 1 +fakeSelf:checkTrainerSight() +T.eq(fakeSelf.cancelledTrainerSight, nil, + "moving one cell releases the cancelled sight latch") +fakeSelf.player.cellX = 0 +fakeSelf:checkTrainerSight() +T.eq(approaches, 1, + "returning to the sight line permits a fresh trainer challenge") +Runtime.install(oldEvents, oldHooks, oldErrors) + +-- OverworldState is a singleton reused by StateStack. A title/load cycle must +-- clear this volatile latch too, or CONTINUE at the same map and cell inherits +-- the cancelled sight suppression from the previous session. +local Camera = require("src.render.Camera") +local Collision = require("src.world.Collision") +local Encounter = require("src.world.Encounter") +local ScriptRunner = require("src.script.ScriptRunner") +local oldCameraNew, oldCollisionLoad = Camera.new, Collision.load +local oldEncounterLoad, oldRunnerNew = Encounter.load, ScriptRunner.new +local oldGameModule = package.loaded["src.core.Game"] +local oldScriptsModule = package.loaded["data.scripts.init"] +Camera.new = function() return {} end +Collision.load = function() end +Encounter.load = function() end +ScriptRunner.new = function() return {} end +package.loaded["src.core.Game"] = { + data = {}, save = { lastOutdoor = "FIX_ROUTE" }, +} +package.loaded["data.scripts.init"] = {} +local lifecycle = setmetatable({ + cancelledTrainerSight = { + npcId = "FIX_ROUTE_obj_1", playerX = 0, playerY = 0, + }, + setMap = function() end, + refreshStandingOnWarp = function() end, +}, { __index = OW }) +lifecycle:enter("FIX_ROUTE", 0, 0, "down", { via = "boot" }) +T.eq(lifecycle.cancelledTrainerSight, nil, + "fresh overworld entry clears a cancelled trainer sight latch") +Camera.new, Collision.load = oldCameraNew, oldCollisionLoad +Encounter.load, ScriptRunner.new = oldEncounterLoad, oldRunnerNew +package.loaded["src.core.Game"] = oldGameModule +package.loaded["data.scripts.init"] = oldScriptsModule + if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic else package.loaded["src.core.Music"] = nil end if realBattle ~= nil then package.loaded["src.battle.BattleState"] = realBattle diff --git a/tests/modkit/cases/trainer_before_battle.lua b/tests/modkit/cases/trainer_before_battle.lua index b95aff1c..cad894c6 100644 --- a/tests/modkit/cases/trainer_before_battle.lua +++ b/tests/modkit/cases/trainer_before_battle.lua @@ -68,4 +68,25 @@ T.eq(out.continue({ playerPartyIndices = { 1 } }), false, T.eq(calls, 1, "a duplicate resume cannot start a second battle") run.release() +local cancelRun = T.sdk.loadMods({ "mods/scope_probe" }, { + fs = T.sdk.memfs(FIXTURE), +}) +local starts, cancels = 0, 0 +OW.prepareTrainerBattle(game, { + trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 2, + mapId = "FIX_ROUTE", npcId = "TRAINER_7", +}, function() + starts = starts + 1 +end, function() + cancels = cancels + 1 +end) +local cancelOut = cancelRun.loader.exports.scope_probe or {} +T.eq(cancelOut.continue({ cancel = true }), true, + "the retained continuation can cancel a deferred encounter") +T.eq(starts, 0, "cancelling never constructs a trainer battle") +T.eq(cancels, 1, "cancelling invokes the encounter's completion callback") +T.eq(cancelOut.continue(), false, + "a cancelled continuation remains one-shot") +cancelRun.release() + T.finish("trainer_before_battle")