mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
Merge pull request #1078 from MaxTomahawk/feat/scripted-battle-checkpoints
feat(mods): checkpoint scripted battle decisions
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user