fix(mod-api): harden deferred trainer preparation

This commit is contained in:
MaxTomahawk
2026-08-14 17:57:59 +02:00
parent a77210799f
commit 407f649e9d
8 changed files with 176 additions and 9 deletions
+7 -2
View File
@@ -436,7 +436,7 @@ mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
continue({ playerPartyIndices = indices }) continue({ playerPartyIndices = indices })
end, end,
onCancel = function() onCancel = function()
continue() continue({ cancel = true })
end, end,
}) })
return true return true
@@ -444,7 +444,12 @@ end)
``` ```
Return `true` only when retaining `continue` for a later callback. Calling 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 `{ playerPartyIndices = { 2, 4, 5 } }` uses those ordered, one-based party
members for initial send, switching and forced replacement, exhaustion, members for initial send, switching and forced replacement, exhaustion,
experience traversal, and battle party displays. The continuation is one-shot. experience traversal, and battle party displays. The continuation is one-shot.
@@ -36,6 +36,7 @@ Add the guarded hook:
mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue) mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
-- context = { trainerClass, partyIndex, mapId, npcId } -- context = { trainerClass, partyIndex, mapId, npcId }
-- Return true only when the battle has been deferred. -- 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: -- Call continue() for the full save party, or:
-- continue({ playerPartyIndices = { 2, 4, 5 } }) -- continue({ playerPartyIndices = { 2, 4, 5 } })
end) 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 and party-ball presentation. Checkpoints preserve the index list and rebuild
the same view before restoring battlers. 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 The API sets no maximum, chooses no members, identifies no boss, and contains
no scaling or challenge policy. no scaling or challenge policy.
+1 -1
View File
@@ -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). -- data/scripts/victories.lua on class#party, so keep it on the battle (#782).
self.partyIndex = partyIndex or 1 self.partyIndex = partyIndex or 1
self.playerParty, self.playerPartyIndices = scopedPlayerParty(game, self.playerParty, self.playerPartyIndices = scopedPlayerParty(game,
opts and opts.playerPartyIndices) type(opts) == "table" and opts.playerPartyIndices or nil)
self.trainer = game.data.trainers[oppClass] self.trainer = game.data.trainers[oppClass]
assert(self.trainer, "unknown trainer class " .. tostring(oppClass)) assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
-- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName -- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName
+1
View File
@@ -383,6 +383,7 @@ local function pickTargetAndUse(game, battle, id, list)
local def = game.data.items[id] local def = game.data.items[id]
local opts = { local opts = {
pickOnly = true, pickOnly = true,
battle = battle,
-- HP medicine animates its bar with the picker still up (#252). Only -- HP medicine animates its bar with the picker still up (#252). Only
-- out of battle: the in-battle tail closes the bag list underneath -- out of battle: the in-battle tail closes the bag list underneath
-- first, which needs the picker already gone. -- first, which needs the picker already gone.
+26 -3
View File
@@ -230,6 +230,7 @@ function OverworldState:enter(mapId, x, y, facing, opts)
-- a fresh entry, or a stale flag can freeze player input forever -- a fresh entry, or a stale flag can freeze player input forever
self.engaging = false self.engaging = false
self.emote = nil self.emote = nil
self.cancelledTrainerSight = nil
-- volatile WRAM state in pokered; never serialize across save/load -- volatile WRAM state in pokered; never serialize across save/load
self.wildEncounterGraceSteps = 0 self.wildEncounterGraceSteps = 0
-- survives save/load: a loaded game may start inside a building whose -- 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 -- Public pre-trainer gate. A mod may retain continueBattle while a registered
-- preparation screen is on top, then resume once with an optional ordered -- 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. -- 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 if not Runtime.wantsHook("trainer.before_battle") then
startBattle() startBattle()
return false return false
@@ -3104,7 +3106,11 @@ function OverworldState.prepareTrainerBattle(game, context, startBattle)
local function continueBattle(options) local function continueBattle(options)
if started then return false end if started then return false end
started = true started = true
startBattle(options) if type(options) == "table" and options.cancel == true then
if cancelBattle then cancelBattle() end
else
startBattle(options)
end
return true return true
end end
local deferred = Runtime.call("trainer.before_battle", 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 BattleState = require("src.battle.BattleState")
local function startBattle(options) local function startBattle(options)
self.cancelledTrainerSight = nil
-- TalkToTrainer (home/trainers.asm:88) prints the before-battle text -- TalkToTrainer (home/trainers.asm:88) prints the before-battle text
-- FIRST and only then runs `call EngageMapTrainer` / `jp -- FIRST and only then runs `call EngageMapTrainer` / `jp
-- StartTrainerBattle`, so a trainer challenged on foot gets the sting -- 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, partyIndex = d.trainerParty or 1,
mapId = self.map.id, mapId = self.map.id,
npcId = npc.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 end
if skipBattleText then if skipBattleText then
prepareBattle() prepareBattle()
@@ -3406,11 +3422,18 @@ function OverworldState:checkTrainerSight()
if self.player.moving or self.engaging then return end if self.player.moving or self.engaging then return end
if Game.stack:top() ~= self then return end if Game.stack:top() ~= self then return end
local p = self.player 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 for _, npc in ipairs(self.npcs) do
local d = npc.def local d = npc.def
-- CheckFightingMapTrainers engages ANY aligned trainer sprite, -- CheckFightingMapTrainers engages ANY aligned trainer sprite,
-- walkers included (they sight between steps) -- walkers included (they sight between steps)
if d.trainerClass and not npc.moving if d.trainerClass and not npc.moving
and not (cancelled and cancelled.npcId == npc.id)
and not self:trainerDefeated(npc) and not self:trainerDefeated(npc)
and not mapScripts.talkScript(self.map.id, d.text) and not mapScripts.talkScript(self.map.id, d.text)
and trainerSpriteOnScreen(npc, p) then and trainerSpriteOnScreen(npc, p) then
+27 -3
View File
@@ -6,12 +6,16 @@ love = love or require("tests.love_stub")
local T = require("tests.harness").suite("trainer battle party scope") local T = require("tests.harness").suite("trainer battle party scope")
local BattleState = require("src.battle.BattleState") local BattleState = require("src.battle.BattleState")
local BagMenu = require("src.ui.BagMenu")
local Fixtures = require("tests.modkit").fixtures local Fixtures = require("tests.modkit").fixtures
local PartyMenu = require("src.ui.PartyMenu") local PartyMenu = require("src.ui.PartyMenu")
local Pokemon = require("src.pokemon.Pokemon") local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local Bag = require("src.inventory.Bag")
local Data = Fixtures.fresh() local Data = Fixtures.fresh()
Data.items.POTION = { id = "POTION", index = 99, name = "POTION",
price = 300, tossable = true }
local function makeGame() local function makeGame()
local save = SaveData.newGame() local save = SaveData.newGame()
@@ -20,9 +24,11 @@ local function makeGame()
Pokemon.new(Data, "FIXMON_B", 11), Pokemon.new(Data, "FIXMON_B", 11),
Pokemon.new(Data, "FIXMON_C", 12), Pokemon.new(Data, "FIXMON_C", 12),
} }
return { data = Data, save = save, stack = { local stack = { states = {} }
push = function() end, pop = function() end, top = function() end, 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 end
local game = makeGame() local game = makeGame()
@@ -47,6 +53,18 @@ local menu = PartyMenu.new(game, { battle = battle })
T.check(menu.party == battle.playerParty, T.check(menu.party == battle.playerParty,
"battle party menus traverse only the local eligible view") "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 second.hp = 0
battle.player.mon.hp = 0 battle.player.mon.hp = 0
battle:playerMonFainted() battle:playerMonFainted()
@@ -94,6 +112,12 @@ local duplicate = BattleState.newTrainer(duplicateGame,
T.eq(duplicate.playerParty, nil, T.eq(duplicate.playerParty, nil,
"duplicate members make the entire scope fall back") "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 linkGame = makeGame()
local linkBattle = BattleState.newTrainer(linkGame, local linkBattle = BattleState.newTrainer(linkGame,
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 3 } }) "OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 3 } })
@@ -13,6 +13,8 @@ package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit") local T = require("tests.modkit")
local OW = require("src.world.OverworldController") 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 function setUpvalue(fn, name, val)
local i = 1 local i = 1
@@ -96,6 +98,89 @@ T.eq(rivalCount, 0, "rival classes play no encounter sting here")
local _, seenCount = stingFor("OPP_LASS", true) local _, seenCount = stingFor("OPP_LASS", true)
T.eq(seenCount, 0, "self.engaging suppresses a second sting") 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 if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic
else package.loaded["src.core.Music"] = nil end else package.loaded["src.core.Music"] = nil end
if realBattle ~= nil then package.loaded["src.battle.BattleState"] = realBattle if realBattle ~= nil then package.loaded["src.battle.BattleState"] = realBattle
@@ -68,4 +68,25 @@ T.eq(out.continue({ playerPartyIndices = { 1 } }), false,
T.eq(calls, 1, "a duplicate resume cannot start a second battle") T.eq(calls, 1, "a duplicate resume cannot start a second battle")
run.release() 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") T.finish("trainer_before_battle")