Files
gen1recomp/tests/engine/scripted_battle_checkpoint_test.lua
2026-08-11 08:58:33 +02:00

179 lines
7.6 KiB
Lua

-- Scripted story battles checkpoint a semantic row-list continuation. The
-- suspended Lua coroutine is deliberately never serialized.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local oldGetRandomState = love.math.getRandomState
local oldSetRandomState = love.math.setRandomState
local checkpointRng = "scripted-battle-rng"
love.math.getRandomState = function() return checkpointRng end
love.math.setRandomState = function(state) checkpointRng = state end
local T = require("tests.harness").suite("scripted battle checkpoints")
local BattleState = require("src.battle.BattleState")
local Checkpoint = require("src.core.Checkpoint")
local Fixtures = require("tests.modkit").fixtures
local GameMethods = require("src.core.Game")
local OverworldState = require("src.world.OverworldController")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local ScriptRunner = require("src.script.ScriptRunner")
local StateStack = require("src.core.StateStack")
local Data = Fixtures.fresh()
local function makeGame()
local save = SaveData.newGame()
save.meta.playthroughId = "script-battle-playthrough"
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
SaveData.validate(save, Data)
save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3
save.player.facing, save.player.surfing = "left", false
local stack = setmetatable({ states = {} }, { __index = StateStack })
local game
local ow = setmetatable({
map = { id = "FIX_TOWN" },
player = { cellX = 2, cellY = 3, facing = "left", surfing = false },
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
}, { __index = OverworldState })
function ow:captureSave(target)
target.player.map = self.map.id
target.player.x, target.player.y = self.player.cellX, self.player.cellY
target.player.facing, target.player.surfing = self.player.facing, false
end
function ow:pushBattle(battle) game.stack:push(battle) end
function ow:afterBattle(result, battle)
self.after = { result = result, battle = battle }
end
game = setmetatable({ data = Data, save = save, stack = stack, overworld = ow },
{ __index = GameMethods })
function game:restoreCheckpointSave(loaded)
self.save = loaded
ow.map = { id = loaded.player.map }
ow.player = {
cellX = loaded.player.x, cellY = loaded.player.y,
facing = loaded.player.facing, surfing = loaded.player.surfing and true or false,
}
ow.parallelRunners, ow.pendingScripts, ow.parallelQueue, ow.scriptMoves = {}, {}, {}, {}
ow.runner = ScriptRunner.new(self, ow)
self.stack.states = { ow }
end
stack.states[1] = ow
ow.runner = ScriptRunner.new(game, ow)
return game, ow
end
local rows = {
{ "start_battle", "trainer", "OPP_FIX_YOUNGSTER", 1 },
{ "set_flag", "EVENT_STORY_CONTINUED" },
}
local game, ow = makeGame()
ow.runner:run(rows)
local battle = game.stack:top()
T.check(getmetatable(battle) == BattleState,
"script command starts the fixture trainer battle")
T.check(type(battle.checkpointOrigin) == "table"
and battle.checkpointOrigin.kind == "script_battle",
"script battle owns a semantic checkpoint continuation")
battle.phase, battle.queue = "menu", {}
battle.afterQueue, battle.introSlide = nil, nil
battle.player.shownHP, battle.player.shownStatus =
battle.player.mon.hp, battle.player.mon.status
battle.enemy.shownHP, battle.enemy.shownStatus =
battle.enemy.mon.hp, battle.enemy.mon.status
local scriptedCapability = Checkpoint.inspect(game)
T.same(scriptedCapability, {
canCapture = true, canRestore = true, kind = "battle",
}, "settled scripted trainer decision is checkpoint-safe: "
.. tostring(scriptedCapability.reason))
local origin = battle.checkpointOrigin
T.check(type(origin.script) == "table" and origin.pc == 1,
"continuation records detached rows and the command program counter")
T.eq(origin.resumeCoroutine, nil,
"continuation never exposes a coroutine or Lua execution stack")
local snapshot, captureCode, captureMessage = Checkpoint.capture(game)
T.check(snapshot and snapshot.kind == "battle",
"scripted battle captures through the generic checkpoint API: "
.. tostring(captureCode or captureMessage))
if snapshot then
game.save.money = 1
local restored, restoreCode, restoreMessage = Checkpoint.restore(game, snapshot)
T.check(restored == true,
"scripted battle reconstructs through the generic checkpoint API: "
.. tostring(restoreCode or restoreMessage))
local rebuilt = game.stack:top()
T.check(rebuilt ~= battle and getmetatable(rebuilt) == BattleState,
"scripted restore creates a fresh battle controller")
T.same(Checkpoint.capture(game), snapshot,
"scripted battle capture/restore/capture is a differential roundtrip")
end
-- Rebind the continuation on a freshly reconstructed overworld. Completing
-- the battle must replay the current command as an already-completed battle,
-- then execute the remaining story rows exactly once.
local restoredGame, restoredOw = makeGame()
local restoredBattle = BattleState.newTrainer(restoredGame,
"OPP_FIX_YOUNGSTER", 1)
restoredBattle.checkpointOrigin = origin
T.check(restoredOw:restoreBattleContinuation(restoredBattle, origin) == true,
"script continuation reconstructs without the old runner")
restoredBattle.onFinish("win")
T.check(restoredGame.save.flags.EVENT_STORY_CONTINUED == true,
"restored battle resumes subsequent story progress")
T.same(restoredOw.after, { result = "win", battle = restoredBattle },
"restored script battle uses the canonical afterBattle path")
T.check(not restoredOw.runner:isRunning(),
"reconstructed continuation completes without a suspended runner")
-- Unsafe rows and opaque completion callbacks must stay fail-closed.
local unsafeGame, unsafeOw = makeGame()
local unsafeRows = {
{ "start_battle", "trainer", "OPP_FIX_YOUNGSTER", 1 },
}
unsafeRows.opaque = function() end
unsafeOw.runner:run(unsafeRows)
local unsafeBattle = unsafeGame.stack:top()
unsafeBattle.phase, unsafeBattle.queue = "menu", {}
T.eq(unsafeBattle.checkpointOrigin, nil,
"non-data-only script arguments do not create a continuation")
T.eq(Checkpoint.inspect(unsafeGame).reason, "battle_origin_unsupported",
"non-data-only scripted battle remains unavailable")
local callbackGame, callbackOw = makeGame()
callbackOw.runner:run(rows, { onDone = function() end })
local callbackBattle = callbackGame.stack:top()
callbackBattle.phase, callbackBattle.queue = "menu", {}
T.eq(callbackBattle.checkpointOrigin, nil,
"opaque script completion callbacks are not guessed")
T.eq(Checkpoint.inspect(callbackGame).reason, "battle_origin_unsupported",
"opaque scripted completion remains fail-closed")
local rivalGame, rivalOw = makeGame()
local rivalRows = {
{ "rival_battle", "OPP_FIX_YOUNGSTER", 1 },
{ "jump_if_false", "end" },
{ "set_flag", "EVENT_RIVAL_STORY_CONTINUED" },
}
rivalOw.runner:run(rivalRows)
local rivalBattle = rivalGame.stack:top()
local rivalOrigin = rivalBattle.checkpointOrigin
T.check(rivalOrigin and rivalOrigin.command == "rival_battle" and rivalOrigin.pc == 1,
"wrapper battle records the wrapper command rather than skipping its tail")
local rivalResumeGame, rivalResumeOw = makeGame()
local rivalRestored = BattleState.newTrainer(rivalResumeGame,
"OPP_FIX_YOUNGSTER", 1)
T.check(rivalResumeOw:restoreBattleContinuation(rivalRestored, rivalOrigin) == true,
"rival wrapper continuation reconstructs")
rivalRestored.onFinish("win")
T.check(rivalResumeGame.save.flags.EVENT_RIVAL_STORY_CONTINUED == true,
"rival wrapper and following branch execute exactly once after restore")
love.math.getRandomState = oldGetRandomState
love.math.setRandomState = oldSetRandomState
T.finish()