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 })
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.
@@ -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.
+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).
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
+1
View File
@@ -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.
+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
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
+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 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 } })
@@ -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
@@ -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")