This commit is contained in:
bryanthaboi
2026-08-13 05:30:35 -04:00
21 changed files with 1264 additions and 89 deletions
+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
+14 -3
View File
@@ -21,12 +21,14 @@ 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")
local TrainerAI = require("src.battle.TrainerAI")
local TurnOrder = require("src.battle.TurnOrder")
local TypeChart = require("src.battle.TypeChart")
local UIVisibility = require("src.battle.UIVisibility")
local RomText = require("src.core.RomText")
local Strings = require("src.core.Strings")
local WideBattle = require("src.battle.WideBattle")
@@ -134,9 +136,7 @@ function BattleState:sgbPalettes()
end
function BattleState:bottomUIVisible()
if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end
return Runtime.call("battle.bottom_ui_visible", function() return true end,
self) ~= false
return UIVisibility.bottomVisible(self, true)
end
function BattleState:statusHUDVisible()
@@ -1962,6 +1962,17 @@ function BattleState:update(dt)
self:resolveTurn(locked)
return
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 row = math.floor((self.menuIndex - 1) / 2)
if input:wasPressed("left") then
+39
View File
@@ -0,0 +1,39 @@
-- Shared visibility rules for battle-owned UI states. Text and choice
-- overlays live above BattleState on the state stack, but they are still part
-- of its bottom UI layer and must inherit that layer's visibility.
local Runtime = require("src.mods.Runtime")
local UIVisibility = {}
local function enclosingBattle(state)
local stack = state and state.game and state.game.stack
local states = stack and stack.states
local found = false
for i = #(states or {}), 1, -1 do
local candidate = states[i]
if candidate == state then found = true end
if found and candidate and candidate.isBattle then return candidate end
end
return nil
end
-- queryState keeps the existing TextBox contract: a mod may still decide
-- visibility for that individual box. ChoiceBox only inherits the enclosing
-- battle decision, so field YES/NO prompts never become battle-hook states.
function UIVisibility.bottomVisible(state, queryState)
if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end
local battle = enclosingBattle(state)
if battle and battle ~= state
and Runtime.call("battle.bottom_ui_visible",
function() return true end, battle) == false then
return false
end
if queryState or battle == state then
return Runtime.call("battle.bottom_ui_visible",
function() return true end, state) ~= false
end
return true
end
return UIVisibility