feat: expose settled battle checkpoint boundary

This commit is contained in:
MaxTomahawk
2026-08-07 16:20:12 +02:00
parent cab62ff7b3
commit 24d9f279ec
2 changed files with 143 additions and 3 deletions
+60 -3
View File
@@ -4,6 +4,7 @@
local SaveSerializer = require("src.core.SaveSerializer") local SaveSerializer = require("src.core.SaveSerializer")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local Version = require("src.core.Version") local Version = require("src.core.Version")
local BattleState = require("src.battle.BattleState")
local Checkpoint = {} local Checkpoint = {}
@@ -27,6 +28,61 @@ local function nonempty(value)
return type(value) == "table" and next(value) ~= nil return type(value) == "table" and next(value) ~= nil
end end
local function scriptsBusy(ow)
return running(ow.runner) or nonempty(ow.parallelRunners)
or nonempty(ow.pendingScripts) or nonempty(ow.parallelQueue)
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
return { canCapture = true, canRestore = true, kind = "battle" }
end
function Checkpoint.inspect(game) function Checkpoint.inspect(game)
local save = game and game.save local save = game and game.save
if type(save) ~= "table" or type(save.version) ~= "string" then if type(save) ~= "table" or type(save.version) ~= "string" then
@@ -41,6 +97,9 @@ function Checkpoint.inspect(game)
"Only a settled overworld can be checkpointed.") "Only a settled overworld can be checkpointed.")
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
return inspectBattle(ow, top)
end
if top ~= ow then if top ~= ow then
return refusal("overworld", "screen_busy", return refusal("overworld", "screen_busy",
"Close the active menu or screen before creating a checkpoint.") "Close the active menu or screen before creating a checkpoint.")
@@ -57,9 +116,7 @@ function Checkpoint.inspect(game)
return refusal("overworld", "transition_busy", return refusal("overworld", "transition_busy",
"Wait for the map transition to finish.") "Wait for the map transition to finish.")
end end
if running(ow.runner) or nonempty(ow.parallelRunners) if scriptsBusy(ow) then
or nonempty(ow.pendingScripts) or nonempty(ow.parallelQueue)
or nonempty(ow.scriptMoves) then
return refusal("overworld", "script_busy", return refusal("overworld", "script_busy",
"Wait for the active or queued script to finish.") "Wait for the active or queued script to finish.")
end end
@@ -0,0 +1,83 @@
-- Battle checkpoints are exposed only at a settled, reconstructable player
-- decision boundary. This suite is ROM-free and exercises the public engine
-- checkpoint capability against the fixture battle implementation.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness").suite("battle checkpoint boundary")
local Fixtures = require("tests.modkit").fixtures
local BattleState = require("src.battle.BattleState")
local Checkpoint = require("src.core.Checkpoint")
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()
local save = SaveData.newGame()
save.meta.playthroughId = "battle-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, surfing = false,
},
runner = { isRunning = function() return false end },
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
}
local game = { data = Data, save = save, stack = stack, overworld = overworld }
stack.states[1] = overworld
local battle = BattleState.newWild(game, "FIXMON_B", 12)
battle.phase = "menu"
battle.queue = {}
battle.checkpointOrigin = { kind = "wild_encounter" }
battle.onFinish = function() end
stack.states[2] = battle
return game, overworld, battle
end
local game, overworld, battle = makeGame()
T.same(Checkpoint.inspect(game), {
canCapture = true, canRestore = true, kind = "battle",
}, "settled standard wild battle is a checkpoint boundary")
local function refused(mutator, code, label)
local game2, ow2, battle2 = makeGame()
mutator(game2, ow2, battle2)
local capability = Checkpoint.inspect(game2)
T.check(capability.canCapture == false and capability.reason == code,
label .. ": " .. tostring(capability.reason))
end
refused(function(_, _, b) b.phase = "messages" end,
"battle_phase_busy", "message phase is rejected")
refused(function(_, _, b) b.queue = { { text = "busy" } } end,
"battle_phase_busy", "nonempty action queue is rejected")
refused(function(_, _, b) b.waitFrames = 1 end,
"battle_phase_busy", "partial wait is rejected")
refused(function(_, _, b) b.player.mustRecharge = true end,
"battle_phase_busy", "automatic locked action is rejected")
refused(function(_, ow) ow.runner = { isRunning = function() return true end } end,
"script_busy", "suspended script beneath battle is rejected")
refused(function(_, _, b) b.checkpointOrigin = nil end,
"battle_origin_unsupported", "unknown completion closure is rejected")
refused(function(_, _, b) b.safari = { balls = 30, steps = 10 } end,
"battle_variant_unsupported", "Safari battle is rejected")
refused(function(_, _, b) b.ghost = true end,
"battle_variant_unsupported", "ghost battle is rejected")
refused(function(_, _, b) b.demo = true end,
"battle_variant_unsupported", "old-man demo is rejected")
refused(function(_, _, b) b.kind = "link" end,
"link_battle_unsupported", "link battle is rejected")
-- Ordinary overworld behavior remains unchanged by the battle branch.
game.stack.states[2] = nil
T.same(Checkpoint.inspect(game), {
canCapture = true, canRestore = true, kind = "overworld",
}, "settled overworld remains supported")
T.finish()