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
+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)