Merge pull request #1078 from MaxTomahawk/feat/scripted-battle-checkpoints

feat(mods): checkpoint scripted battle decisions
This commit is contained in:
bryanthaboi
2026-08-11 21:22:40 -04:00
committed by GitHub
12 changed files with 371 additions and 30 deletions
+2 -1
View File
@@ -203,7 +203,8 @@ return {
rows[#rows + 1] = { "set_field", "pikachuInBall", true }
rows[#rows + 1] = { "set_flag", "EVENT_GOT_STARTER" }
rows[#rows + 1] = { "set_flag", "EVENT_CHOSE_PIKACHU" }
ow.runner:run(rows, { npc = npc, onDone = done })
ow.runner:run(rows, { npc = npc, onDone = done,
checkpointOnDone = "release_npc" })
end,
TEXT_OAKSLAB_RIVAL = {
+4 -2
View File
@@ -36,7 +36,8 @@ M.CERULEAN_MELANIES_HOUSE = {
rows[#rows + 1] = { "label", "declined" }
rows[#rows + 1] = { "show_text", "MelanieText5" }
end
ow.runner:run(rows, { npc = npc, onDone = done })
ow.runner:run(rows, { npc = npc, onDone = done,
checkpointOnDone = "release_npc" })
end,
-- pet flavor: the text with the species' cry over it
TEXT_CERULEANMELANIESHOUSE_BULBASAUR = {
@@ -105,7 +106,8 @@ M.VERMILION_CITY = {
rows[#rows + 1] = { "label", "declined" }
rows[#rows + 1] = { "show_text", "_OfficerJennyText4" }
end
ow.runner:run(rows, { npc = npc, onDone = done })
ow.runner:run(rows, { npc = npc, onDone = done,
checkpointOnDone = "release_npc" })
end,
},
}
+2 -1
View File
@@ -100,7 +100,8 @@ local function oldMan2Talk(game, ow, npc, done)
game.stack:push(TextBox.new(game, text(game).losingMyTouch, done))
return
end
ow.runner:run(oldMan2Rows(game, ow, npc), { npc = npc, onDone = done })
ow.runner:run(oldMan2Rows(game, ow, npc), { npc = npc, onDone = done,
checkpointOnDone = "release_npc" })
end
M.VIRIDIAN_CITY = {
+7 -4
View File
@@ -253,10 +253,13 @@ local ok, code, message = mod.checkpoints:restore(game, checkpoint)
```
Checkpoint format 1 supports settled overworld control and proven battle
player-decision safe points. Battle checkpoints are limited to ordinary
single-player wild/trainer origins with no suspended script; link, Safari,
ghost, demo, scripted, animation, message, queue, and forced-action phases fail
closed. New checkpoints preserve gameplay RNG, while legacy overworld records
player-decision safe points. Ordinary single-player wild/trainer encounters are
supported. Scripted story battles are also supported when the engine can detach
their current built-in battle command and data-only row continuation, rebind any
NPC by stable id, and resume the story through a fresh runner. The suspended Lua
coroutine is never serialized. Link, Safari, ghost, demo, opaque callback,
non-data-only script, animation, message, queue, concurrent-script, and
forced-action phases fail closed. New checkpoints preserve gameplay RNG, while legacy overworld records
without RNG remain loadable. Capture excludes global options and runtime
objects. Restore validates format, game/playthrough identity, content,
coordinates, battle relationships, continuation, and RNG before mutation;
+30 -12
View File
@@ -15,9 +15,12 @@ references, completion is currently an `onFinish` closure, and scripted battles
resume a suspended `ScriptRunner` coroutine. Copying the controller would create
a record that is neither data-only nor process-independent.
The engine can instead expose a narrow semantic safe point. This gives all mods
the strongest persistent battle checkpoint the current architecture can prove,
without claiming mid-animation or suspended-script support.
The engine can instead expose a narrow semantic safe point. Ordinary encounters
use fixed engine-owned completion descriptors. A scripted story encounter may
also participate when its active command row and remaining row-list are
detached data, its NPC can be rebound by stable object id, and its completion is
one of the engine-declared semantic forms. The suspended coroutine itself is
never captured.
## API delta
@@ -26,7 +29,7 @@ second format-1 runtime kind.
### Capability
`mod.checkpoints:inspect(game)` returns this only when an ordinary single-player
`mod.checkpoints:inspect(game)` returns this only when a supported single-player
wild or trainer battle is settled at the player command menu:
```lua
@@ -35,13 +38,15 @@ wild or trainer battle is settled at the player command menu:
The action/message queue, waits, UI, animations, HP/status presentation, and
faint processing must be settled. The player must actually control the menu.
The underlying overworld must have no running/queued script or scripted move,
and the battle must carry an engine-owned semantic continuation descriptor.
The battle must carry an engine-owned semantic continuation descriptor. An
ordinary encounter requires an idle overworld. A scripted story encounter may
have exactly its originating foreground runner suspended at the battle command;
queued/parallel scripts and scripted movement remain unsafe.
Additional refusal codes are `battle_phase_busy`, `battle_origin_unsupported`,
`battle_variant_unsupported`, and `link_battle_unsupported`. Link, Safari,
ghost, old-man/demo, fishing, static-object, script-suspended, and mod-created
closure continuations remain rejected.
ghost, old-man/demo, fishing, opaque callback continuations, non-data-only
scripts, and unsupported concurrent script work remain rejected.
### Capture
@@ -107,10 +112,20 @@ trainer class/party, and optional header event; a win reapplies the same defeate
flag, event, reward, and `afterBattle` path. Reconstructed overworld input and
NPC freeze state are normalized instead of reviving the old closure.
`Commands.start_battle` is deliberately unsupported: its completion closure
mutates script context and resumes a coroutine whose program counter and Lua
stack cannot be serialized. Existing script rejection remains the correct safe
contract until a separate semantic ScriptRunner checkpoint RFC exists.
For `start_battle`, `rival_battle`, and `static_battle`, the runner records the
detached row list and current command program counter plus stable source/NPC
identity where present. On restore, battle completion starts a fresh runner at
that command with a one-use semantic battle result. Replaying the current
command (rather than skipping to the next row) preserves wrapper behavior such
as rival-party consequences, static-object removal, `lastCheck`, and deferred
`afterBattle` evolution ordering. The reconstructed overworld starts with
normalized input/NPC freeze state, so an engine-marked `release_npc` callback
needs no closure revival.
Arbitrary `onDone` callbacks, function-bearing rows, unknown commands, missing
NPC identities, old-man/demo flows, and concurrent scripts fail closed. This is
not a general ScriptRunner snapshot: no coroutine, Lua stack, local variable,
function, or runtime object enters the checkpoint.
## Migration note
@@ -138,3 +153,6 @@ No-mod behavior is unchanged when checkpoints are unused.
- exactly one post-verification `checkpoint.restored` event and none on failure;
- legacy overworld checkpoint compatibility;
- complete ROM-free engine and public mod-API suites.
- scripted trainer and static/wild story continuation, including wrapper-row
replay, cold reconstruction, malformed row rejection, and opaque callback
refusal.
+25 -2
View File
@@ -4,6 +4,7 @@
local BattleCheckpoint = {}
local BattleState = require("src.battle.BattleState")
local ScriptRunner = require("src.script.ScriptRunner")
local BUILTIN_RULESETS = {
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
modern_clean = require("src.battle.rulesets.modern_clean"),
@@ -158,7 +159,9 @@ function BattleCheckpoint.validate(game, checkpoint)
end
local expectedOrigin = model.kind == "wild" and "wild_encounter"
or model.kind == "trainer" and "trainer_encounter" or nil
if not expectedOrigin or model.origin.kind ~= expectedOrigin
local scripted = model.origin.kind == "script_battle"
if not expectedOrigin
or (model.origin.kind ~= expectedOrigin and not scripted)
or model.origin.map ~= checkpoint.runtime.overworld.map then
return nil, "battle_origin_unsupported",
"Battle continuation data is unsupported or inconsistent."
@@ -167,7 +170,27 @@ function BattleCheckpoint.validate(game, checkpoint)
or type(rulesets(game)[model.rulesetId]) ~= "table" then
return nil, "invalid_content", "Battle ruleset is unavailable."
end
if model.kind == "trainer" and (type(model.origin.npcId) ~= "string"
if scripted then
local origin = model.origin
local row = type(origin.script) == "table" and origin.script[origin.pc]
local allowed = { start_battle = true, static_battle = true, rival_battle = true }
if type(origin.pc) ~= "number" or origin.pc % 1 ~= 0
or type(row) ~= "table" or row[1] ~= origin.command
or not allowed[origin.command] or origin.battleKind ~= model.kind
or (model.kind == "trainer" and (origin.trainerClass ~= model.oppClass
or origin.partyIndex ~= (model.partyIndex or 1)))
or (model.kind == "wild" and (origin.wildSpecies ~= model.enemyMon.species
or origin.wildLevel ~= model.enemyMon.level))
or (origin.npcId ~= nil and type(origin.npcId) ~= "string") then
return nil, "battle_origin_unsupported",
"Script battle continuation data is incomplete or inconsistent."
end
local problems = ScriptRunner.validate(origin.script)
if #problems > 0 then
return nil, "battle_origin_unsupported",
"Script battle continuation commands are unavailable."
end
elseif model.kind == "trainer" and (type(model.origin.npcId) ~= "string"
or model.origin.trainerClass ~= model.oppClass
or model.origin.partyIndex ~= (model.partyIndex or 1)) then
return nil, "battle_origin_unsupported",
+14 -3
View File
@@ -57,13 +57,24 @@ local function inspectBattle(ow, battle)
"This battle kind does not have a checkpoint contract.")
end
local origin = battle.checkpointOrigin
local expectedOrigin = battle.kind == "wild" and "wild_encounter"
local ordinaryOrigin = battle.kind == "wild" and "wild_encounter"
or "trainer_encounter"
if type(origin) ~= "table" or origin.kind ~= expectedOrigin then
local scriptedOrigin = type(origin) == "table"
and origin.kind == "script_battle"
if type(origin) ~= "table"
or (origin.kind ~= ordinaryOrigin and not scriptedOrigin) then
return refusal("battle", "battle_origin_unsupported",
"The battle completion path cannot be reconstructed safely.")
end
if scriptsBusy(ow) then
local scriptedRunner = scriptedOrigin and (battle.checkpointScriptContinuation
or (ow.runner
and ow.runner.isCheckpointBattle
and ow.runner:isCheckpointBattle(battle)))
local otherScriptWork = nonempty(ow.parallelRunners)
or nonempty(ow.pendingScripts) or nonempty(ow.parallelQueue)
or nonempty(ow.scriptMoves)
if (scriptedOrigin and (not scriptedRunner or otherScriptWork))
or (not scriptedOrigin and scriptsBusy(ow)) then
return refusal("battle", "script_busy",
"A suspended or queued script cannot be checkpointed.")
end
+22
View File
@@ -300,6 +300,24 @@ end
function Commands.start_battle(ctx, kind, a, b)
local BattleState = require("src.battle.BattleState")
local runner = ctx.runner
local resumed = ctx.resumeBattle
if resumed then
ctx.resumeBattle = nil
local result, restoredBattle = resumed.result, resumed.battle
ctx.lastBattleResult = result
ctx.lastCheck = result == "win"
if ctx.overworld then
if result == "win" then
ctx.afterScript = ctx.afterScript or {}
table.insert(ctx.afterScript, function()
ctx.overworld:afterBattle(result, restoredBattle)
end)
else
ctx.overworld:afterBattle(result, restoredBattle)
end
end
return
end
local battle
if kind == "wild" then
battle = BattleState.newWild(ctx.game, a, b)
@@ -309,6 +327,10 @@ function Commands.start_battle(ctx, kind, a, b)
-- one SaveEndBattleTextPointers arms one battle; leaving it set would leak
-- the line into the next scripted fight
battle.endBattleText, ctx.endBattleText = ctx.endBattleText, nil
if runner and runner.battleCheckpointOrigin then
battle.checkpointOrigin = runner:battleCheckpointOrigin(battle)
if battle.checkpointOrigin then runner.checkpointBattle = battle end
end
battle.onFinish = function(result)
ctx.lastBattleResult = result
ctx.lastCheck = result == "win"
+61 -4
View File
@@ -13,6 +13,7 @@
local Commands = require("src.script.Commands")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local SaveSerializer = require("src.core.SaveSerializer")
local Strings = require("src.core.Strings")
local unpack = table.unpack or unpack -- LuaJIT (LÖVE) compatibility
@@ -116,7 +117,7 @@ function ScriptRunner:makeContext(extra)
return ctx
end
function ScriptRunner:run(script, extra)
function ScriptRunner:run(script, extra, startPc)
assert(not self:isRunning(), "script already running")
local ctx = self:makeContext(extra)
self.ctx = ctx
@@ -124,7 +125,7 @@ function ScriptRunner:run(script, extra)
Runtime.emit("script.started", { ctx = ctx })
end
self.co = coroutine.create(function()
self:exec(script, ctx)
self:exec(script, ctx, startPc)
-- Commands can defer a game action until the script's own dialogue is
-- done. start_battle uses this for win-path evolutions, which must not
-- be covered by post-battle trainer text.
@@ -140,11 +141,13 @@ end
-- Execute a command list. Supports labels via jump commands: a script is
-- an array of rows; control commands return a new program counter, as a
-- row number or a label name.
function ScriptRunner:exec(script, ctx)
function ScriptRunner:exec(script, ctx, startPc)
local labels = ScriptRunner.scanLabels(script)
local data = self.game and self.game.data
local pc = 1
local pc = startPc or 1
self.script = script
while pc <= #script do
self.pc = pc
local row = script[pc]
local name = row[1]
local fn, meta = Commands.resolve(data, name)
@@ -189,6 +192,60 @@ function ScriptRunner:exec(script, ctx)
end
end
local CHECKPOINT_BATTLE_COMMANDS = {
start_battle = true,
static_battle = true,
rival_battle = true,
}
local function dataCopy(value)
local ok, encoded = pcall(SaveSerializer.encode, value)
if not ok then return nil end
return SaveSerializer.decode(encoded)
end
-- Return a detached semantic continuation only for built-in battle commands
-- whose post-yield behavior can be replayed from the current row. The live
-- coroutine and arbitrary completion callbacks never cross this boundary.
function ScriptRunner:battleCheckpointOrigin(battle)
local row = self.script and self.script[self.pc]
if not self:isRunning() or type(row) ~= "table"
or not CHECKPOINT_BATTLE_COMMANDS[row[1]] then
return nil
end
local ctx = self.ctx or {}
if ctx.onDone ~= nil and ctx.checkpointOnDone ~= "release_npc" then
return nil
end
local npcId = ctx.npc and ctx.npc.id or nil
if ctx.checkpointOnDone == "release_npc" and type(npcId) ~= "string" then
return nil
end
local map = self.overworld and self.overworld.map and self.overworld.map.id
if type(map) ~= "string" then return nil end
local origin = {
kind = "script_battle",
map = map,
script = self.script,
pc = self.pc,
command = row[1],
npcId = npcId,
source = ctx.source,
battleKind = battle and battle.kind,
trainerClass = battle and battle.oppClass or nil,
partyIndex = battle and battle.partyIndex or nil,
wildSpecies = battle and battle.enemy and battle.enemy.mon
and battle.enemy.mon.species or nil,
wildLevel = battle and battle.enemy and battle.enemy.mon
and battle.enemy.mon.level or nil,
}
return dataCopy(origin)
end
function ScriptRunner:isCheckpointBattle(battle)
return self.checkpointBattle == battle and self:isRunning()
end
-- Called by blocking commands from inside the coroutine.
function ScriptRunner:yield()
return coroutine.yield()
+25
View File
@@ -3395,6 +3395,7 @@ function OverworldState:showMapText(textConst, npc, onDone)
-- the winning contribution's rows run as their owner (09 §4.4): mod:
-- field routing, strict dispatch and error reports all read the source
self.runner:run(script, { npc = npc, onDone = onDone,
checkpointOnDone = onDone and "release_npc" or nil,
source = mapScripts.talkSource(self.map.id, textConst) })
return
end
@@ -4067,6 +4068,30 @@ function OverworldState:restoreBattleContinuation(battle, origin)
battle.onFinish = function(result) self:afterBattle(result, battle) end
return true
end
if origin.kind == "script_battle" then
if origin.battleKind ~= battle.kind
or (battle.kind == "trainer" and (origin.trainerClass ~= battle.oppClass
or origin.partyIndex ~= (battle.partyIndex or 1)))
or type(origin.script) ~= "table" or type(origin.pc) ~= "number" then
return false
end
local npc = origin.npcId and self.npcPool and self.npcPool[origin.npcId] or nil
if origin.npcId and not npc then return false end
battle.onFinish = function(result)
local runner = self.runner
if not runner or runner:isRunning() then
runner = ScriptRunner.new(game, self)
self.runner = runner
end
runner:run(origin.script, {
npc = npc,
source = origin.source,
resumeBattle = { result = result, battle = battle },
}, origin.pc)
end
battle.checkpointScriptContinuation = true
return true
end
if origin.kind ~= "trainer_encounter" or battle.kind ~= "trainer"
or origin.trainerClass ~= battle.oppClass
or origin.partyIndex ~= (battle.partyIndex or 1)
+1 -1
View File
@@ -113,7 +113,7 @@ refused(function(_, _, b) b.enemy.mon.hp = b.enemy.mon.hp - 1 end,
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")
"script_busy", "unknown 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,
@@ -0,0 +1,178 @@
-- 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()