feat(mods): add validated battle menu intents

This commit is contained in:
AverageConsumer
2026-08-16 01:43:27 +02:00
parent 3588a5f3fe
commit c22888a7fd
6 changed files with 374 additions and 95 deletions
+57
View File
@@ -197,4 +197,61 @@ function BattleAPI:snapshot()
mimicMoves = mimicCopies(game, battle), mimicIndex = battle.mimicIndex }
end
local MENU_CHOICES = { fight = true, party = true, item = true, run = true }
local function validSlot(slot)
return type(slot) == "number" and slot % 1 == 0 and slot >= 1
end
function BattleAPI:submit(intent)
if type(intent) ~= "table" then return nil, "intent must be a table" end
if type(intent.id) ~= "number" or intent.id % 1 ~= 0 or intent.id < 1 then
return nil, "intent id must be a positive integer"
end
if self.lastIntentId and intent.id <= self.lastIntentId then
return nil, "replayed intent"
end
local battle, top = activeBattle(self.game)
if not battle then return nil, "no battle" end
if intent.revision ~= self:_revision(battle, top) then
return nil, "stale battle context"
end
local kind = battle:battleKind()
if kind == "oldman" or kind == "link" or kind == "safari" then
return nil, "battle kind is not controllable"
end
if top ~= battle then return nil, "battle menu is covered" end
local ok, err
if intent.kind == "menu" then
if battle.phase ~= "menu" then return nil, "battle menu is not active" end
if not MENU_CHOICES[intent.choice] then
return nil, "unknown battle menu choice"
end
ok, err = battle:chooseMenu(intent.choice)
elseif intent.kind == "move" then
if battle.phase ~= "moveSelect" then
return nil, "move menu is not active"
end
if battle.moveSwapIndex then return nil, "move reorder is active" end
local move = validSlot(intent.slot) and battle.player
and battle.player.curMoves[intent.slot]
if not move then return nil, "invalid move slot" end
if (move.pp or 0) <= 0 then return nil, "move has no PP" end
if battle.player.disabledSlot == intent.slot then
return nil, "move is disabled"
end
ok, err = battle:chooseMove(intent.slot)
elseif intent.kind == "back" then
ok, err = battle:cancelMove()
else
return nil, "unknown battle intent"
end
if not ok then return nil, err end
self.lastIntentId = intent.id
self.signature = nil
return true
end
return BattleAPI
+74 -51
View File
@@ -1951,6 +1951,77 @@ function BattleState:playerHasPP()
return false
end
-- One semantic path for the native command menu and mod.battle intents.
function BattleState:chooseMenu(choice)
if self.phase ~= "menu" then return nil, "battle menu is not active" end
if not self.player or not self.player.mon or self.player.mon.hp <= 0
or self:menuLockedAction(self.player) then
return nil, "battle menu is not ready"
end
self:clearTurnFlinches()
if choice == "fight" and self.ghost then
self:say(Strings("%s is too\nscared to move!", self.player.name))
self.phase = "messages"
self.afterQueue = "menu"
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
end)
-- A scared turn still ticks the player's residual effects.
self:queueResidual(self.player, self.enemy)
self:act(function() self:endOfTurn() end)
elseif choice == "fight" then
-- Trapping, Bide, and similar locks skip the move list.
local fightLock = self:fightLockedAction(self.player)
if fightLock then
self:resolveTurn(fightLock)
elseif not self:playerHasPP() then
-- No usable PP goes straight to Struggle.
self:say(Strings("%s has no\nmoves left!", self.player.name))
self:resolveTurn({ id = "STRUGGLE", pp = 1, struggle = true })
else
self.phase = "moveSelect"
self.moveIndex = math.min(self.moveIndex, #self.player.curMoves)
self.moveSwapIndex = nil
end
elseif choice == "run" then
self:tryRun()
elseif choice == "item" then
self:openItems()
elseif choice == "party" then
self:openParty()
else
return nil, "unknown battle menu choice"
end
return true
end
function BattleState:chooseMove(index)
if self.phase ~= "moveSelect" then return nil, "move menu is not active" end
local move = self.player.curMoves[index]
if not move then return nil, "invalid move slot" end
self.moveIndex = index
if self.player.disabledSlot == index then
self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!"))
self.phase = "messages"
self.afterQueue = "menu"
elseif move.pp <= 0 then
self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!"))
self.phase = "messages"
self.afterQueue = "menu"
else
self.playerMoveListIndex = index
self:resolveTurn(move)
end
return true
end
function BattleState:cancelMove()
if self.phase ~= "moveSelect" then return nil, "move menu is not active" end
self.moveSwapIndex = nil
self.phase = "menu"
return true
end
function BattleState:swapMoves(i, j)
if i == j then return end
local moves = self.player.curMoves
@@ -2120,42 +2191,7 @@ function BattleState:update(dt)
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex]
if choice == "fight" and self.ghost then
self:say(Strings("%s is too\nscared to move!", self.player.name))
self.phase = "messages"
self.afterQueue = "menu"
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
end)
-- the scared turn still ticks the player's residual (PrintGhostText
-- -> ExecutePlayerMoveDone, core.asm:3056, 3275-3279)
self:queueResidual(self.player, self.enemy)
self:act(function() self:endOfTurn() end)
elseif choice == "fight" then
-- After the menu: own trapping/Bide or foe Wrap skips the move
-- list and forces the locked action (core.asm:320-329)
local fightLock = self:fightLockedAction(self.player)
if fightLock then
self:resolveTurn(fightLock)
return
end
if not self:playerHasPP() then
-- _NoMovesLeftText, then Struggle engages
self:say(Strings("%s has no\nmoves left!", self.player.name))
self:resolveTurn({ id = "STRUGGLE", pp = 1, struggle = true })
return
end
self.phase = "moveSelect"
self.moveIndex = math.min(self.moveIndex, #self.player.curMoves)
self.moveSwapIndex = nil
elseif choice == "run" then
self:tryRun()
elseif choice == "item" then
self:openItems()
else
self:openParty()
end
self:chooseMenu(({ "fight", "party", "item", "run" })[self.menuIndex])
end
return
end
@@ -2184,8 +2220,7 @@ function BattleState:update(dt)
end
elseif input:wasPressed("b") then
require("src.core.Sound").play(self.data, "Press_AB")
self.moveSwapIndex = nil
self.phase = "menu"
self:cancelMove()
elseif input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
if self.moveSwapIndex then
@@ -2193,19 +2228,7 @@ function BattleState:update(dt)
self.moveSwapIndex = nil
return
end
local mv = moves[self.moveIndex]
if self.player.disabledSlot == self.moveIndex then
self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!"))
self.phase = "messages"
self.afterQueue = "menu"
elseif mv.pp <= 0 then
self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!"))
self.phase = "messages"
self.afterQueue = "menu"
else
self.playerMoveListIndex = self.moveIndex
self:resolveTurn(mv)
end
self:chooseMove(self.moveIndex)
end
return
end
+53
View File
@@ -116,4 +116,57 @@ function BattleAPI:snapshot()
items = {} }
end
local MENU_CHOICES = { fight = true, party = true, item = true, run = true }
local function validSlot(slot)
return type(slot) == "number" and slot % 1 == 0 and slot >= 1
end
function BattleAPI:submit(intent)
if type(intent) ~= "table" then return nil, "intent must be a table" end
if type(intent.id) ~= "number" or intent.id % 1 ~= 0 or intent.id < 1 then
return nil, "intent id must be a positive integer"
end
if self.lastIntentId and intent.id <= self.lastIntentId then
return nil, "replayed intent"
end
local screen, top = activeBattle(self.game)
if not screen or not screen.battle then return nil, "no battle" end
if intent.revision ~= self:_revision(screen, top) then
return nil, "stale battle context"
end
if screen.tutorial then return nil, "battle kind is not controllable" end
if top ~= screen then return nil, "battle menu is covered" end
local battle = screen.battle
local ok, err
if intent.kind == "menu" then
if screen.phase ~= "menu" then return nil, "battle menu is not active" end
if not MENU_CHOICES[intent.choice] then
return nil, "unknown battle menu choice"
end
ok, err = screen:chooseMenu(intent.choice)
elseif intent.kind == "move" then
if screen.phase ~= "moves" then return nil, "move menu is not active" end
if screen.moveSwapIndex then return nil, "move reorder is active" end
local move = validSlot(intent.slot) and battle.player
and battle.player.moves and battle.player.moves[intent.slot]
if not move then return nil, "invalid move slot" end
if (move.pp or 0) <= 0 then return nil, "move has no PP" end
if battle:moveDisabled(battle.player, move.id) then
return nil, "move is disabled"
end
ok, err = screen:chooseMove(intent.slot)
elseif intent.kind == "back" then
ok, err = screen:cancelMove()
else
return nil, "unknown battle intent"
end
if not ok then return nil, err end
self.lastIntentId = intent.id
self.signature = nil
return true
end
return BattleAPI