mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 07:41:21 +02:00
feat(mods): add battle menu auxiliary action
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+5
-56
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user