mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
feat(mods): resume selected checkpoints from title
This commit is contained in:
+19
-1
@@ -210,6 +210,15 @@ local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
|
||||
version is compatibility metadata; physical launcher-slot and path identity stays
|
||||
private.
|
||||
|
||||
At the title screen only, `mod.storage:selected(game)` returns a bound storage
|
||||
facade for the launcher-selected existing playthrough, or `nil, code, message`.
|
||||
Resolving this facade is read-only: it never allocates an identity, adopts a
|
||||
fresh New Game, or exposes a slot id/path. Its `context()`, `read(key)`,
|
||||
`write(key, value)`, `list(prefix)`, and `delete(key)` methods have the same
|
||||
data-only and transaction contract as `mod.storage`, but remain restricted to
|
||||
the calling mod's selected existing namespace. It is intended for title tools
|
||||
that need to browse or manage durable history before the first normal SAVE.
|
||||
|
||||
Values must be tables containing serializable data only. Keys are conservative
|
||||
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem
|
||||
handles are never exposed. Writes are staged and decode-verified, reads recover
|
||||
@@ -270,7 +279,16 @@ private state. A mod that deliberately stores progress-coupled truth in
|
||||
cannot distinguish it safely from independent history, configuration, or cache
|
||||
data.
|
||||
|
||||
See RFC 0003, RFC 0004, and RFC 0005 for exact contracts and error codes.
|
||||
`mod.checkpoints:resume(game, checkpoint)` is the title-session counterpart to
|
||||
live `restore`. It validates the same data-only checkpoint against the
|
||||
engine-selected existing playthrough, reconstructs only after all validation
|
||||
passes, preserves current options, and verifies by recapture. A title session
|
||||
has no live gameplay rollback state: if reconstruction or verification fails,
|
||||
the engine rebuilds a usable title session and returns `false, code, message`.
|
||||
It never writes a normal Pokémon save. It is unavailable outside title and does
|
||||
not broaden capture or arbitrary-frame support.
|
||||
See RFC 0003, RFC 0004, RFC 0005, and RFC 0006 for exact contracts and error
|
||||
codes.
|
||||
|
||||
## Developer console
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# RFC 0006 — Selected title playthrough storage and checkpoint resume
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `SaveData.lua`, `Storage.lua`, `Checkpoint.lua`, and
|
||||
`Loader.lua`. Tests: `title_playthrough_context.lua`, existing storage,
|
||||
checkpoint, title, save-slot, and no-mod parity suites.
|
||||
|
||||
## Motivation
|
||||
|
||||
A tool checkpoint may be the first durable record of a new playthrough. The
|
||||
engine intentionally keeps normal Pokémon SAVE independent: before the first
|
||||
normal write, identity is retained by the engine-owned selected-slot mapping,
|
||||
while title starts with a fresh New Game skeleton. Calling ordinary active
|
||||
`mod.storage` there would allocate/adopt an identity, and live
|
||||
`mod.checkpoints:restore` correctly refuses title because it has no gameplay
|
||||
rollback state. A generic title capability is required; a tool must not use
|
||||
private storage paths, slot ids, or a hidden normal SAVE.
|
||||
|
||||
## Additive public API
|
||||
|
||||
### `mod.storage:selected(game)`
|
||||
|
||||
Available only while the engine is in a title session. Returns an opaque bound
|
||||
facade or `nil, code, message`:
|
||||
|
||||
```lua
|
||||
local selected = mod.storage:selected(game)
|
||||
local context = selected:context()
|
||||
local history = selected:read("history/index")
|
||||
```
|
||||
|
||||
The facade exposes `context()`, `read(key)`, `write(key, value)`,
|
||||
`list(prefix)`, and `delete(key)`. It is bound internally to the launcher-
|
||||
selected existing game-version/playthrough and the calling mod id. It neither
|
||||
accepts an arbitrary playthrough id nor reveals a slot id, filesystem path, or
|
||||
another mod namespace. Resolution is read-only; no selected mapping means
|
||||
`no_selected_playthrough`, and opening a title browser never mints an identity.
|
||||
|
||||
### `mod.checkpoints:resume(game, checkpoint)`
|
||||
|
||||
Available only from title. It validates format, data-only structure, selected
|
||||
game/playthrough identity, canonical save/content, overworld/battle runtime, and
|
||||
RNG exactly as `restore` does. It then reconstructs semantic overworld or a
|
||||
supported battle continuation, preserves current options, and differentially
|
||||
recaptures before committing. On success it emits `checkpoint.restored` once.
|
||||
|
||||
Title has no live runtime rollback. A reconstruction or verification failure
|
||||
therefore rebuilds a clean title session from the pre-operation title save and
|
||||
RNG; it emits no success event and never writes normal progress. Validation
|
||||
failure leaves the existing title session untouched. Stable errors include
|
||||
`not_at_title`, `no_selected_playthrough`, normal checkpoint validation codes,
|
||||
`resume_failed`, and `title_recovery_failed`.
|
||||
|
||||
## Isolation and migration
|
||||
|
||||
Explicit NEW GAME retains its existing fresh-identity rule. It does not reuse a
|
||||
previous selected mapping and cannot see old tool history. Existing mods change
|
||||
nothing: no identity, storage, title reconstruction, or event is created unless
|
||||
the new methods are called. `mod.storage` remains independent durable data and
|
||||
does not rewind with a checkpoint; canonical `game.save` / `mod.save` does.
|
||||
|
||||
## Verification
|
||||
|
||||
The public SDK test starts a fresh playthrough, stores tool history without a
|
||||
normal SAVE, simulates title/restart, reads the selected binding without
|
||||
allocating title identity, resumes an overworld checkpoint, preserves options,
|
||||
performs no normal SAVE, differentially recaptures, and confirms a later
|
||||
explicit NEW GAME receives another identity. Existing no-mod, storage,
|
||||
checkpoint, battle, and title suites prove additive parity.
|
||||
+83
-3
@@ -246,7 +246,7 @@ end
|
||||
|
||||
local FACINGS = { up = true, down = true, left = true, right = true }
|
||||
|
||||
local function validate(game, checkpoint)
|
||||
local function validate(game, checkpoint, expectedIdentity)
|
||||
if type(checkpoint) ~= "table" then
|
||||
return nil, "invalid_checkpoint", "Checkpoint root must be a table."
|
||||
end
|
||||
@@ -264,13 +264,16 @@ local function validate(game, checkpoint)
|
||||
end
|
||||
local identity = copy.identity
|
||||
local current = game and game.save
|
||||
local currentId = current and current.meta and current.meta.playthroughId
|
||||
local currentId = expectedIdentity and expectedIdentity.playthroughId
|
||||
or (current and current.meta and current.meta.playthroughId)
|
||||
local currentVersion = expectedIdentity and expectedIdentity.gameVersion
|
||||
or (current and current.version)
|
||||
if type(identity) ~= "table" or type(identity.engineVersion) ~= "string"
|
||||
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
|
||||
if identity.gameVersion ~= currentVersion then
|
||||
return nil, "wrong_game", "Checkpoint belongs to another game version."
|
||||
end
|
||||
if identity.playthroughId ~= currentId then
|
||||
@@ -421,4 +424,81 @@ function Checkpoint.restore(game, checkpoint)
|
||||
return false, "restore_failed", "Checkpoint restoration failed: " .. tostring(err)
|
||||
end
|
||||
|
||||
local function isTitleSession(game)
|
||||
local states = game and game.stack and game.stack.states
|
||||
if type(states) ~= "table" then return false end
|
||||
for _, state in ipairs(states) do
|
||||
if type(state) == "table" and state.screenId == "TitleState" then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function emitRestored(game, checkpoint)
|
||||
if ModRuntime.wants("checkpoint.restored") then
|
||||
ModRuntime.emit("checkpoint.restored", { game = game, kind = checkpoint.kind })
|
||||
end
|
||||
end
|
||||
|
||||
local function rebuildTitle(game, savedTitle, rng)
|
||||
local save, err = dataCopy(savedTitle)
|
||||
if not save then error("title rollback decode failed: " .. tostring(err), 0) end
|
||||
game.save = save
|
||||
if type(game.adoptSave) == "function" then game:adoptSave(save) end
|
||||
restoreRng(rng)
|
||||
if not (game.stack and game.stack.top and game.stack.pop and game.stack.push
|
||||
and type(game.makeTitleState) == "function") then
|
||||
error("title recovery is unavailable", 0)
|
||||
end
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
game.stack:push(game:makeTitleState())
|
||||
end
|
||||
|
||||
-- Reconstruct a validated persistent checkpoint from the title session. This
|
||||
-- is intentionally separate from restore(): title has no live gameplay state
|
||||
-- to capture for rollback. Validation happens before any mutation; a failed
|
||||
-- reconstruction rebuilds a fresh usable title session instead of exposing a
|
||||
-- half-installed overworld or battle.
|
||||
function Checkpoint.resume(game, checkpoint)
|
||||
if not isTitleSession(game) then
|
||||
return false, "not_at_title",
|
||||
"Checkpoint resume is available only from the title session."
|
||||
end
|
||||
local save = game and game.save
|
||||
local playthroughId, identityCode, identityMessage =
|
||||
SaveData.selectedPlaythroughId(save)
|
||||
if type(playthroughId) ~= "string" or playthroughId == "" then
|
||||
return false, identityCode, identityMessage
|
||||
end
|
||||
local expected = { gameVersion = save and save.version, playthroughId = playthroughId }
|
||||
local validated, code, message = validate(game, checkpoint, expected)
|
||||
if not validated then return false, code, message end
|
||||
|
||||
local titleSave, titleErr = dataCopy(save)
|
||||
if not titleSave then
|
||||
return false, "title_recovery_unavailable",
|
||||
"Could not preserve the title session: " .. tostring(titleErr)
|
||||
end
|
||||
local titleRng = captureRng()
|
||||
local currentOptions = save.options
|
||||
local ok, err = pcall(apply, game, validated, currentOptions)
|
||||
if ok then
|
||||
local restored, verifyCode = Checkpoint.capture(game)
|
||||
if restored and validated.rng == nil then restored.rng = nil end
|
||||
if restored and equalData(restored, validated) then
|
||||
emitRestored(game, validated)
|
||||
return true
|
||||
end
|
||||
err = restored and ("resumed state differed at "
|
||||
.. tostring(firstDifference(validated, restored) or "canonical encoding"))
|
||||
or ("resumed state could not be captured: " .. tostring(verifyCode))
|
||||
end
|
||||
|
||||
local recovered, recoveryErr = pcall(rebuildTitle, game, titleSave, titleRng)
|
||||
if not recovered then
|
||||
return false, "title_recovery_failed",
|
||||
"Checkpoint resume failed and title recovery failed: " .. tostring(recoveryErr)
|
||||
end
|
||||
return false, "resume_failed", "Checkpoint resume failed: " .. tostring(err)
|
||||
end
|
||||
|
||||
return Checkpoint
|
||||
|
||||
@@ -967,6 +967,33 @@ function SaveData.ensurePlaythroughId(save, injectedFs)
|
||||
return id
|
||||
end
|
||||
|
||||
-- Resolve the already-selected playthrough without changing the supplied save
|
||||
-- or allocating a replacement id. Title tools use this before a normal SAVE:
|
||||
-- Game:load intentionally owns a fresh skeleton there, while the selected
|
||||
-- launcher slot's durable tool data remains bound by the engine-owned mapping.
|
||||
-- This does not make arbitrary identities addressable; callers still need a
|
||||
-- higher-level engine capability that decides when selected resolution is safe.
|
||||
function SaveData.selectedPlaythroughId(save, injectedFs)
|
||||
if type(save) ~= "table" then
|
||||
return nil, "not_in_playthrough", "No selected playthrough is available."
|
||||
end
|
||||
local version = save.version or GameVersion.get()
|
||||
if not knownVersion(version) then
|
||||
return nil, "unknown_game", "The selected game version is unavailable."
|
||||
end
|
||||
local id = save.meta and save.meta.playthroughId
|
||||
if type(id) == "string" and id ~= "" then return id end
|
||||
|
||||
local opts = SaveData.loadOptions(injectedFs)
|
||||
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
|
||||
id = byVersion and byVersion[playthroughScope(version, injectedFs)] or nil
|
||||
if type(id) ~= "string" or id == "" then
|
||||
return nil, "no_selected_playthrough",
|
||||
"The selected playthrough has no durable tool state."
|
||||
end
|
||||
return id
|
||||
end
|
||||
|
||||
-- ------- meta
|
||||
|
||||
-- the version/engine/mod-set stamp every v2 save carries; mods is the
|
||||
|
||||
@@ -666,6 +666,7 @@ function Loader:_api(mod)
|
||||
-- callers never receive paths or a raw filesystem handle.
|
||||
storage = {
|
||||
context = function(_, game) return storage:context(game) end,
|
||||
selected = function(_, game) return storage:selected(game) end,
|
||||
write = function(_, game, key, value) return storage:write(game, key, value) end,
|
||||
read = function(_, game, key) return storage:read(game, key) end,
|
||||
list = function(_, game, prefix) return storage:list(game, prefix) end,
|
||||
@@ -679,6 +680,9 @@ function Loader:_api(mod)
|
||||
restore = function(_, game, checkpoint)
|
||||
return Checkpoint.restore(game, checkpoint)
|
||||
end,
|
||||
resume = function(_, game, checkpoint)
|
||||
return Checkpoint.resume(game, checkpoint)
|
||||
end,
|
||||
},
|
||||
options = {
|
||||
define = function(_, schema)
|
||||
|
||||
@@ -79,6 +79,56 @@ function Storage:_scope(game)
|
||||
base = base, fs = fs }
|
||||
end
|
||||
|
||||
local function isTitleSession(game)
|
||||
local states = game and game.stack and game.stack.states
|
||||
if type(states) ~= "table" then return false end
|
||||
for _, state in ipairs(states) do
|
||||
if type(state) == "table" and state.screenId == "TitleState" then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Bind this mod only to the engine-selected existing playthrough while the
|
||||
-- title session is active. Unlike _scope this must never allocate an identity:
|
||||
-- browsing history before the first normal SAVE is a read of durable state,
|
||||
-- not the start of a New Game. The returned facade closes over its private
|
||||
-- proxy game, so callers cannot substitute another playthrough id or path.
|
||||
function Storage:selected(game)
|
||||
if not isTitleSession(game) then
|
||||
return failure("not_at_title",
|
||||
"Selected playthrough storage is available only from the title session.")
|
||||
end
|
||||
local save = game and game.save
|
||||
local version = save and save.version
|
||||
if not validSegment(version) then
|
||||
return failure("not_in_playthrough",
|
||||
"The title session has no selected game version.")
|
||||
end
|
||||
local playthroughId, code, message =
|
||||
SaveData.selectedPlaythroughId(save, self.injectedFs)
|
||||
if not validSegment(playthroughId) then return failure(code, message) end
|
||||
|
||||
local selectedGame = {
|
||||
save = { version = version, meta = { playthroughId = playthroughId } },
|
||||
}
|
||||
local context = {
|
||||
engineVersion = Version.engine,
|
||||
gameVersion = version,
|
||||
playthroughId = playthroughId,
|
||||
}
|
||||
return {
|
||||
context = function() return {
|
||||
engineVersion = context.engineVersion,
|
||||
gameVersion = context.gameVersion,
|
||||
playthroughId = context.playthroughId,
|
||||
} end,
|
||||
read = function(_, key) return self:read(selectedGame, key) end,
|
||||
write = function(_, key, value) return self:write(selectedGame, key, value) end,
|
||||
list = function(_, prefix) return self:list(selectedGame, prefix) end,
|
||||
delete = function(_, key) return self:delete(selectedGame, key) end,
|
||||
}
|
||||
end
|
||||
|
||||
function Storage:context(game)
|
||||
local scope, code, message = self:_scope(game)
|
||||
if not scope then return nil, code, message end
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
-- A tool can persist a checkpoint before the first normal Pokémon save. After
|
||||
-- a restart the title runtime is deliberately a fresh save skeleton, so it
|
||||
-- needs a read-only binding to the already-selected playthrough -- not a call
|
||||
-- to the normal active-playthrough storage methods, which would mint an id.
|
||||
--
|
||||
-- This is a public SDK contract test. The fixture never reaches into storage
|
||||
-- paths or launcher slot internals.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("mod title playthrough context")
|
||||
local Loader = require("src.mods.Loader")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Version = require("src.core.Version")
|
||||
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 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_TITLE_STORAGE = mod.storage
|
||||
_G.MOD_TITLE_CHECKPOINTS = mod.checkpoints
|
||||
end
|
||||
]],
|
||||
}
|
||||
local fs = memfs(files)
|
||||
-- Production storage and checkpoint resume share the same engine persistence
|
||||
-- backend. Route the test's default SaveData lookup to this fixture backend so
|
||||
-- the restart path exercises that shared mapping rather than host test files.
|
||||
local originalLoadOptions = SaveData.loadOptions
|
||||
SaveData.loadOptions = function(injectedFs)
|
||||
return originalLoadOptions(injectedFs or fs)
|
||||
end
|
||||
local active = { save = SaveData.newGame({ version = "red" }) }
|
||||
local loader = Loader.new({ fs = fs })
|
||||
loader.game = active
|
||||
T.check(loader:load({}) == true, "title-context fixture mod loads")
|
||||
|
||||
local storage = _G.MOD_TITLE_STORAGE
|
||||
T.check(type(storage) == "table", "loader exposes the public storage facade")
|
||||
if type(storage) == "table" then
|
||||
local written, writeCode, writeMessage = storage:write(active, "history/index", {
|
||||
format = 1, newest = "q0001",
|
||||
})
|
||||
T.check(written == true,
|
||||
"a fresh playthrough can durably store tool history: "
|
||||
.. tostring(writeCode or writeMessage))
|
||||
local originalId = active.save.meta and active.save.meta.playthroughId
|
||||
T.check(type(originalId) == "string" and originalId ~= "",
|
||||
"first tool persistence allocates the opaque active playthrough identity")
|
||||
|
||||
-- Simulate a fresh process/title session. The normal save was never written:
|
||||
-- only the engine-owned slot/playthrough mapping and this mod's durable data
|
||||
-- exist. The title skeleton must remain unmodified by browsing.
|
||||
SaveData.resetSlotState()
|
||||
local title = {
|
||||
save = SaveData.newGame({ version = "red" }),
|
||||
stack = {
|
||||
states = { { screenId = "TitleState" } },
|
||||
top = function(self) return self.states[#self.states] end,
|
||||
},
|
||||
}
|
||||
T.check(title.save.meta.playthroughId == nil,
|
||||
"title starts from an unbound fresh skeleton before normal SAVE")
|
||||
T.check(type(storage.selected) == "function",
|
||||
"public storage exposes a read-only selected-playthrough binding at title")
|
||||
|
||||
if type(storage.selected) == "function" then
|
||||
local selected, selectedCode, selectedMessage = storage:selected(title)
|
||||
T.check(type(selected) == "table",
|
||||
"title resolves the selected existing playthrough: "
|
||||
.. tostring(selectedCode or selectedMessage))
|
||||
if type(selected) == "table" then
|
||||
T.same(selected:context(), {
|
||||
engineVersion = Version.engine,
|
||||
gameVersion = "red",
|
||||
playthroughId = originalId,
|
||||
}, "selected binding reports the durable playthrough without exposing a slot path")
|
||||
T.same(selected:read("history/index"), { format = 1, newest = "q0001" },
|
||||
"title reads only this mod's selected-playthrough durable history")
|
||||
end
|
||||
T.check(title.save.meta.playthroughId == nil,
|
||||
"opening title history never allocates or adopts a playthrough identity")
|
||||
end
|
||||
|
||||
local function makeRuntime(save, title)
|
||||
local stack = setmetatable({ states = {} }, { __index = StateStack })
|
||||
local game
|
||||
local overworld = {
|
||||
map = { id = "PALLET_TOWN" },
|
||||
player = { cellX = 3, cellY = 6, facing = "down", surfing = false },
|
||||
scriptMoves = {}, pendingScripts = {}, parallelRunners = {}, parallelQueue = {},
|
||||
runner = { isRunning = function() return false end },
|
||||
}
|
||||
function overworld:captureSave(target)
|
||||
target.player.map, target.player.x, target.player.y = self.map.id,
|
||||
self.player.cellX, self.player.cellY
|
||||
target.player.facing, target.player.surfing = self.player.facing,
|
||||
self.player.surfing and true or false
|
||||
end
|
||||
function overworld:enter(mapId, x, y, facing)
|
||||
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 = save, stack = stack, overworld = overworld,
|
||||
data = {
|
||||
pokemon = {}, moves = { TACKLE = { pp = 35 } }, items = { POTION = {} },
|
||||
constants = { fallbackMove = "TACKLE" },
|
||||
field = { boot = { startMap = "PALLET_TOWN", startX = 3, startY = 6 } },
|
||||
maps = { PALLET_TOWN = { id = "PALLET_TOWN", width = 10, height = 9 } },
|
||||
},
|
||||
}, { __index = GameMethods })
|
||||
stack.states[1] = title and { screenId = "TitleState" } or overworld
|
||||
return game
|
||||
end
|
||||
|
||||
local runtime = makeRuntime(active.save, false)
|
||||
local checkpoints = _G.MOD_TITLE_CHECKPOINTS
|
||||
T.check(type(checkpoints) == "table", "loader exposes the public checkpoint facade")
|
||||
local checkpoint = checkpoints and checkpoints:capture(runtime)
|
||||
T.check(type(checkpoint) == "table",
|
||||
"a fresh playthrough can capture a stable overworld checkpoint")
|
||||
SaveData.resetSlotState()
|
||||
local titleRuntime = makeRuntime(SaveData.newGame({ version = "red" }), true)
|
||||
titleRuntime.save.options = { volume = 9, bindings = {} }
|
||||
T.check(type(checkpoints and checkpoints.resume) == "function",
|
||||
"public checkpoints expose validated title-session resume")
|
||||
if type(checkpoints and checkpoints.resume) == "function" and checkpoint then
|
||||
local resumed, resumeCode, resumeMessage = checkpoints:resume(titleRuntime, checkpoint)
|
||||
T.check(resumed == true,
|
||||
"title resumes the durable checkpoint: " .. tostring(resumeCode or resumeMessage))
|
||||
T.eq(titleRuntime.save.meta.playthroughId, originalId,
|
||||
"title bootstrap retains the checkpoint's original playthrough identity")
|
||||
T.eq(titleRuntime.save.options.volume, 9,
|
||||
"title bootstrap preserves current options rather than rewinding them")
|
||||
T.check(files["save.lua"] == nil,
|
||||
"title bootstrap never creates a normal Pokémon save as a side effect")
|
||||
T.same(checkpoints:capture(titleRuntime), checkpoint,
|
||||
"bootstrapped overworld differentially recaptures the selected checkpoint")
|
||||
end
|
||||
|
||||
local explicitNewGame = SaveData.newGame({ version = "red" })
|
||||
local freshContext = storage:context({ save = explicitNewGame })
|
||||
T.check(freshContext and freshContext.playthroughId ~= originalId,
|
||||
"an explicit New Game receives a distinct identity and cannot inherit old history")
|
||||
end
|
||||
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
Runtime.currentMod = nil
|
||||
_G.MOD_TITLE_STORAGE = nil
|
||||
_G.MOD_TITLE_CHECKPOINTS = nil
|
||||
SaveData.resetSlotState()
|
||||
SaveData.loadOptions = originalLoadOptions
|
||||
|
||||
T.finish()
|
||||
Reference in New Issue
Block a user