Merge pull request #1077 from MaxTomahawk/feat/battle-menu-auxiliary

feat(mods): add battle menu auxiliary action
This commit is contained in:
bryanthaboi
2026-08-12 16:56:04 -04:00
committed by GitHub
7 changed files with 304 additions and 68 deletions
+12
View File
@@ -340,6 +340,18 @@ failure as a failed first checkpoint rather than claiming restart safety.
See RFC 0003, RFC 0004, RFC 0005, and RFC 0006 for exact contracts and error See RFC 0003, RFC 0004, RFC 0005, and RFC 0006 for exact contracts and error
codes. codes.
At that same settled supported wild/trainer decision boundary, a tool may claim
START through `battle.menu_auxiliary`. It receives `(next, game, context)`, where
`context` is the data-only `{ kind = "wild" }` or `{ kind = "trainer" }`; it
never receives the live battle controller. Return `true` to consume START after
opening source-owned UI, or call `next(game, context)` to allow lower-priority
handlers. With no handler, START remains inert. Ordinary encounters and the
validated built-in scripted battle origins described by RFC 0005 are eligible;
opaque scripts, link/Safari/ghost/demo battles, action queues,
animation/messages, forced choices, and every phase that cannot safely be
checkpointed remain excluded. Exceptions are contained by normal hook isolation
and fall through without advancing a turn.
## Developer console ## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload Boot with developer mode on to unlock the in-game console and hot-reload
@@ -0,0 +1,46 @@
# RFC 0007: Battle menu auxiliary actions
## Status
Proposed.
## Problem
Tool mods can inspect/capture a persistent checkpoint only at a settled
ordinary wild/trainer player-decision boundary. Before this proposal, that
boundary had no public semantic input/action seam: `BattleState` consumed the
command loop directly. A mod could reach it only through private battle/input
internals, which would be unsafe and incompatible with controller/touch input.
## Contract
`mod.hooks:wrap("battle.menu_auxiliary", callback)` is called only when START
is pressed at the existing checkpoint-safe player-decision boundary. The
callback signature is:
```lua
function callback(next, game, context)
-- context is { kind = "wild" } or { kind = "trainer" }
-- return true after claiming START, otherwise return next(game, context)
end
```
The context is data-only. No live battle controller, input object, serializer,
or restoration primitive is exposed. A `true` result consumes START for that
fixed step without selecting a battle command. With no installed handler,
START is inert exactly as before. Hook priorities and error isolation are the
existing generic wrapper semantics: a throwing handler is skipped and cannot
advance battle state.
The engine reuses the same internal safety predicate as battle checkpoint
capture. Link, Safari, ghost/demo, unsupported origins, scripts, queues,
animations, messages, forced replacement/locked actions, and unsettled HP or
status presentation never invoke the hook.
## Compatibility and verification
The call is additive and no-op with no handler. ROM-free engine tests prove
wild/trainer delivery, cursor/turn preservation, and unsafe-phase refusal;
the mod-SDK fixture proves a loaded mod can consume the semantic action using
only its public hook facade. `gate_hooks` automatically includes the new call
site in no-mod parity coverage.
+98
View File
@@ -0,0 +1,98 @@
-- Shared settled supported player-decision predicate. Checkpoint capture and
-- the public auxiliary action deliberately use this one engine-owned rule so
-- a tool cannot open at a phase that it could not subsequently checkpoint.
-- It exposes no controller; callers receive only the result/reason.
local BattleSafety = {}
local BATTLE_BUSY_FIELDS = {
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
}
local function nonempty(value)
return type(value) == "table" and next(value) ~= nil
end
local function running(runner)
return runner and runner.isRunning and runner:isRunning()
end
local function scriptsBusy(overworld)
return running(overworld and overworld.runner)
or nonempty(overworld and overworld.parallelRunners)
or nonempty(overworld and overworld.pendingScripts)
or nonempty(overworld and overworld.parallelQueue)
or nonempty(overworld and overworld.scriptMoves)
end
function BattleSafety.inspect(game, battle)
if type(game) ~= "table" or type(game.save) ~= "table"
or type(game.save.version) ~= "string" then
return nil, "not_in_playthrough", "A checkpoint requires an identified active playthrough."
end
if type(battle) ~= "table" then
return nil, "not_battle", "No battle is active."
end
if battle.kind == "link" then
return nil, "link_battle_unsupported", "Network battles cannot be checkpointed."
end
if battle.safari or battle.ghost or battle.scopeReveal or battle.demo or battle.noCatch then
return nil, "battle_variant_unsupported",
"This battle variant does not have a checkpoint contract."
end
if battle.kind ~= "wild" and battle.kind ~= "trainer" then
return nil, "battle_variant_unsupported",
"This battle kind does not have a checkpoint contract."
end
local origin = battle.checkpointOrigin
local ordinaryOrigin = battle.kind == "wild" and "wild_encounter"
or "trainer_encounter"
local scriptedOrigin = type(origin) == "table"
and origin.kind == "script_battle"
if type(origin) ~= "table"
or (origin.kind ~= ordinaryOrigin and not scriptedOrigin) then
return nil, "battle_origin_unsupported",
"The battle completion path cannot be reconstructed safely."
end
local overworld = game.overworld or {}
local scriptedRunner = scriptedOrigin and (battle.checkpointScriptContinuation
or (overworld.runner
and overworld.runner.isCheckpointBattle
and overworld.runner:isCheckpointBattle(battle)))
local otherScriptWork = nonempty(overworld.parallelRunners)
or nonempty(overworld.pendingScripts) or nonempty(overworld.parallelQueue)
or nonempty(overworld.scriptMoves)
if (scriptedOrigin and (not scriptedRunner or otherScriptWork))
or (not scriptedOrigin and scriptsBusy(overworld)) then
return nil, "script_busy", "A suspended or queued script cannot be checkpointed."
end
if battle.phase ~= "menu" or nonempty(battle.queue) then
return nil, "battle_phase_busy",
"Wait for the player command menu before creating a checkpoint."
end
for _, field in ipairs(BATTLE_BUSY_FIELDS) do
if battle[field] ~= nil and battle[field] ~= false then
return nil, "battle_phase_busy", "Wait for the current battle action to finish."
end
end
if not battle.player or not battle.enemy or not battle.player.mon
or battle.player.mon.hp <= 0
or (battle.menuLockedAction and battle:menuLockedAction(battle.player)) then
return nil, "battle_phase_busy",
"Wait for a supported player decision before creating a checkpoint."
end
for _, battler in ipairs({ battle.player, battle.enemy }) do
if not battler.mon or battler.shownHP ~= battler.mon.hp
or battler.shownStatus ~= battler.mon.status
or battler.drainFloor ~= nil or battler.drainHold ~= nil
or battler.faintQueued then
return nil, "battle_phase_busy",
"Wait for battle status and HP presentation to settle."
end
end
return true
end
return BattleSafety
+12
View File
@@ -21,6 +21,7 @@ local MoveEffects = require("src.battle.MoveEffects")
local Party = require("src.pokemon.Party") local Party = require("src.pokemon.Party")
local Pokemon = require("src.pokemon.Pokemon") local Pokemon = require("src.pokemon.Pokemon")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local BattleSafety = require("src.battle.BattleSafety")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
local Status = require("src.battle.Status") local Status = require("src.battle.Status")
local Timing = require("src.core.Timing") local Timing = require("src.core.Timing")
@@ -1962,6 +1963,17 @@ function BattleState:update(dt)
self:resolveTurn(locked) self:resolveTurn(locked)
return return
end end
-- START has no vanilla action at a settled supported player-decision
-- boundary. A tool mod may claim this semantic auxiliary action through
-- the public hook, receiving only game plus a data-only kind. The shared
-- safety predicate keeps every unsupported/forced/animated phase inert.
if input:wasPressed("start") and Runtime.wantsHook("battle.menu_auxiliary") then
local safe = BattleSafety.inspect(self.game, self)
if safe and Runtime.call("battle.menu_auxiliary", function() return false end,
self.game, { kind = self.kind }) == true then
return
end
end
local col = (self.menuIndex - 1) % 2 local col = (self.menuIndex - 1) % 2
local row = math.floor((self.menuIndex - 1) / 2) local row = math.floor((self.menuIndex - 1) / 2)
if input:wasPressed("left") then if input:wasPressed("left") then
+5 -67
View File
@@ -7,6 +7,7 @@ local Version = require("src.core.Version")
local BattleState = require("src.battle.BattleState") local BattleState = require("src.battle.BattleState")
local BattleCheckpoint = require("src.core.BattleCheckpoint") local BattleCheckpoint = require("src.core.BattleCheckpoint")
local ModRuntime = require("src.mods.Runtime") local ModRuntime = require("src.mods.Runtime")
local BattleSafety = require("src.battle.BattleSafety")
local Checkpoint = {} local Checkpoint = {}
@@ -36,72 +37,9 @@ local function scriptsBusy(ow)
or nonempty(ow.scriptMoves) or nonempty(ow.scriptMoves)
end end
local BATTLE_BUSY_FIELDS = { local function inspectBattle(game, battle)
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI", local allowed, reason, message = BattleSafety.inspect(game, battle)
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn", if not allowed then return refusal("battle", reason, message) end
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
}
local function inspectBattle(ow, battle)
if battle.kind == "link" then
return refusal("battle", "link_battle_unsupported",
"Network battles cannot be checkpointed.")
end
if battle.safari or battle.ghost or battle.scopeReveal or battle.demo
or battle.noCatch then
return refusal("battle", "battle_variant_unsupported",
"This battle variant does not have a checkpoint contract.")
end
if battle.kind ~= "wild" and battle.kind ~= "trainer" then
return refusal("battle", "battle_variant_unsupported",
"This battle kind does not have a checkpoint contract.")
end
local origin = battle.checkpointOrigin
local ordinaryOrigin = battle.kind == "wild" and "wild_encounter"
or "trainer_encounter"
local scriptedOrigin = type(origin) == "table"
and origin.kind == "script_battle"
if type(origin) ~= "table"
or (origin.kind ~= ordinaryOrigin and not scriptedOrigin) then
return refusal("battle", "battle_origin_unsupported",
"The battle completion path cannot be reconstructed safely.")
end
local scriptedRunner = scriptedOrigin and (battle.checkpointScriptContinuation
or (ow.runner
and ow.runner.isCheckpointBattle
and ow.runner:isCheckpointBattle(battle)))
local otherScriptWork = nonempty(ow.parallelRunners)
or nonempty(ow.pendingScripts) or nonempty(ow.parallelQueue)
or nonempty(ow.scriptMoves)
if (scriptedOrigin and (not scriptedRunner or otherScriptWork))
or (not scriptedOrigin and scriptsBusy(ow)) then
return refusal("battle", "script_busy",
"A suspended or queued script cannot be checkpointed.")
end
if battle.phase ~= "menu" or nonempty(battle.queue) then
return refusal("battle", "battle_phase_busy",
"Wait for the player command menu before creating a checkpoint.")
end
for _, field in ipairs(BATTLE_BUSY_FIELDS) do
if battle[field] ~= nil and battle[field] ~= false then
return refusal("battle", "battle_phase_busy",
"Wait for the current battle action to finish.")
end
end
if not battle.player or not battle.enemy or battle.player.mon.hp <= 0
or (battle.menuLockedAction and battle:menuLockedAction(battle.player)) then
return refusal("battle", "battle_phase_busy",
"Wait for an ordinary player decision before creating a checkpoint.")
end
for _, battler in ipairs({ battle.player, battle.enemy }) do
if battler.shownHP ~= battler.mon.hp
or battler.shownStatus ~= battler.mon.status
or battler.drainFloor ~= nil or battler.drainHold ~= nil
or battler.faintQueued then
return refusal("battle", "battle_phase_busy",
"Wait for battle status and HP presentation to settle.")
end
end
return { canCapture = true, canRestore = true, kind = "battle" } return { canCapture = true, canRestore = true, kind = "battle" }
end end
@@ -120,7 +58,7 @@ function Checkpoint.inspect(game)
end end
local top = game.stack and game.stack.top and game.stack:top() local top = game.stack and game.stack.top and game.stack:top()
if getmetatable(top) == BattleState then if getmetatable(top) == BattleState then
return inspectBattle(ow, top) return inspectBattle(game, top)
end end
if top ~= ow then if top ~= ow then
return refusal("overworld", "screen_busy", return refusal("overworld", "screen_busy",
+108
View File
@@ -0,0 +1,108 @@
-- Public battle auxiliary actions are a narrow semantic entry point for tool
-- mods. They run only at the same settled ordinary decision boundary as a
-- battle checkpoint, consume no FIGHT/PKMN/ITEM/RUN action, and receive no
-- live BattleState object.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness").suite("battle menu auxiliary action")
local Fixtures = require("tests.modkit").fixtures
local BattleState = require("src.battle.BattleState")
local Hooks = require("src.mods.Hooks")
local Runtime = require("src.mods.Runtime")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local Data = Fixtures.fresh()
local function makeGame(kind)
local save = SaveData.newGame()
save.meta.playthroughId = "battle-menu-playthrough"
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
local stack = setmetatable({ states = {} }, { __index = StateStack })
local overworld = {
map = { id = save.player.map },
player = { cellX = save.player.x, cellY = save.player.y, facing = save.player.facing },
runner = { isRunning = function() return false end },
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
}
local game = { data = Data, save = save, stack = stack }
game.input = { wasPressed = function(_, button) return button == "start" end }
game.overworld = overworld
stack.states[1] = overworld
local battle = kind == "trainer"
and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
or BattleState.newWild(game, "FIXMON_B", 12)
battle.phase, battle.queue = "menu", {}
battle.checkpointOrigin = kind == "trainer"
and { kind = "trainer_encounter", map = save.player.map, npcId = "TRAINER_1",
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1, event = "EVENT_BEAT_TRAINER_1" }
or { kind = "wild_encounter", map = save.player.map }
battle.onFinish = function() end
stack.states[2] = battle
return game, battle
end
local oldHooks = Runtime.hooks
local hooks = Hooks.new()
Runtime.hooks = hooks
local game, battle = makeGame("wild")
local calls = 0
hooks:wrap("battle.menu_auxiliary", function(nextFn, liveGame, context)
calls = calls + 1
T.check(liveGame == game, "auxiliary action receives the live game")
T.same(context, { kind = "wild" }, "auxiliary action receives only data-only battle context")
return true
end, 0, "tool_fixture")
local originalIndex = battle.menuIndex
battle:update(1 / 60)
T.eq(calls, 1, "START reaches the public auxiliary action at a wild decision")
T.eq(battle.phase, "menu", "handled auxiliary action does not advance the battle")
T.eq(battle.menuIndex, originalIndex, "handled auxiliary action preserves cursor")
T.eq(#battle.queue, 0, "handled auxiliary action does not enqueue a turn")
hooks:removeOwner("tool_fixture")
local trainerGame, trainer = makeGame("trainer")
local trainerCalls = 0
hooks:wrap("battle.menu_auxiliary", function(_, liveGame, context)
trainerCalls = trainerCalls + 1
T.check(liveGame == trainerGame, "trainer action receives its live game")
T.same(context, { kind = "trainer" }, "trainer context remains data-only")
return true
end, 0, "trainer_fixture")
trainer:update(1 / 60)
T.eq(trainerCalls, 1, "START reaches the public auxiliary action at a trainer decision")
hooks:removeOwner("trainer_fixture")
local scriptedGame, scripted = makeGame("trainer")
scripted.checkpointOrigin = { kind = "script_battle", scriptId = "STORY_TEST", pc = 4 }
scripted.checkpointScriptContinuation = { kind = "script_battle" }
local scriptedCalls = 0
hooks:wrap("battle.menu_auxiliary", function(_, liveGame, context)
scriptedCalls = scriptedCalls + 1
T.check(liveGame == scriptedGame,
"scripted action receives the live game without its runner")
T.same(context, { kind = "trainer" },
"supported scripted trainer context remains data-only")
return true
end, 0, "scripted_fixture")
scripted:update(1 / 60)
T.eq(scriptedCalls, 1,
"START reaches the public auxiliary action at a supported scripted decision")
hooks:removeOwner("scripted_fixture")
local unsafeGame, unsafe = makeGame("wild")
unsafe.phase = "messages"
local unsafeCalls = 0
hooks:wrap("battle.menu_auxiliary", function() unsafeCalls = unsafeCalls + 1 return true end,
0, "unsafe_fixture")
unsafe:update(1 / 60)
T.eq(unsafeCalls, 0, "messages never expose the auxiliary action")
hooks:removeOwner("unsafe_fixture")
Runtime.hooks = oldHooks
T.finish()
+23 -1
View File
@@ -140,7 +140,10 @@ local files = {
'{"id":"probe","name":"probe","version":"1.0.0",' '{"id":"probe","name":"probe","version":"1.0.0",'
.. '"entry":"main.lua","api":2,"profile":"content"}', .. '"entry":"main.lua","api":2,"profile":"content"}',
["mods/probe/main.lua"] = [[ ["mods/probe/main.lua"] = [[
return function(mod) _G.MOD_CHECKPOINTS = mod.checkpoints end return function(mod)
_G.MOD_CHECKPOINTS = mod.checkpoints
_G.MOD_HOOKS = mod.hooks
end
]], ]],
} }
local game, ow = makeGame() local game, ow = makeGame()
@@ -433,9 +436,28 @@ if battleSnapshot then
"public battle capture/restore/capture is a normalized differential roundtrip") "public battle capture/restore/capture is a normalized differential roundtrip")
end end
-- The mod receives the normal public hook facade, never BattleState. START
-- at the restored safe decision reaches its semantic auxiliary action without
-- selecting a native command.
local auxiliaryCalls = 0
_G.MOD_HOOKS:wrap("battle.menu_auxiliary", function(nextFn, liveGame, context)
auxiliaryCalls = auxiliaryCalls + 1
T.check(liveGame == battleGame, "public battle auxiliary action receives the game")
T.same(context, { kind = "wild" }, "public auxiliary context is data-only")
return true
end)
battleGame.input = { wasPressed = function(_, button) return button == "start" end }
local boundary = battleGame.stack:top()
local originalMenuIndex = boundary.menuIndex
boundary:update(1 / 60)
T.eq(auxiliaryCalls, 1, "public mod hook receives START at the checkpoint boundary")
T.eq(boundary.phase, "menu", "public auxiliary hook does not advance the turn")
T.eq(boundary.menuIndex, originalMenuIndex, "public auxiliary hook preserves cursor")
Runtime.events, Runtime.hooks = savedEvents, savedHooks Runtime.events, Runtime.hooks = savedEvents, savedHooks
Runtime.currentMod = nil Runtime.currentMod = nil
_G.MOD_CHECKPOINTS = nil _G.MOD_CHECKPOINTS = nil
_G.MOD_HOOKS = nil
love.math.getRandomState = oldGetRandomState love.math.getRandomState = oldGetRandomState
love.math.setRandomState = oldSetRandomState love.math.setRandomState = oldSetRandomState