From 882763cfe1dc42714908b5d16018811f00a64b70 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Tue, 11 Aug 2026 08:58:33 +0200 Subject: [PATCH] feat: checkpoint scripted battle decisions --- data/scripts/oaks_lab_yellow.lua | 3 +- data/scripts/yellow_gifts.lua | 6 +- data/scripts/yellow_viridian_old_man.lua | 3 +- docs/modding.md | 11 +- docs/rfcs/0005-battle-runtime-checkpoints.md | 42 +++-- src/core/BattleCheckpoint.lua | 27 ++- src/core/Checkpoint.lua | 17 +- src/script/Commands.lua | 22 +++ src/script/ScriptRunner.lua | 65 ++++++- src/world/OverworldController.lua | 25 +++ tests/engine/battle_checkpoint_boundary.lua | 2 +- .../scripted_battle_checkpoint_test.lua | 178 ++++++++++++++++++ 12 files changed, 371 insertions(+), 30 deletions(-) create mode 100644 tests/engine/scripted_battle_checkpoint_test.lua diff --git a/data/scripts/oaks_lab_yellow.lua b/data/scripts/oaks_lab_yellow.lua index c56f6091..91b1365d 100644 --- a/data/scripts/oaks_lab_yellow.lua +++ b/data/scripts/oaks_lab_yellow.lua @@ -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 = { diff --git a/data/scripts/yellow_gifts.lua b/data/scripts/yellow_gifts.lua index fe2f08a1..738e4e92 100644 --- a/data/scripts/yellow_gifts.lua +++ b/data/scripts/yellow_gifts.lua @@ -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, }, } diff --git a/data/scripts/yellow_viridian_old_man.lua b/data/scripts/yellow_viridian_old_man.lua index 3cf7dcd9..dcadc731 100644 --- a/data/scripts/yellow_viridian_old_man.lua +++ b/data/scripts/yellow_viridian_old_man.lua @@ -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 = { diff --git a/docs/modding.md b/docs/modding.md index b13fb048..6d4499ab 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -230,10 +230,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; diff --git a/docs/rfcs/0005-battle-runtime-checkpoints.md b/docs/rfcs/0005-battle-runtime-checkpoints.md index f94d2ba4..d3459584 100644 --- a/docs/rfcs/0005-battle-runtime-checkpoints.md +++ b/docs/rfcs/0005-battle-runtime-checkpoints.md @@ -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. diff --git a/src/core/BattleCheckpoint.lua b/src/core/BattleCheckpoint.lua index 912ea1b2..67698662 100644 --- a/src/core/BattleCheckpoint.lua +++ b/src/core/BattleCheckpoint.lua @@ -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", diff --git a/src/core/Checkpoint.lua b/src/core/Checkpoint.lua index e6a3b036..433a7794 100644 --- a/src/core/Checkpoint.lua +++ b/src/core/Checkpoint.lua @@ -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 diff --git a/src/script/Commands.lua b/src/script/Commands.lua index dba4844b..3eb73f6f 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -284,6 +284,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) @@ -293,6 +311,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" diff --git a/src/script/ScriptRunner.lua b/src/script/ScriptRunner.lua index 5ad244ea..63472742 100644 --- a/src/script/ScriptRunner.lua +++ b/src/script/ScriptRunner.lua @@ -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() diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index a345ba6c..aea59594 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -3394,6 +3394,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 @@ -4066,6 +4067,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) diff --git a/tests/engine/battle_checkpoint_boundary.lua b/tests/engine/battle_checkpoint_boundary.lua index e8d1bf5f..b8c1d09d 100644 --- a/tests/engine/battle_checkpoint_boundary.lua +++ b/tests/engine/battle_checkpoint_boundary.lua @@ -64,7 +64,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, diff --git a/tests/engine/scripted_battle_checkpoint_test.lua b/tests/engine/scripted_battle_checkpoint_test.lua new file mode 100644 index 00000000..678d4eb1 --- /dev/null +++ b/tests/engine/scripted_battle_checkpoint_test.lua @@ -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()