feat(mods): expose selected save chronology

This commit is contained in:
MaxTomahawk
2026-08-10 14:47:32 +02:00
parent 2d85c9595d
commit 67491e2dac
5 changed files with 86 additions and 9 deletions
+3 -1
View File
@@ -208,7 +208,9 @@ 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 At the title screen only, `mod.storage:selected(game)` returns a bound storage
facade for the launcher-selected existing playthrough, or `nil, code, message`. facade for the launcher-selected existing playthrough, or `nil, code, message`.
@@ -36,6 +36,9 @@ 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 accepts an arbitrary playthrough id nor reveals a slot id, filesystem path, or
another mod namespace. Resolution is read-only; no selected mapping means another mod namespace. Resolution is read-only; no selected mapping means
`no_selected_playthrough`, and opening a title browser never mints an identity. `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:resume(game, checkpoint)` ### `mod.checkpoints:resume(game, checkpoint)`
+44 -3
View File
@@ -630,6 +630,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
@@ -655,9 +664,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
@@ -984,9 +993,13 @@ function SaveData.selectedPlaythroughId(save, injectedFs)
local id = save.meta and save.meta.playthroughId local id = save.meta and save.meta.playthroughId
if type(id) == "string" and id ~= "" then return id end 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 opts = SaveData.loadOptions(injectedFs)
local byVersion = opts.playthroughIds and opts.playthroughIds[version] local byVersion = opts.playthroughIds and opts.playthroughIds[version]
id = byVersion and byVersion[playthroughScope(version, injectedFs)] or nil id = byVersion and byVersion[scope] or nil
if type(id) ~= "string" or id == "" then if type(id) ~= "string" or id == "" then
return nil, "no_selected_playthrough", return nil, "no_selected_playthrough",
"The selected playthrough has no durable tool state." "The selected playthrough has no durable tool state."
@@ -994,6 +1007,34 @@ function SaveData.selectedPlaythroughId(save, injectedFs)
return id return id
end 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 { savedAt = nil }
end
local normalId = normal.meta and normal.meta.playthroughId
if type(normalId) == "string" and normalId ~= "" and normalId ~= playthroughId then
return { 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 { 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
+13 -5
View File
@@ -116,12 +116,20 @@ function Storage:selected(game)
gameVersion = version, gameVersion = version,
playthroughId = playthroughId, playthroughId = playthroughId,
} }
local normal = SaveData.selectedNormalSaveInfo(save, self.injectedFs)
if type(normal) == "table" and normal.savedAt ~= nil then
context.normalSavedAt = normal.savedAt
end
return { return {
context = function() return { context = function()
engineVersion = context.engineVersion, local copy = {
gameVersion = context.gameVersion, engineVersion = context.engineVersion,
playthroughId = context.playthroughId, gameVersion = context.gameVersion,
} end, 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, read = function(_, key) return self:read(selectedGame, key) end,
write = function(_, key, value) return self:write(selectedGame, key, value) end, write = function(_, key, value) return self:write(selectedGame, key, value) end,
list = function(_, prefix) return self:list(selectedGame, prefix) end, list = function(_, prefix) return self:list(selectedGame, prefix) end,
@@ -13,6 +13,7 @@ local T = require("tests.harness").suite("mod title playthrough context")
local Loader = require("src.mods.Loader") local Loader = require("src.mods.Loader")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local SaveSerializer = require("src.core.SaveSerializer")
local Version = require("src.core.Version") local Version = require("src.core.Version")
local GameMethods = require("src.core.Game") local GameMethods = require("src.core.Game")
local StateStack = require("src.core.StateStack") local StateStack = require("src.core.StateStack")
@@ -227,6 +228,28 @@ if type(storage) == "table" then
"failed title reconstruction never writes a normal Pokémon save") "failed title reconstruction never writes a normal Pokémon save")
end 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
fs.write("save.lua", SaveSerializer.encode(active.save))
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 explicitNewGame = SaveData.newGame({ version = "red" })
local freshContext = storage:context({ save = explicitNewGame }) local freshContext = storage:context({ save = explicitNewGame })
T.check(freshContext and freshContext.playthroughId ~= originalId, T.check(freshContext and freshContext.playthroughId ~= originalId,