mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-20 20:50:21 +02:00
Merge pull request #1076 from MaxTomahawk/feat/mod-title-checkpoint-resume
feat(mods): resume selected checkpoints from title
This commit is contained in:
+36
-2
@@ -231,7 +231,18 @@ local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
|
|||||||
|
|
||||||
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
|
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
|
||||||
version is compatibility metadata; physical launcher-slot and path identity stays
|
version is compatibility metadata; physical launcher-slot and path identity stays
|
||||||
private.
|
private. A title-selected context may additionally contain `normalSavedAt`, the
|
||||||
|
validated matching ordinary-save chronology only; it never exposes normal-save
|
||||||
|
progress or a slot/path handle.
|
||||||
|
|
||||||
|
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
|
Values must be tables containing serializable data only. Keys are conservative
|
||||||
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem
|
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem
|
||||||
@@ -250,6 +261,11 @@ if capability.canCapture then
|
|||||||
end
|
end
|
||||||
|
|
||||||
local ok, code, message = mod.checkpoints:restore(game, checkpoint)
|
local ok, code, message = mod.checkpoints:restore(game, checkpoint)
|
||||||
|
|
||||||
|
-- After the tool has durably committed its first checkpoint, make a
|
||||||
|
-- never-saved playthrough reachable through ordinary title boot exactly once.
|
||||||
|
local anchored, anchorCode, anchorMessage =
|
||||||
|
mod.checkpoints:ensureNormalSave(game, checkpoint)
|
||||||
```
|
```
|
||||||
|
|
||||||
Checkpoint format 1 supports settled overworld control and proven battle
|
Checkpoint format 1 supports settled overworld control and proven battle
|
||||||
@@ -296,7 +312,25 @@ private state. A mod that deliberately stores progress-coupled truth in
|
|||||||
cannot distinguish it safely from independent history, configuration, or cache
|
cannot distinguish it safely from independent history, configuration, or cache
|
||||||
data.
|
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 rewrites a normal Pokémon save. It is unavailable outside title and does
|
||||||
|
not broaden capture or arbitrary-frame support.
|
||||||
|
|
||||||
|
`mod.checkpoints:ensureNormalSave(game, checkpoint)` is a separate live-runtime
|
||||||
|
operation for durable checkpoint tools. It creates ordinary progress only when
|
||||||
|
none exists, only after validating that the supplied checkpoint is the exact
|
||||||
|
current safe runtime, and through the normal atomic save lifecycle. Once an
|
||||||
|
ordinary save exists it returns `true, "already_exists"` without writing, so
|
||||||
|
subsequent checkpoints and the player's later SAVE commands remain independent.
|
||||||
|
Call it only after the tool's own checkpoint/index commit; treat an anchoring
|
||||||
|
failure as a failed first checkpoint rather than claiming restart safety.
|
||||||
|
See RFC 0003, RFC 0004, RFC 0005, and RFC 0006 for exact contracts and error
|
||||||
|
codes.
|
||||||
|
|
||||||
## Developer console
|
## Developer console
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# 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. A durable checkpoint tool may
|
||||||
|
explicitly create one ordinary progress anchor after its first checkpoint has
|
||||||
|
committed; later tool writes must remain independent. 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. Generic public capabilities are required; a tool must not use
|
||||||
|
private storage paths, slot ids, or simulate the player's SAVE menu flow.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
Its detached context can include only `normalSavedAt` from a matching ordinary
|
||||||
|
save, so title tools can apply their own resume policy without receiving the
|
||||||
|
canonical normal-save record.
|
||||||
|
|
||||||
|
### `mod.checkpoints:ensureNormalSave(game, checkpoint)`
|
||||||
|
|
||||||
|
Available only at a live checkpoint-safe boundary. After a tool has durably
|
||||||
|
committed the supplied current checkpoint, it may request an ordinary progress
|
||||||
|
anchor for a playthrough that has never had one. The engine validates the
|
||||||
|
checkpoint, proves it exactly matches a fresh capture of the live runtime, and
|
||||||
|
uses the normal atomic save path including `save.write` lifecycle/veto hooks.
|
||||||
|
|
||||||
|
The operation is idempotent. It returns `true, "already_exists"` without writing
|
||||||
|
when matching normal progress already exists, so later checkpoints never move
|
||||||
|
the vanilla CONTINUE target. A stale/non-current checkpoint, unsafe runtime,
|
||||||
|
write veto/failure, or failed readback returns a structured failure. A tool
|
||||||
|
should call it only after its own checkpoint and index are durable and must not
|
||||||
|
report that first checkpoint as successful if the required anchor fails.
|
||||||
|
|
||||||
|
### `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 rewrites 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, creates and
|
||||||
|
readback-verifies exactly one normal anchor, proves subsequent calls do not
|
||||||
|
rewrite it, simulates title/restart, reads the selected binding without
|
||||||
|
allocating title identity, resumes an overworld checkpoint, preserves options,
|
||||||
|
differentially recaptures, and confirms a later explicit NEW GAME receives
|
||||||
|
another identity. A separate two-process disk test proves cold-start routing and
|
||||||
|
reconstruction. Existing no-mod, storage, checkpoint, battle, and title suites
|
||||||
|
prove additive parity.
|
||||||
@@ -77,6 +77,8 @@ run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_
|
|||||||
run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua
|
run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua
|
||||||
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
|
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
|
||||||
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
|
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
|
||||||
|
run_tier "T4 title checkpoint cold restart" \
|
||||||
|
bash tests/integration/title_checkpoint_cold_start.sh
|
||||||
|
|
||||||
# The modded-link desync suite (symmetric mod, handshake fail-closed,
|
# The modded-link desync suite (symmetric mod, handshake fail-closed,
|
||||||
# extra-bag round trip) is ROM-free and runs inside the T4 tier above, as
|
# extra-bag round trip) is ROM-free and runs inside the T4 tier above, as
|
||||||
|
|||||||
+130
-9
@@ -257,7 +257,7 @@ end
|
|||||||
|
|
||||||
local FACINGS = { up = true, down = true, left = true, right = true }
|
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
|
if type(checkpoint) ~= "table" then
|
||||||
return nil, "invalid_checkpoint", "Checkpoint root must be a table."
|
return nil, "invalid_checkpoint", "Checkpoint root must be a table."
|
||||||
end
|
end
|
||||||
@@ -275,13 +275,16 @@ local function validate(game, checkpoint)
|
|||||||
end
|
end
|
||||||
local identity = copy.identity
|
local identity = copy.identity
|
||||||
local current = game and game.save
|
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"
|
if type(identity) ~= "table" or type(identity.engineVersion) ~= "string"
|
||||||
or type(identity.gameVersion) ~= "string"
|
or type(identity.gameVersion) ~= "string"
|
||||||
or type(identity.playthroughId) ~= "string" then
|
or type(identity.playthroughId) ~= "string" then
|
||||||
return nil, "invalid_checkpoint", "Checkpoint identity is missing or corrupt."
|
return nil, "invalid_checkpoint", "Checkpoint identity is missing or corrupt."
|
||||||
end
|
end
|
||||||
if identity.gameVersion ~= current.version then
|
if identity.gameVersion ~= currentVersion then
|
||||||
return nil, "wrong_game", "Checkpoint belongs to another game version."
|
return nil, "wrong_game", "Checkpoint belongs to another game version."
|
||||||
end
|
end
|
||||||
if identity.playthroughId ~= currentId then
|
if identity.playthroughId ~= currentId then
|
||||||
@@ -394,6 +397,49 @@ local function firstDifference(a, b, path)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local emitRestored
|
||||||
|
|
||||||
|
-- Persist the current verified checkpoint as the ordinary progress anchor only
|
||||||
|
-- when this playthrough has never had one. This is intentionally idempotent:
|
||||||
|
-- durable checkpoint tools can make a first session resumable without turning
|
||||||
|
-- every later checkpoint into a hidden normal SAVE. The live runtime must still
|
||||||
|
-- match the supplied checkpoint, and the ordinary save.write veto/lifecycle
|
||||||
|
-- remains authoritative through Game:writeSave().
|
||||||
|
function Checkpoint.ensureNormalSave(game, checkpoint, injectedFs)
|
||||||
|
local capability = Checkpoint.inspect(game)
|
||||||
|
if not capability.canCapture 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 info, infoCode, infoMessage =
|
||||||
|
SaveData.selectedNormalSaveInfo(game.save, injectedFs)
|
||||||
|
if not info then return false, infoCode, infoMessage end
|
||||||
|
if info.exists then return true, "already_exists" end
|
||||||
|
|
||||||
|
local current, captureCode, captureMessage = Checkpoint.capture(game)
|
||||||
|
if not current then return false, captureCode, captureMessage end
|
||||||
|
if not equalData(current, validated) then
|
||||||
|
return false, "checkpoint_not_current",
|
||||||
|
"The active runtime changed after this checkpoint was captured."
|
||||||
|
end
|
||||||
|
if type(game.writeSave) ~= "function" then
|
||||||
|
return false, "save_unavailable",
|
||||||
|
"The active runtime cannot persist ordinary progress."
|
||||||
|
end
|
||||||
|
local ok, saved = pcall(game.writeSave, game)
|
||||||
|
if not ok or saved == false then
|
||||||
|
return false, "save_failed", "Could not create the first ordinary progress save."
|
||||||
|
end
|
||||||
|
local verified = SaveData.selectedNormalSaveInfo(game.save, injectedFs)
|
||||||
|
if type(verified) ~= "table" or not verified.exists then
|
||||||
|
return false, "save_verify_failed",
|
||||||
|
"The first ordinary progress save could not be verified."
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
function Checkpoint.restore(game, checkpoint)
|
function Checkpoint.restore(game, checkpoint)
|
||||||
local capability = Checkpoint.inspect(game)
|
local capability = Checkpoint.inspect(game)
|
||||||
if not capability.canRestore then
|
if not capability.canRestore then
|
||||||
@@ -411,12 +457,7 @@ function Checkpoint.restore(game, checkpoint)
|
|||||||
local restored, verifyCode = Checkpoint.capture(game)
|
local restored, verifyCode = Checkpoint.capture(game)
|
||||||
if restored and validated.rng == nil then restored.rng = nil end
|
if restored and validated.rng == nil then restored.rng = nil end
|
||||||
if restored and equalData(restored, validated) then
|
if restored and equalData(restored, validated) then
|
||||||
if ModRuntime.wants("checkpoint.restored") then
|
emitRestored(game, validated)
|
||||||
ModRuntime.emit("checkpoint.restored", {
|
|
||||||
game = game,
|
|
||||||
kind = validated.kind,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
err = restored and ("restored state differed at "
|
err = restored and ("restored state differed at "
|
||||||
@@ -432,4 +473,84 @@ function Checkpoint.restore(game, checkpoint)
|
|||||||
return false, "restore_failed", "Checkpoint restoration failed: " .. tostring(err)
|
return false, "restore_failed", "Checkpoint restoration failed: " .. tostring(err)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
emitRestored = function(game, checkpoint)
|
||||||
|
if ModRuntime.wants("checkpoint.restored") then
|
||||||
|
ModRuntime.emit("checkpoint.restored", {
|
||||||
|
game = game,
|
||||||
|
kind = checkpoint.kind,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
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 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
|
return Checkpoint
|
||||||
|
|||||||
+81
-3
@@ -766,6 +766,15 @@ local function tryMigrateLegacy(version, fs)
|
|||||||
local opts = SaveData.loadOptions(fs)
|
local opts = SaveData.loadOptions(fs)
|
||||||
opts.saveSlots = opts.saveSlots or {}
|
opts.saveSlots = opts.saveSlots or {}
|
||||||
opts.saveSlots[version] = { list = { id }, active = id }
|
opts.saveSlots[version] = { list = { id }, active = id }
|
||||||
|
-- A tool may have allocated the legacy scope before the player made their
|
||||||
|
-- first ordinary SAVE. Promoting that flat save into slot1 must preserve the
|
||||||
|
-- same opaque identity; otherwise title-selected mod storage becomes
|
||||||
|
-- unreachable after the migration even though every durable record exists.
|
||||||
|
local ids = opts.playthroughIds and opts.playthroughIds[version]
|
||||||
|
if type(ids) == "table" and type(ids.legacy) == "string" and ids.legacy ~= "" then
|
||||||
|
if type(ids[id]) ~= "string" or ids[id] == "" then ids[id] = ids.legacy end
|
||||||
|
ids.legacy = nil
|
||||||
|
end
|
||||||
SaveData.saveOptions(opts, fs)
|
SaveData.saveOptions(opts, fs)
|
||||||
return id
|
return id
|
||||||
end
|
end
|
||||||
@@ -791,9 +800,9 @@ end
|
|||||||
|
|
||||||
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
|
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
|
||||||
-- the version, falling back to the flat legacy names when no slot is in use.
|
-- the version, falling back to the flat legacy names when no slot is in use.
|
||||||
function saveNames(version)
|
function saveNames(version, injectedFs)
|
||||||
version = version or GameVersion.get()
|
version = version or GameVersion.get()
|
||||||
local fs = persistFs(nil)
|
local fs = persistFs(injectedFs)
|
||||||
ensureVersionSlots(version, fs)
|
ensureVersionSlots(version, fs)
|
||||||
local slot = activeSlotCache[version]
|
local slot = activeSlotCache[version]
|
||||||
if slot then return slotNames(version, slot) end
|
if slot then return slotNames(version, slot) end
|
||||||
@@ -1081,7 +1090,17 @@ local function rememberPlaythroughId(save, opts, injectedFs)
|
|||||||
if type(id) ~= "string" or id == "" then return opts, false end
|
if type(id) ~= "string" or id == "" then return opts, false end
|
||||||
local version = save.version or GameVersion.get()
|
local version = save.version or GameVersion.get()
|
||||||
local scope = playthroughScope(version, injectedFs)
|
local scope = playthroughScope(version, injectedFs)
|
||||||
opts = opts or SaveData.loadOptions(injectedFs)
|
local persisted = SaveData.loadOptions(injectedFs)
|
||||||
|
if opts then
|
||||||
|
-- Slot selection and opaque playthrough routing are engine-owned launcher
|
||||||
|
-- state. A live game may carry an options snapshot from before a legacy
|
||||||
|
-- save was promoted to slot1; writing that stale snapshot must not erase
|
||||||
|
-- the freshly persisted routing and strand tool storage on next boot.
|
||||||
|
opts.saveSlots = deepCopy(persisted.saveSlots)
|
||||||
|
opts.playthroughIds = deepCopy(persisted.playthroughIds)
|
||||||
|
else
|
||||||
|
opts = persisted
|
||||||
|
end
|
||||||
opts.playthroughIds = opts.playthroughIds or {}
|
opts.playthroughIds = opts.playthroughIds or {}
|
||||||
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
|
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
|
||||||
local changed = opts.playthroughIds[version][scope] ~= id
|
local changed = opts.playthroughIds[version][scope] ~= id
|
||||||
@@ -1116,6 +1135,65 @@ function SaveData.ensurePlaythroughId(save, injectedFs)
|
|||||||
return id
|
return id
|
||||||
end
|
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
|
||||||
|
|
||||||
|
-- Resolve the selected scope first. That may perform the one-time legacy
|
||||||
|
-- save-to-slot migration, which also moves the opaque identity mapping; only
|
||||||
|
-- then read options so this lookup never observes the pre-migration table.
|
||||||
|
local scope = playthroughScope(version, injectedFs)
|
||||||
|
local opts = SaveData.loadOptions(injectedFs)
|
||||||
|
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
|
||||||
|
id = byVersion and byVersion[scope] 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
|
||||||
|
|
||||||
|
-- Read only the chronology of the ordinary selected save for title tools.
|
||||||
|
-- This intentionally returns no canonical progress, slot id, path, or raw
|
||||||
|
-- save handle. A legacy pre-id normal save is valid when the selected scope's
|
||||||
|
-- engine-owned mapping identifies it; a stamped id must match exactly.
|
||||||
|
function SaveData.selectedNormalSaveInfo(save, injectedFs)
|
||||||
|
local playthroughId, code, message = SaveData.selectedPlaythroughId(save, injectedFs)
|
||||||
|
if not playthroughId then return nil, code, message end
|
||||||
|
local version = save and (save.version or GameVersion.get())
|
||||||
|
local fs = persistFs(injectedFs)
|
||||||
|
local main, backup, staged = saveNames(version, injectedFs)
|
||||||
|
local normal = readTable(fs, main)
|
||||||
|
or readTable(fs, staged)
|
||||||
|
or readTable(fs, backup)
|
||||||
|
if type(normal) ~= "table" or normal.version ~= version then
|
||||||
|
return { exists = false, savedAt = nil }
|
||||||
|
end
|
||||||
|
local normalId = normal.meta and normal.meta.playthroughId
|
||||||
|
if type(normalId) == "string" and normalId ~= "" and normalId ~= playthroughId then
|
||||||
|
return { exists = false, savedAt = nil }
|
||||||
|
end
|
||||||
|
local savedAt = normal.meta and normal.meta.savedAt
|
||||||
|
if type(savedAt) ~= "number" or savedAt < 0 or savedAt ~= savedAt
|
||||||
|
or savedAt == math.huge or savedAt == -math.huge then
|
||||||
|
savedAt = nil
|
||||||
|
end
|
||||||
|
return { exists = true, savedAt = savedAt }
|
||||||
|
end
|
||||||
|
|
||||||
-- ------- meta
|
-- ------- meta
|
||||||
|
|
||||||
-- the version/engine/mod-set stamp every v2 save carries; mods is the
|
-- the version/engine/mod-set stamp every v2 save carries; mods is the
|
||||||
|
|||||||
@@ -951,6 +951,7 @@ function Loader:_api(mod)
|
|||||||
-- callers never receive paths or a raw filesystem handle.
|
-- callers never receive paths or a raw filesystem handle.
|
||||||
storage = {
|
storage = {
|
||||||
context = function(_, game) return storage:context(game) end,
|
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,
|
write = function(_, game, key, value) return storage:write(game, key, value) end,
|
||||||
read = function(_, game, key) return storage:read(game, key) end,
|
read = function(_, game, key) return storage:read(game, key) end,
|
||||||
list = function(_, game, prefix) return storage:list(game, prefix) end,
|
list = function(_, game, prefix) return storage:list(game, prefix) end,
|
||||||
@@ -964,6 +965,12 @@ function Loader:_api(mod)
|
|||||||
restore = function(_, game, checkpoint)
|
restore = function(_, game, checkpoint)
|
||||||
return Checkpoint.restore(game, checkpoint)
|
return Checkpoint.restore(game, checkpoint)
|
||||||
end,
|
end,
|
||||||
|
resume = function(_, game, checkpoint)
|
||||||
|
return Checkpoint.resume(game, checkpoint)
|
||||||
|
end,
|
||||||
|
ensureNormalSave = function(_, game, checkpoint)
|
||||||
|
return Checkpoint.ensureNormalSave(game, checkpoint, loader.fs)
|
||||||
|
end,
|
||||||
},
|
},
|
||||||
options = {
|
options = {
|
||||||
define = function(_, schema)
|
define = function(_, schema)
|
||||||
|
|||||||
@@ -79,6 +79,64 @@ function Storage:_scope(game)
|
|||||||
base = base, fs = fs }
|
base = base, fs = fs }
|
||||||
end
|
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,
|
||||||
|
}
|
||||||
|
local normal = SaveData.selectedNormalSaveInfo(save, self.injectedFs)
|
||||||
|
if type(normal) == "table" and normal.savedAt ~= nil then
|
||||||
|
context.normalSavedAt = normal.savedAt
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
context = function()
|
||||||
|
local copy = {
|
||||||
|
engineVersion = context.engineVersion,
|
||||||
|
gameVersion = context.gameVersion,
|
||||||
|
playthroughId = context.playthroughId,
|
||||||
|
}
|
||||||
|
if context.normalSavedAt ~= nil then copy.normalSavedAt = context.normalSavedAt end
|
||||||
|
return copy
|
||||||
|
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)
|
function Storage:context(game)
|
||||||
local scope, code, message = self:_scope(game)
|
local scope, code, message = self:_scope(game)
|
||||||
if not scope then return nil, code, message end
|
if not scope then return nil, code, message end
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local phase, root = arg[1], arg[2]
|
||||||
|
assert(phase == "capture" or phase == "resume", "phase must be capture or resume")
|
||||||
|
assert(type(root) == "string" and root ~= "", "test needs a persistence root")
|
||||||
|
|
||||||
|
local function quote(value)
|
||||||
|
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function full(path) return root .. "/" .. path end
|
||||||
|
|
||||||
|
local fs = {}
|
||||||
|
function fs.createDirectory(path)
|
||||||
|
return os.execute("mkdir -p " .. quote(full(path))) == 0
|
||||||
|
end
|
||||||
|
function fs.write(path, body)
|
||||||
|
local parent = path:match("^(.*)/[^/]+$")
|
||||||
|
if parent then assert(fs.createDirectory(parent)) end
|
||||||
|
local handle = assert(io.open(full(path), "wb"))
|
||||||
|
handle:write(body)
|
||||||
|
handle:close()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
function fs.read(path)
|
||||||
|
local handle = io.open(full(path), "rb")
|
||||||
|
if not handle then return nil end
|
||||||
|
local body = handle:read("*a")
|
||||||
|
handle:close()
|
||||||
|
return body
|
||||||
|
end
|
||||||
|
function fs.remove(path)
|
||||||
|
os.remove(full(path))
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
function fs.getInfo(path)
|
||||||
|
if os.execute("test -d " .. quote(full(path))) == 0 then
|
||||||
|
return { type = "directory" }
|
||||||
|
end
|
||||||
|
local handle = io.open(full(path), "rb")
|
||||||
|
if handle then handle:close(); return { type = "file" } end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
function fs.load(path)
|
||||||
|
local body = fs.read(path)
|
||||||
|
if not body then return nil, "no file: " .. path end
|
||||||
|
return load(body, "@" .. path)
|
||||||
|
end
|
||||||
|
function fs.getDirectoryItems(path)
|
||||||
|
local items = {}
|
||||||
|
local pipe = io.popen("find " .. quote(full(path))
|
||||||
|
.. " -mindepth 1 -maxdepth 1 -printf '%f\\n' 2>/dev/null")
|
||||||
|
if pipe then
|
||||||
|
for item in pipe:lines() do items[#items + 1] = item end
|
||||||
|
pipe:close()
|
||||||
|
end
|
||||||
|
table.sort(items)
|
||||||
|
return items
|
||||||
|
end
|
||||||
|
function fs.getSaveDirectory() return root end
|
||||||
|
|
||||||
|
love = require("tests.love_stub")
|
||||||
|
love.filesystem = fs
|
||||||
|
|
||||||
|
local Loader = require("src.mods.Loader")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local SaveSerializer = require("src.core.SaveSerializer")
|
||||||
|
local GameMethods = require("src.core.Game")
|
||||||
|
local StateStack = require("src.core.StateStack")
|
||||||
|
|
||||||
|
local function writeProbe()
|
||||||
|
fs.write("mods/cold_start_probe/manifest.json",
|
||||||
|
'{"id":"cold_start_probe","name":"cold start probe","version":"1.0.0",'
|
||||||
|
.. '"entry":"main.lua","api":2,"profile":"content"}')
|
||||||
|
fs.write("mods/cold_start_probe/main.lua", [[
|
||||||
|
return function(mod)
|
||||||
|
_G.COLD_STORAGE = mod.storage
|
||||||
|
_G.COLD_CHECKPOINTS = mod.checkpoints
|
||||||
|
end
|
||||||
|
]])
|
||||||
|
end
|
||||||
|
|
||||||
|
local function runtime(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
|
||||||
|
if title then function game:makeTitleState() return { screenId = "TitleState" } end end
|
||||||
|
return game
|
||||||
|
end
|
||||||
|
|
||||||
|
writeProbe()
|
||||||
|
SaveData.resetSlotState()
|
||||||
|
local loader = Loader.new({ fs = fs })
|
||||||
|
|
||||||
|
if phase == "capture" then
|
||||||
|
local game = runtime(SaveData.newGame({ version = "red" }), false)
|
||||||
|
loader.game = game
|
||||||
|
assert(loader:load({}) == true)
|
||||||
|
assert(_G.COLD_STORAGE:write(game, "history/index", { newest = "q0001" }))
|
||||||
|
local checkpoint = assert(_G.COLD_CHECKPOINTS:capture(game))
|
||||||
|
assert(_G.COLD_STORAGE:write(game, "history/q0001", checkpoint))
|
||||||
|
local id = assert(game.save.meta.playthroughId)
|
||||||
|
assert(_G.COLD_CHECKPOINTS:ensureNormalSave(game, checkpoint))
|
||||||
|
local normal = assert(SaveData.load("red"))
|
||||||
|
assert(normal.meta.playthroughId == id)
|
||||||
|
fs.write("cold-start-witness.lua", SaveSerializer.encode({ playthroughId = id }))
|
||||||
|
print("cold-start capture persisted")
|
||||||
|
else
|
||||||
|
local title = runtime(SaveData.newGame({ version = "red" }), true)
|
||||||
|
title.save.options = { volume = 7, bindings = {} }
|
||||||
|
loader.game = title
|
||||||
|
assert(loader:load({}) == true)
|
||||||
|
local selected = assert(_G.COLD_STORAGE:selected(title))
|
||||||
|
local witness = assert(SaveSerializer.decode(assert(fs.read("cold-start-witness.lua"))))
|
||||||
|
assert(selected:context().playthroughId == witness.playthroughId)
|
||||||
|
assert(selected:read("history/index").newest == "q0001")
|
||||||
|
local checkpoint = assert(selected:read("history/q0001"))
|
||||||
|
assert(_G.COLD_CHECKPOINTS:resume(title, checkpoint))
|
||||||
|
assert(title.save.meta.playthroughId == witness.playthroughId)
|
||||||
|
assert(title.save.options.volume == 7)
|
||||||
|
assert(SaveSerializer.encode(_G.COLD_CHECKPOINTS:capture(title))
|
||||||
|
== SaveSerializer.encode(checkpoint))
|
||||||
|
local normal = assert(SaveData.load("red"))
|
||||||
|
assert(normal.meta.playthroughId == witness.playthroughId)
|
||||||
|
print("cold-start resume reconstructed")
|
||||||
|
end
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
test_root="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$test_root"' EXIT
|
||||||
|
|
||||||
|
"${LUA:-luajit}" tests/integration/title_checkpoint_cold_start.lua capture "$test_root"
|
||||||
|
"${LUA:-luajit}" tests/integration/title_checkpoint_cold_start.lua resume "$test_root"
|
||||||
|
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
-- 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 non-allocating 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 SaveSerializer = require("src.core.SaveSerializer")
|
||||||
|
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 realFs = love.filesystem
|
||||||
|
|
||||||
|
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
|
||||||
|
mod.events:on("checkpoint.restored", function(ev)
|
||||||
|
_G.MOD_TITLE_RESTORE_COUNT = (_G.MOD_TITLE_RESTORE_COUNT or 0) + 1
|
||||||
|
_G.MOD_TITLE_RESTORE_KIND = ev.kind
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
]],
|
||||||
|
}
|
||||||
|
local fs = memfs(files)
|
||||||
|
love.filesystem = fs
|
||||||
|
-- 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")
|
||||||
|
local nonTitleSelected, nonTitleCode = storage:selected(active)
|
||||||
|
T.check(nonTitleSelected == nil and nonTitleCode == "not_at_title",
|
||||||
|
"selected-playthrough storage cannot be used from active gameplay")
|
||||||
|
|
||||||
|
-- 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")
|
||||||
|
T.check(selected:write("history/title-operation", { allowed = true }) == true,
|
||||||
|
"title binding supports safe same-namespace durable operations")
|
||||||
|
T.same(selected:read("history/title-operation"), { allowed = true },
|
||||||
|
"title durable operation remains scoped to the selected playthrough")
|
||||||
|
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
|
||||||
|
if title then
|
||||||
|
-- The failure-injection path needs the same title recovery contract as a
|
||||||
|
-- real Game without constructing renderer-owned title content.
|
||||||
|
function game:makeTitleState() return { screenId = "TitleState" } end
|
||||||
|
end
|
||||||
|
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")
|
||||||
|
T.check(type(checkpoints and checkpoints.ensureNormalSave) == "function",
|
||||||
|
"public checkpoints expose an idempotent first-save anchor")
|
||||||
|
local normalWrites = 0
|
||||||
|
local writeSave = runtime.writeSave
|
||||||
|
function runtime:writeSave()
|
||||||
|
normalWrites = normalWrites + 1
|
||||||
|
return writeSave(self)
|
||||||
|
end
|
||||||
|
local anchored, anchorCode, anchorMessage =
|
||||||
|
checkpoints:ensureNormalSave(runtime, checkpoint)
|
||||||
|
T.check(anchored == true,
|
||||||
|
"first persisted checkpoint can anchor normal progress: "
|
||||||
|
.. tostring(anchorCode or anchorMessage))
|
||||||
|
T.eq(normalWrites, 1,
|
||||||
|
"first checkpoint creates exactly one normal Pokemon save")
|
||||||
|
local anchoredAgain, againCode = checkpoints:ensureNormalSave(runtime, checkpoint)
|
||||||
|
T.check(anchoredAgain == true and againCode == "already_exists",
|
||||||
|
"later checkpoints leave the established normal save independent")
|
||||||
|
T.eq(normalWrites, 1,
|
||||||
|
"idempotent anchor never rewrites the established normal save")
|
||||||
|
local normalBytes = files["save.lua"]
|
||||||
|
T.check(type(normalBytes) == "string" and normalBytes ~= "",
|
||||||
|
"first checkpoint anchor is durably represented before restart")
|
||||||
|
local anchoredAt = SaveSerializer.decode(normalBytes).meta.savedAt
|
||||||
|
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.eq(SaveData.selectedNormalSaveInfo({
|
||||||
|
version = "red", meta = { playthroughId = originalId },
|
||||||
|
}, fs).savedAt, anchoredAt,
|
||||||
|
"title bootstrap never rewrites the first normal save")
|
||||||
|
T.same(checkpoints:capture(titleRuntime), checkpoint,
|
||||||
|
"bootstrapped overworld differentially recaptures the selected checkpoint")
|
||||||
|
T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1,
|
||||||
|
"a successfully verified title resume emits checkpoint.restored exactly once")
|
||||||
|
T.eq(_G.MOD_TITLE_RESTORE_KIND, "overworld",
|
||||||
|
"title resume lifecycle reports the reconstructed checkpoint kind")
|
||||||
|
|
||||||
|
-- Force a failure after restoreCheckpointSave has already installed the
|
||||||
|
-- checkpoint's canonical save and overworld. Title has no live checkpoint
|
||||||
|
-- rollback, so it must rebuild a clean title session.
|
||||||
|
SaveData.resetSlotState()
|
||||||
|
local failingTitle = makeRuntime(SaveData.newGame({ version = "red" }), true)
|
||||||
|
failingTitle.save.options = { volume = 7, bindings = {} }
|
||||||
|
local restoreCheckpointSave = failingTitle.restoreCheckpointSave
|
||||||
|
function failingTitle:restoreCheckpointSave(loaded)
|
||||||
|
restoreCheckpointSave(self, loaded)
|
||||||
|
error("forced title reconstruction failure")
|
||||||
|
end
|
||||||
|
local failed, failureCode = checkpoints:resume(failingTitle, checkpoint)
|
||||||
|
T.check(failed == false and failureCode == "resume_failed",
|
||||||
|
"failed title reconstruction reports a recoverable bootstrap failure")
|
||||||
|
T.eq(failingTitle.stack:top().screenId, "TitleState",
|
||||||
|
"failed title reconstruction returns to a usable title session")
|
||||||
|
T.check(failingTitle.save.meta.playthroughId == nil,
|
||||||
|
"failed title reconstruction restores the unbound title skeleton")
|
||||||
|
T.eq(failingTitle.save.options.volume, 7,
|
||||||
|
"failed title reconstruction retains current title options")
|
||||||
|
T.eq(SaveData.selectedNormalSaveInfo({
|
||||||
|
version = "red", meta = { playthroughId = originalId },
|
||||||
|
}, fs).savedAt, anchoredAt,
|
||||||
|
"failed title reconstruction never rewrites the normal Pokémon save")
|
||||||
|
T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1,
|
||||||
|
"failed title reconstruction emits no additional restored lifecycle event")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A title policy may compare its own durable checkpoint chronology with the
|
||||||
|
-- ordinary CONTINUE target, but it must never receive that save's contents,
|
||||||
|
-- slot path, or a way to open another playthrough. This fixture writes the
|
||||||
|
-- canonical normal save directly to model an already-completed vanilla SAVE.
|
||||||
|
active.save.meta.savedAt = 4321
|
||||||
|
T.check(SaveData.save(active.save) == true,
|
||||||
|
"fixture updates the selected normal save chronology")
|
||||||
|
SaveData.resetSlotState()
|
||||||
|
local titleWithNormalSave = {
|
||||||
|
save = SaveData.newGame({ version = "red" }),
|
||||||
|
stack = { states = { { screenId = "TitleState" } } },
|
||||||
|
}
|
||||||
|
local selectedWithNormal, normalCode, normalMessage = storage:selected(titleWithNormalSave)
|
||||||
|
T.check(type(selectedWithNormal) == "table",
|
||||||
|
"legacy-to-slot migration keeps the selected playthrough identity: "
|
||||||
|
.. tostring(normalCode or normalMessage))
|
||||||
|
if type(selectedWithNormal) == "table" then
|
||||||
|
T.eq(selectedWithNormal:context().normalSavedAt, 4321,
|
||||||
|
"title selected context exposes only matching normal-save chronology")
|
||||||
|
end
|
||||||
|
T.check(titleWithNormalSave.save.meta.playthroughId == nil,
|
||||||
|
"normal-save chronology lookup does not bind the fresh title skeleton")
|
||||||
|
|
||||||
|
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
|
||||||
|
_G.MOD_TITLE_RESTORE_COUNT = nil
|
||||||
|
_G.MOD_TITLE_RESTORE_KIND = nil
|
||||||
|
SaveData.resetSlotState()
|
||||||
|
SaveData.loadOptions = originalLoadOptions
|
||||||
|
love.filesystem = realFs
|
||||||
|
|
||||||
|
T.finish()
|
||||||
Reference in New Issue
Block a user