mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
feat: expose stable overworld checkpoints to mods
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
-- Public runtime checkpoint implementation. Loader exposes bound forwarding
|
||||
-- methods; mods never receive controller or state-stack internals from here.
|
||||
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
|
||||
local Checkpoint = {}
|
||||
|
||||
Checkpoint.FORMAT = 1
|
||||
|
||||
local function refusal(kind, reason, message)
|
||||
return {
|
||||
canCapture = false,
|
||||
canRestore = false,
|
||||
kind = kind or "unknown",
|
||||
reason = reason,
|
||||
message = message,
|
||||
}
|
||||
end
|
||||
|
||||
local function running(runner)
|
||||
return runner and runner.isRunning and runner:isRunning()
|
||||
end
|
||||
|
||||
local function nonempty(value)
|
||||
return type(value) == "table" and next(value) ~= nil
|
||||
end
|
||||
|
||||
function Checkpoint.inspect(game)
|
||||
local save = game and game.save
|
||||
local identity = save and save.meta and save.meta.playthroughId
|
||||
if type(save) ~= "table" or type(save.version) ~= "string"
|
||||
or type(identity) ~= "string" or identity == "" then
|
||||
return refusal("unknown", "not_in_playthrough",
|
||||
"A checkpoint requires an identified active playthrough.")
|
||||
end
|
||||
|
||||
local ow = game.overworld
|
||||
if type(ow) ~= "table" or type(ow.map) ~= "table"
|
||||
or type(ow.map.id) ~= "string" or type(ow.player) ~= "table" then
|
||||
return refusal("unknown", "not_overworld",
|
||||
"Only a settled overworld can be checkpointed.")
|
||||
end
|
||||
local top = game.stack and game.stack.top and game.stack:top()
|
||||
if top ~= ow then
|
||||
return refusal("overworld", "screen_busy",
|
||||
"Close the active menu or screen before creating a checkpoint.")
|
||||
end
|
||||
if ow.transitioning then
|
||||
return refusal("overworld", "transition_busy",
|
||||
"Wait for the map transition to finish.")
|
||||
end
|
||||
if running(ow.runner) or nonempty(ow.parallelRunners)
|
||||
or nonempty(ow.pendingScripts) or nonempty(ow.parallelQueue)
|
||||
or nonempty(ow.scriptMoves) then
|
||||
return refusal("overworld", "script_busy",
|
||||
"Wait for the active or queued script to finish.")
|
||||
end
|
||||
|
||||
local animationFields = {
|
||||
"engaging", "emote", "teleportOut", "dustAnim", "cutAnim", "fishPose",
|
||||
"pikaHop", "healAnim", "flyAnim", "flyArrive",
|
||||
}
|
||||
for _, field in ipairs(animationFields) do
|
||||
if ow[field] then
|
||||
return refusal("overworld", "animation_busy",
|
||||
"Wait for the overworld animation to finish.")
|
||||
end
|
||||
end
|
||||
if ow.player.moving or ow.player.targetX ~= nil or ow.player.targetY ~= nil then
|
||||
return refusal("overworld", "movement_busy",
|
||||
"Wait for movement to settle on a tile.")
|
||||
end
|
||||
return { canCapture = true, canRestore = true, kind = "overworld" }
|
||||
end
|
||||
|
||||
local function dataCopy(value)
|
||||
local ok, encoded = pcall(SaveSerializer.encode, value)
|
||||
if not ok then return nil, tostring(encoded) end
|
||||
local decoded, err = SaveSerializer.decode(encoded)
|
||||
if not decoded then return nil, err end
|
||||
return decoded
|
||||
end
|
||||
|
||||
function Checkpoint.capture(game)
|
||||
local capability = Checkpoint.inspect(game)
|
||||
if not capability.canCapture then
|
||||
return nil, capability.reason, capability.message
|
||||
end
|
||||
|
||||
local progress = {}
|
||||
for key, value in pairs(game.save) do
|
||||
if key ~= "options" then progress[key] = value end
|
||||
end
|
||||
progress = dataCopy(progress)
|
||||
if not progress then
|
||||
return nil, "capture_failed", "Progress contains non-serializable runtime data."
|
||||
end
|
||||
|
||||
local ok, err = pcall(game.overworld.captureSave, game.overworld, progress)
|
||||
if not ok then
|
||||
return nil, "capture_failed", "Could not synchronize overworld progress: "
|
||||
.. tostring(err)
|
||||
end
|
||||
progress, err = dataCopy(progress)
|
||||
if not progress then
|
||||
return nil, "capture_failed", "Synchronized progress is not data-only: "
|
||||
.. tostring(err)
|
||||
end
|
||||
|
||||
local player = game.overworld.player
|
||||
return {
|
||||
format = Checkpoint.FORMAT,
|
||||
kind = "overworld",
|
||||
identity = {
|
||||
gameVersion = game.save.version,
|
||||
playthroughId = game.save.meta.playthroughId,
|
||||
},
|
||||
save = progress,
|
||||
runtime = { overworld = {
|
||||
map = game.overworld.map.id,
|
||||
x = player.cellX,
|
||||
y = player.cellY,
|
||||
facing = player.facing,
|
||||
surfing = player.surfing and true or false,
|
||||
} },
|
||||
}
|
||||
end
|
||||
|
||||
local FACINGS = { up = true, down = true, left = true, right = true }
|
||||
|
||||
local function validate(game, checkpoint)
|
||||
if type(checkpoint) ~= "table" then
|
||||
return nil, "invalid_checkpoint", "Checkpoint root must be a table."
|
||||
end
|
||||
if checkpoint.format ~= Checkpoint.FORMAT then
|
||||
return nil, "unsupported_format", "This checkpoint format is not supported."
|
||||
end
|
||||
if checkpoint.kind ~= "overworld" then
|
||||
return nil, "unsupported_runtime_kind", "Only overworld checkpoints are supported."
|
||||
end
|
||||
|
||||
local copy, copyErr = dataCopy(checkpoint)
|
||||
if not copy then
|
||||
return nil, "invalid_checkpoint", "Checkpoint is not data-only: "
|
||||
.. tostring(copyErr)
|
||||
end
|
||||
local identity = copy.identity
|
||||
local current = game and game.save
|
||||
local currentId = current and current.meta and current.meta.playthroughId
|
||||
if type(identity) ~= "table" or type(identity.gameVersion) ~= "string"
|
||||
or type(identity.playthroughId) ~= "string" then
|
||||
return nil, "invalid_checkpoint", "Checkpoint identity is missing or corrupt."
|
||||
end
|
||||
if identity.gameVersion ~= current.version then
|
||||
return nil, "wrong_game", "Checkpoint belongs to another game version."
|
||||
end
|
||||
if identity.playthroughId ~= currentId then
|
||||
return nil, "wrong_playthrough", "Checkpoint belongs to another playthrough."
|
||||
end
|
||||
|
||||
local save = copy.save
|
||||
local runtime = copy.runtime and copy.runtime.overworld
|
||||
if type(save) ~= "table" or type(save.player) ~= "table"
|
||||
or type(runtime) ~= "table" then
|
||||
return nil, "invalid_checkpoint", "Checkpoint progress or runtime data is missing."
|
||||
end
|
||||
if save.version ~= identity.gameVersion
|
||||
or not save.meta or save.meta.playthroughId ~= identity.playthroughId then
|
||||
return nil, "invalid_checkpoint", "Checkpoint progress identity is inconsistent."
|
||||
end
|
||||
if type(runtime.map) ~= "string" or type(runtime.x) ~= "number"
|
||||
or type(runtime.y) ~= "number" or runtime.x % 1 ~= 0 or runtime.y % 1 ~= 0
|
||||
or not FACINGS[runtime.facing] or type(runtime.surfing) ~= "boolean" then
|
||||
return nil, "invalid_checkpoint", "Overworld position is missing or corrupt."
|
||||
end
|
||||
if save.player.map ~= runtime.map or save.player.x ~= runtime.x
|
||||
or save.player.y ~= runtime.y or save.player.facing ~= runtime.facing
|
||||
or (save.player.surfing and true or false) ~= runtime.surfing then
|
||||
return nil, "invalid_checkpoint", "Progress and runtime position disagree."
|
||||
end
|
||||
|
||||
local map = game.data and game.data.maps and game.data.maps[runtime.map]
|
||||
if type(map) ~= "table" then
|
||||
return nil, "invalid_map", "Checkpoint references a map that is unavailable."
|
||||
end
|
||||
local width, height = tonumber(map.width), tonumber(map.height)
|
||||
if not width or not height or runtime.x < 0 or runtime.y < 0
|
||||
or runtime.x >= width * 2 or runtime.y >= height * 2 then
|
||||
return nil, "invalid_position", "Checkpoint position is outside the map."
|
||||
end
|
||||
return copy
|
||||
end
|
||||
|
||||
local function apply(game, checkpoint, options)
|
||||
local save, err = dataCopy(checkpoint.save)
|
||||
if not save then error("checkpoint progress decode failed: " .. tostring(err), 0) end
|
||||
local runtime = checkpoint.runtime.overworld
|
||||
save.options = options
|
||||
save.player.map = runtime.map
|
||||
save.player.x = runtime.x
|
||||
save.player.y = runtime.y
|
||||
save.player.facing = runtime.facing
|
||||
save.player.surfing = runtime.surfing
|
||||
if type(game.restoreCheckpointSave) ~= "function" then
|
||||
error("game has no checkpoint reconstruction path", 0)
|
||||
end
|
||||
game:restoreCheckpointSave(save)
|
||||
end
|
||||
|
||||
local function equalData(a, b)
|
||||
local okA, encodedA = pcall(SaveSerializer.encode, a)
|
||||
local okB, encodedB = pcall(SaveSerializer.encode, b)
|
||||
return okA and okB and encodedA == encodedB
|
||||
end
|
||||
|
||||
function Checkpoint.restore(game, checkpoint)
|
||||
local capability = Checkpoint.inspect(game)
|
||||
if not capability.canRestore then
|
||||
return false, capability.reason, capability.message
|
||||
end
|
||||
local validated, code, message = validate(game, checkpoint)
|
||||
if not validated then return false, code, message end
|
||||
|
||||
local rollback, captureCode, captureMessage = Checkpoint.capture(game)
|
||||
if not rollback then return false, captureCode, captureMessage end
|
||||
local options = game.save.options
|
||||
|
||||
local ok, err = pcall(apply, game, validated, options)
|
||||
if ok then
|
||||
local restored, verifyCode = Checkpoint.capture(game)
|
||||
if restored and equalData(restored, validated) then return true end
|
||||
err = "restored state did not match checkpoint: " .. tostring(verifyCode)
|
||||
end
|
||||
|
||||
local rolledBack, rollbackErr = pcall(apply, game, rollback, options)
|
||||
if not rolledBack then
|
||||
return false, "rollback_failed",
|
||||
"Checkpoint restore and rollback both failed: " .. tostring(rollbackErr)
|
||||
end
|
||||
return false, "restore_failed", "Checkpoint restoration failed: " .. tostring(err)
|
||||
end
|
||||
|
||||
return Checkpoint
|
||||
@@ -1131,4 +1131,17 @@ function Game:restoreSave(loaded, recovered)
|
||||
end
|
||||
end
|
||||
|
||||
-- Reconstruct a previously validated runtime checkpoint without replaying the
|
||||
-- ordinary CONTINUE lifecycle. In particular, map onEnter scripts and
|
||||
-- save.loading/save.loaded events must not run a second time. Validation,
|
||||
-- identity checks and transactional rollback live in Checkpoint.lua.
|
||||
function Game:restoreCheckpointSave(loaded)
|
||||
self.save = loaded
|
||||
self:adoptSave(loaded)
|
||||
while self.stack:top() do self.stack:pop() end
|
||||
self.stack:push(self.overworld, loaded.player.map,
|
||||
loaded.player.x, loaded.player.y, loaded.player.facing,
|
||||
{ via = "checkpoint", checkpoint = true })
|
||||
end
|
||||
|
||||
return Game
|
||||
|
||||
@@ -571,6 +571,7 @@ function Loader:_api(mod)
|
||||
local modId = mod.manifest.id
|
||||
local Storage = engineRequire("src.mods.Storage")
|
||||
local storage = Storage and Storage.new(modId, loader.fs)
|
||||
local Checkpoint = engineRequire("src.core.Checkpoint")
|
||||
local api = {
|
||||
id = modId,
|
||||
version = mod.manifest.version,
|
||||
@@ -670,6 +671,15 @@ function Loader:_api(mod)
|
||||
list = function(_, game, prefix) return storage:list(game, prefix) end,
|
||||
delete = function(_, game, key) return storage:delete(game, key) end,
|
||||
},
|
||||
-- Runtime safety and reconstruction stay engine-owned. Checkpoints contain
|
||||
-- data only; no controller, stack, coroutine or renderer object crosses out.
|
||||
checkpoints = {
|
||||
inspect = function(_, game) return Checkpoint.inspect(game) end,
|
||||
capture = function(_, game) return Checkpoint.capture(game) end,
|
||||
restore = function(_, game, checkpoint)
|
||||
return Checkpoint.restore(game, checkpoint)
|
||||
end,
|
||||
},
|
||||
options = {
|
||||
define = function(_, schema)
|
||||
assert(type(schema) == "table", "options schema must be a table of rows")
|
||||
|
||||
@@ -206,7 +206,7 @@ function OverworldState.computeNeighbors(maps, rootId, hops, reachW, reachH)
|
||||
return out
|
||||
end
|
||||
|
||||
function OverworldState:enter(mapId, x, y, facing)
|
||||
function OverworldState:enter(mapId, x, y, facing, opts)
|
||||
Game = require("src.core.Game")
|
||||
Game.overworld = self
|
||||
Collision.load(Game.data) -- tile-pair (elevation) collisions
|
||||
@@ -227,7 +227,7 @@ function OverworldState:enter(mapId, x, y, facing)
|
||||
-- survives save/load: a loaded game may start inside a building whose
|
||||
-- exit mat is a LAST_MAP warp
|
||||
self.lastOutdoor = Game.save.lastOutdoor
|
||||
self:setMap(mapId, x, y, facing, { via = "boot" })
|
||||
self:setMap(mapId, x, y, facing, opts or { via = "boot" })
|
||||
-- boot/load: derive the flag from the tile the save left us standing on,
|
||||
-- like MapEntryAfterBattle's IsPlayerStandingOnWarp, so a game saved on a
|
||||
-- door mat can still walk straight back out (issue #378)
|
||||
@@ -282,7 +282,7 @@ end
|
||||
|
||||
function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
local fromMapId = self.map and self.map.id
|
||||
if fromMapId then
|
||||
if fromMapId and not (opts and opts.checkpoint) then
|
||||
Runtime.emit("map.exited", { mapId = fromMapId, toMapId = mapId })
|
||||
end
|
||||
-- ambient choreography is per-map: parallel runners die here, and the
|
||||
@@ -460,13 +460,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
-- (home/overworld.asm) -- a warp can land directly on one (the Route
|
||||
-- 16/18 gate exits), and the scripted door-mat walkout that follows
|
||||
-- suppresses onStepComplete, so waiting for a plain step never mounts
|
||||
self:checkForcedMovement()
|
||||
if not (opts and opts.checkpoint) then self:checkForcedMovement() end
|
||||
-- Seafoam B4F's map script pushes off the B3F stair warps every frame
|
||||
-- while the upper plugs are out (SeafoamIslandsB4FDefaultScript); the
|
||||
-- B3F/B4F force-surf mouths also arm their MOVE_OBJECT current scripts
|
||||
-- from CheckForceBikeOrSurf. Re-check here so a warp-in does not sit
|
||||
-- idle on those cells waiting for a player step.
|
||||
self:checkSeafoamCurrent()
|
||||
if not (opts and opts.checkpoint) then self:checkSeafoamCurrent() end
|
||||
|
||||
-- snap the camera immediately: the overworld doesn't update while a
|
||||
-- Transition is on top, so a stale camera would show the new map at
|
||||
@@ -476,27 +476,31 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
|
||||
-- fires before the onEnter chain so a listener sees the map in the same
|
||||
-- state the map script does
|
||||
Runtime.emit("map.entered", {
|
||||
mapId = mapId, map = self.map, fromMapId = fromMapId,
|
||||
via = (opts and opts.via)
|
||||
or (opts and opts.seamless and "connection")
|
||||
or (fromMapId and "warp" or "boot"),
|
||||
})
|
||||
if not (opts and opts.checkpoint) then
|
||||
Runtime.emit("map.entered", {
|
||||
mapId = mapId, map = self.map, fromMapId = fromMapId,
|
||||
via = (opts and opts.via)
|
||||
or (opts and opts.seamless and "connection")
|
||||
or (fromMapId and "warp" or "boot"),
|
||||
})
|
||||
end
|
||||
|
||||
-- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers).
|
||||
-- fromMapId lets elevators seed a valid walk-out floor when the ROM
|
||||
-- car warps still point at a missing map (Silph's UNUSED_MAP_ED) and
|
||||
-- the player B-cancels the floor menu without .UpdateWarp.
|
||||
local hooks = mapScripts.get(mapId)
|
||||
if hooks and hooks.onEnter then
|
||||
hooks.onEnter(Game, self, fromMapId)
|
||||
if not (opts and opts.checkpoint) then
|
||||
local hooks = mapScripts.get(mapId)
|
||||
if hooks and hooks.onEnter then
|
||||
hooks.onEnter(Game, self, fromMapId)
|
||||
end
|
||||
end
|
||||
|
||||
self:rebuildNeighbors()
|
||||
Logger.info("map: %s at (%d,%d)", mapId, x, y)
|
||||
-- Route22Gate_Script rewrites wLastMap from the player's Y on entry
|
||||
-- too (not only on step), so a save/load mid-gate keeps exits correct
|
||||
self:syncLastMapRewrite()
|
||||
if not (opts and opts.checkpoint) then self:syncLastMapRewrite() end
|
||||
end
|
||||
|
||||
-- Neighbor maps drawn at the composed connection offsets: at least the
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
-- Public mod.checkpoints contract over a semantic Game/StateStack fixture.
|
||||
-- The mod entry chunk sees no private module; the harness builds the engine side.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("mod checkpoints")
|
||||
local Loader = require("src.mods.Loader")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local GameMethods = require("src.core.Game")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
|
||||
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
||||
|
||||
local function memfs(files)
|
||||
return {
|
||||
read = function(path) return files[path] end,
|
||||
write = function(path, body) files[path] = body return true end,
|
||||
remove = function(path) files[path] = nil return true end,
|
||||
createDirectory = function() return true end,
|
||||
getInfo = function(path)
|
||||
if files[path] then return { type = "file" } end
|
||||
local prefix = path .. "/"
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
load = function(path)
|
||||
if not files[path] then return nil, "no file: " .. path end
|
||||
return load(files[path], path)
|
||||
end,
|
||||
getDirectoryItems = function(path)
|
||||
local prefix, seen, out = path .. "/", {}, {}
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then
|
||||
local child = key:sub(#prefix + 1):match("^[^/]+")
|
||||
if child and not seen[child] then
|
||||
seen[child] = true
|
||||
out[#out + 1] = child
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local function baseSave()
|
||||
return {
|
||||
version = "red",
|
||||
meta = { format = 4, mods = {}, playthroughId = "play-a" },
|
||||
player = {
|
||||
map = "PALLET_TOWN", x = 5, y = 6, facing = "down", surfing = false,
|
||||
name = "RED", rival = "BLUE", id = 7,
|
||||
},
|
||||
money = 3000,
|
||||
party = { { species = "BULBASAUR", hp = 19, moves = { "TACKLE" } } },
|
||||
flags = { GOT_STARTER = true },
|
||||
inventory = { POTION = 1 },
|
||||
pcItems = {}, box = {}, boxes = {}, defeatedTrainers = {},
|
||||
pokedex = { seen = { BULBASAUR = true }, owned = { BULBASAUR = true } },
|
||||
modData = {},
|
||||
options = { volume = 4, bindings = {} },
|
||||
}
|
||||
end
|
||||
|
||||
local function makeGame()
|
||||
local stack = setmetatable({ states = {} }, { __index = StateStack })
|
||||
local game
|
||||
local ow = {
|
||||
map = { id = "PALLET_TOWN" },
|
||||
player = { cellX = 5, cellY = 6, facing = "down", surfing = false },
|
||||
scriptMoves = {}, pendingScripts = {}, parallelRunners = {}, parallelQueue = {},
|
||||
runner = { isRunning = function() return false end },
|
||||
}
|
||||
function ow:captureSave(save)
|
||||
save.player.map = self.map.id
|
||||
save.player.x = self.player.cellX
|
||||
save.player.y = self.player.cellY
|
||||
save.player.facing = self.player.facing
|
||||
save.player.surfing = self.player.surfing and true or false
|
||||
end
|
||||
function ow:enter(mapId, x, y, facing, opts)
|
||||
game.lastEnterOpts = opts
|
||||
if game.failNextEnter then
|
||||
game.failNextEnter = false
|
||||
error("injected reconstruction failure")
|
||||
end
|
||||
self.map = { id = mapId }
|
||||
self.player = {
|
||||
cellX = x, cellY = y, facing = facing,
|
||||
surfing = game.save.player.surfing and true or false,
|
||||
}
|
||||
self.scriptMoves, self.pendingScripts = {}, {}
|
||||
self.parallelRunners, self.parallelQueue = {}, {}
|
||||
self.runner = { isRunning = function() return false end }
|
||||
end
|
||||
game = setmetatable({
|
||||
save = baseSave(), stack = stack, overworld = ow,
|
||||
data = { maps = {
|
||||
PALLET_TOWN = { id = "PALLET_TOWN", width = 10, height = 9 },
|
||||
ROUTE_1 = { id = "ROUTE_1", width = 10, height = 18 },
|
||||
BROKEN = { id = "BROKEN", width = 10, height = 9 },
|
||||
} },
|
||||
}, { __index = GameMethods })
|
||||
stack.states[1] = ow
|
||||
return game, ow
|
||||
end
|
||||
|
||||
local files = {
|
||||
["mods/probe/manifest.json"] =
|
||||
'{"id":"probe","name":"probe","version":"1.0.0",'
|
||||
.. '"entry":"main.lua","api":2,"profile":"content"}',
|
||||
["mods/probe/main.lua"] = [[
|
||||
return function(mod) _G.MOD_CHECKPOINTS = mod.checkpoints end
|
||||
]],
|
||||
}
|
||||
local game, ow = makeGame()
|
||||
local loader = Loader.new({ fs = memfs(files) })
|
||||
loader.game = game
|
||||
T.check(loader:load({}) == true, "checkpoint fixture mod loads")
|
||||
local checkpoints = _G.MOD_CHECKPOINTS
|
||||
T.check(type(checkpoints) == "table",
|
||||
"Loader exposes mod.checkpoints through the public mod object")
|
||||
if type(checkpoints) ~= "table" then
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
_G.MOD_CHECKPOINTS = nil
|
||||
T.finish()
|
||||
end
|
||||
|
||||
local capability = checkpoints:inspect(game)
|
||||
T.same(capability, { canCapture = true, canRestore = true, kind = "overworld" },
|
||||
"plain overworld control is a stable checkpoint boundary")
|
||||
|
||||
local function refused(mutator, expectedCode, message)
|
||||
local undo = mutator()
|
||||
local result = checkpoints:inspect(game)
|
||||
T.check(result.canCapture == false and result.reason == expectedCode, message)
|
||||
undo()
|
||||
end
|
||||
|
||||
refused(function()
|
||||
ow.transitioning = true
|
||||
return function() ow.transitioning = nil end
|
||||
end, "transition_busy", "transition frames are rejected")
|
||||
|
||||
refused(function()
|
||||
ow.runner = { isRunning = function() return true end }
|
||||
return function() ow.runner = { isRunning = function() return false end } end
|
||||
end, "script_busy", "foreground suspended scripts are rejected")
|
||||
|
||||
refused(function()
|
||||
ow.parallelRunners = { { isRunning = function() return true end } }
|
||||
return function() ow.parallelRunners = {} end
|
||||
end, "script_busy", "parallel suspended scripts are rejected")
|
||||
|
||||
refused(function()
|
||||
ow.pendingScripts = { { rows = {} } }
|
||||
return function() ow.pendingScripts = {} end
|
||||
end, "script_busy", "queued scripts are rejected")
|
||||
|
||||
refused(function()
|
||||
ow.scriptMoves = { { entity = ow.player } }
|
||||
return function() ow.scriptMoves = {} end
|
||||
end, "script_busy", "scripted movement is rejected")
|
||||
|
||||
refused(function()
|
||||
game.stack.states[2] = { screenId = "StartMenu" }
|
||||
return function() game.stack.states[2] = nil end
|
||||
end, "screen_busy", "modal screens over the overworld are rejected")
|
||||
|
||||
refused(function()
|
||||
ow.emote = { frames = 1 }
|
||||
return function() ow.emote = nil end
|
||||
end, "animation_busy", "partial overworld animations are rejected")
|
||||
|
||||
refused(function()
|
||||
ow.player.moving = true
|
||||
return function() ow.player.moving = nil end
|
||||
end, "movement_busy", "partial player movement is rejected")
|
||||
|
||||
local titleGame = { save = game.save, stack = {
|
||||
top = function() return { screenId = "TitleState" } end,
|
||||
} }
|
||||
local titleCapability = checkpoints:inspect(titleGame)
|
||||
T.check(titleCapability.canCapture == false
|
||||
and titleCapability.reason == "not_overworld",
|
||||
"title and non-playthrough runtime is rejected")
|
||||
|
||||
-- Capture synchronizes semantic position into a detached data-only record.
|
||||
ow.map.id, ow.player.cellX, ow.player.cellY = "ROUTE_1", 7, 8
|
||||
ow.player.facing, ow.player.surfing = "left", true
|
||||
local snapshot, code, message = checkpoints:capture(game)
|
||||
T.check(snapshot ~= nil, "stable overworld captures: " .. tostring(code or message))
|
||||
T.eq(snapshot.format, 1, "checkpoint format is explicit")
|
||||
T.eq(snapshot.kind, "overworld", "checkpoint runtime kind is explicit")
|
||||
T.same(snapshot.identity, { gameVersion = "red", playthroughId = "play-a" },
|
||||
"checkpoint carries compatibility identity")
|
||||
T.same(snapshot.runtime.overworld,
|
||||
{ map = "ROUTE_1", x = 7, y = 8, facing = "left", surfing = true },
|
||||
"checkpoint carries exact semantic overworld position")
|
||||
T.eq(snapshot.save.player.map, "ROUTE_1",
|
||||
"captured progress is synchronized from the live controller")
|
||||
T.eq(snapshot.save.options, nil, "global settings are excluded from progress rewind")
|
||||
|
||||
snapshot.save.money = 1
|
||||
snapshot.runtime.overworld.x = 1
|
||||
T.eq(game.save.money, 3000, "mutating a checkpoint cannot mutate live progress")
|
||||
T.eq(ow.player.cellX, 7, "mutating a checkpoint cannot move the live player")
|
||||
|
||||
-- Recapture the unmodified canonical A used for the differential roundtrip.
|
||||
snapshot = checkpoints:capture(game)
|
||||
local original = snapshot
|
||||
|
||||
game.save.money = 999999
|
||||
game.save.flags.GOT_STARTER = nil
|
||||
game.save.party[1].hp = 1
|
||||
game.save.options.volume = 9
|
||||
ow.map.id, ow.player.cellX, ow.player.cellY = "PALLET_TOWN", 2, 3
|
||||
ow.player.facing, ow.player.surfing = "up", false
|
||||
|
||||
local restored, restoreCode, restoreMessage = checkpoints:restore(game, original)
|
||||
T.check(restored == true,
|
||||
"valid checkpoint restores: " .. tostring(restoreCode or restoreMessage))
|
||||
local recaptured = checkpoints:capture(game)
|
||||
T.same(recaptured, original,
|
||||
"capture A, mutate B, restore A, capture A2 yields normalized A == A2")
|
||||
T.eq(game.save.options.volume, 9,
|
||||
"checkpoint restoration preserves current global settings")
|
||||
T.check(game.lastEnterOpts and game.lastEnterOpts.checkpoint == true,
|
||||
"engine reconstruction is marked to suppress map-entry side effects")
|
||||
|
||||
-- Compatibility and schema failures occur before any mutation.
|
||||
local beforeRejected = checkpoints:capture(game)
|
||||
local wrongFormat = checkpoints:capture(game)
|
||||
wrongFormat.format = 99
|
||||
restored, restoreCode = checkpoints:restore(game, wrongFormat)
|
||||
T.check(not restored and restoreCode == "unsupported_format",
|
||||
"unknown checkpoint format is rejected")
|
||||
|
||||
local wrongGame = checkpoints:capture(game)
|
||||
wrongGame.identity.gameVersion = "blue"
|
||||
restored, restoreCode = checkpoints:restore(game, wrongGame)
|
||||
T.check(not restored and restoreCode == "wrong_game",
|
||||
"another game version is rejected")
|
||||
|
||||
local wrongProfile = checkpoints:capture(game)
|
||||
wrongProfile.identity.playthroughId = "play-b"
|
||||
restored, restoreCode = checkpoints:restore(game, wrongProfile)
|
||||
T.check(not restored and restoreCode == "wrong_playthrough",
|
||||
"another playthrough is rejected")
|
||||
|
||||
local badMap = checkpoints:capture(game)
|
||||
badMap.runtime.overworld.map = "MISSING_MAP"
|
||||
badMap.save.player.map = "MISSING_MAP"
|
||||
restored, restoreCode = checkpoints:restore(game, badMap)
|
||||
T.check(not restored and restoreCode == "invalid_map",
|
||||
"unknown content reference is rejected")
|
||||
T.same(checkpoints:capture(game), beforeRejected,
|
||||
"validation failures leave the live state unchanged")
|
||||
|
||||
-- A reconstruction exception rolls back to the exact pre-operation state.
|
||||
local target = checkpoints:capture(game)
|
||||
target.runtime.overworld.map = "BROKEN"
|
||||
target.runtime.overworld.x, target.runtime.overworld.y = 1, 1
|
||||
target.save.player.map = "BROKEN"
|
||||
target.save.player.x, target.save.player.y = 1, 1
|
||||
target.save.money = 42
|
||||
local beforeFailure = checkpoints:capture(game)
|
||||
game.failNextEnter = true
|
||||
restored, restoreCode = checkpoints:restore(game, target)
|
||||
T.check(not restored and restoreCode == "restore_failed",
|
||||
"reconstruction exception is returned as a structured failure")
|
||||
T.same(checkpoints:capture(game), beforeFailure,
|
||||
"failed reconstruction rolls back the complete pre-operation checkpoint")
|
||||
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
Runtime.currentMod = nil
|
||||
_G.MOD_CHECKPOINTS = nil
|
||||
|
||||
T.finish()
|
||||
Reference in New Issue
Block a user