From 59725c0ead3c761b7e6c4fbf977e15531819f285 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Mon, 10 Aug 2026 19:01:31 +0200 Subject: [PATCH] feat(mods): add battle menu auxiliary action --- docs/modding.md | 10 ++ .../0007-battle-menu-auxiliary-actions.md | 46 ++++++++++ src/battle/BattleSafety.lua | 86 ++++++++++++++++++ src/battle/BattleState.lua | 12 +++ src/core/Checkpoint.lua | 61 +------------ tests/engine/battle_menu_auxiliary.lua | 91 +++++++++++++++++++ tests/modkit/cases/checkpoints.lua | 24 ++++- 7 files changed, 273 insertions(+), 57 deletions(-) create mode 100644 docs/rfcs/0007-battle-menu-auxiliary-actions.md create mode 100644 src/battle/BattleSafety.lua create mode 100644 tests/engine/battle_menu_auxiliary.lua diff --git a/docs/modding.md b/docs/modding.md index b13fb048..9d1149e5 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -272,6 +272,16 @@ data. See RFC 0003, RFC 0004, and RFC 0005 for exact contracts and error codes. +At that same settled ordinary 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. The hook is never reached for +link/Safari/ghost/demo/scripted battles, action queues, animation/messages, +forced choices, or any phase that cannot safely be checkpointed. Exceptions are +contained by normal hook isolation and fall through without advancing a turn. + ## Developer console Boot with developer mode on to unlock the in-game console and hot-reload diff --git a/docs/rfcs/0007-battle-menu-auxiliary-actions.md b/docs/rfcs/0007-battle-menu-auxiliary-actions.md new file mode 100644 index 00000000..422a7420 --- /dev/null +++ b/docs/rfcs/0007-battle-menu-auxiliary-actions.md @@ -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. diff --git a/src/battle/BattleSafety.lua b/src/battle/BattleSafety.lua new file mode 100644 index 00000000..305d9795 --- /dev/null +++ b/src/battle/BattleSafety.lua @@ -0,0 +1,86 @@ +-- Shared settled ordinary-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 expectedOrigin = battle.kind == "wild" and "wild_encounter" + or "trainer_encounter" + if type(battle.checkpointOrigin) ~= "table" + or battle.checkpointOrigin.kind ~= expectedOrigin then + return nil, "battle_origin_unsupported", + "The battle completion path cannot be reconstructed safely." + end + if scriptsBusy(game.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 an ordinary 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 diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 6cee785a..db7fcb7d 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -21,6 +21,7 @@ local MoveEffects = require("src.battle.MoveEffects") local Party = require("src.pokemon.Party") local Pokemon = require("src.pokemon.Pokemon") local Runtime = require("src.mods.Runtime") +local BattleSafety = require("src.battle.BattleSafety") local Screens = require("src.ui.Screens") local Status = require("src.battle.Status") local Timing = require("src.core.Timing") @@ -1942,6 +1943,17 @@ function BattleState:update(dt) self:resolveTurn(locked) return end + -- START has no vanilla action at the settled ordinary 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 row = math.floor((self.menuIndex - 1) / 2) if input:wasPressed("left") then diff --git a/src/core/Checkpoint.lua b/src/core/Checkpoint.lua index e6a3b036..5625a272 100644 --- a/src/core/Checkpoint.lua +++ b/src/core/Checkpoint.lua @@ -7,6 +7,7 @@ local Version = require("src.core.Version") local BattleState = require("src.battle.BattleState") local BattleCheckpoint = require("src.core.BattleCheckpoint") local ModRuntime = require("src.mods.Runtime") +local BattleSafety = require("src.battle.BattleSafety") local Checkpoint = {} @@ -36,61 +37,9 @@ local function scriptsBusy(ow) or nonempty(ow.scriptMoves) end -local BATTLE_BUSY_FIELDS = { - "current", "afterQueue", "nextInsert", "pendingHit", "waitingUI", - "waitingSound", "waitFrames", "draining", "animPlaying", "growIn", - "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 expectedOrigin = battle.kind == "wild" and "wild_encounter" - or "trainer_encounter" - if type(origin) ~= "table" or origin.kind ~= expectedOrigin then - return refusal("battle", "battle_origin_unsupported", - "The battle completion path cannot be reconstructed safely.") - end - if 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 +local function inspectBattle(game, battle) + local allowed, reason, message = BattleSafety.inspect(game, battle) + if not allowed then return refusal("battle", reason, message) end return { canCapture = true, canRestore = true, kind = "battle" } end @@ -109,7 +58,7 @@ function Checkpoint.inspect(game) end local top = game.stack and game.stack.top and game.stack:top() if getmetatable(top) == BattleState then - return inspectBattle(ow, top) + return inspectBattle(game, top) end if top ~= ow then return refusal("overworld", "screen_busy", diff --git a/tests/engine/battle_menu_auxiliary.lua b/tests/engine/battle_menu_auxiliary.lua new file mode 100644 index 00000000..d147bda7 --- /dev/null +++ b/tests/engine/battle_menu_auxiliary.lua @@ -0,0 +1,91 @@ +-- 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 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() diff --git a/tests/modkit/cases/checkpoints.lua b/tests/modkit/cases/checkpoints.lua index adea5f90..c5921158 100644 --- a/tests/modkit/cases/checkpoints.lua +++ b/tests/modkit/cases/checkpoints.lua @@ -140,7 +140,10 @@ local files = { '{"id":"probe","name":"probe","version":"1.0.0",' .. '"entry":"main.lua","api":2,"profile":"content"}', ["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() @@ -433,9 +436,28 @@ if battleSnapshot then "public battle capture/restore/capture is a normalized differential roundtrip") 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.currentMod = nil _G.MOD_CHECKPOINTS = nil +_G.MOD_HOOKS = nil love.math.getRandomState = oldGetRandomState love.math.setRandomState = oldSetRandomState