diff --git a/src/core/Checkpoint.lua b/src/core/Checkpoint.lua new file mode 100644 index 00000000..b29caba8 --- /dev/null +++ b/src/core/Checkpoint.lua @@ -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 diff --git a/src/core/Game.lua b/src/core/Game.lua index b8b452d0..527f4fc5 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -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 diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 0ce9c314..daca6548 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -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") diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index d6fcdb56..5a7bd65b 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -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 diff --git a/tests/modkit/cases/checkpoints.lua b/tests/modkit/cases/checkpoints.lua new file mode 100644 index 00000000..0feeed1c --- /dev/null +++ b/tests/modkit/cases/checkpoints.lua @@ -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() diff --git a/tests/mod_storage_tests.lua b/tests/modkit/cases/storage.lua similarity index 100% rename from tests/mod_storage_tests.lua rename to tests/modkit/cases/storage.lua