mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-05 17:03:12 -04:00
feat: add playthrough-scoped mod storage
This commit is contained in:
@@ -216,6 +216,13 @@ local function persistFs(fs)
|
||||
return SaveData.portableFs() or fs or (love and love.filesystem)
|
||||
end
|
||||
|
||||
-- Engine-owned persistence routing for subsystems that must follow the same
|
||||
-- standard/portable root as saves without exposing raw filesystem access to a
|
||||
-- mod. An explicitly injected headless filesystem still wins for tests.
|
||||
function SaveData.persistenceFs(fs)
|
||||
return persistFs(fs)
|
||||
end
|
||||
|
||||
-- Port + original Options menu defaults. Missing keys on load are filled
|
||||
-- from this table so old options.lua files stay compatible.
|
||||
function SaveData.defaultOptions()
|
||||
|
||||
@@ -569,6 +569,8 @@ end
|
||||
function Loader:_api(mod)
|
||||
local loader = self
|
||||
local modId = mod.manifest.id
|
||||
local Storage = engineRequire("src.mods.Storage")
|
||||
local storage = Storage and Storage.new(modId, loader.fs)
|
||||
local api = {
|
||||
id = modId,
|
||||
version = mod.manifest.version,
|
||||
@@ -658,6 +660,16 @@ function Loader:_api(mod)
|
||||
bucket[key] = value
|
||||
end,
|
||||
},
|
||||
-- Data-only state independent of the vanilla progress checkpoint. The
|
||||
-- engine binds version/playthrough/mod scope and portable persistence;
|
||||
-- callers never receive paths or a raw filesystem handle.
|
||||
storage = {
|
||||
context = function(_, game) return storage:context(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,
|
||||
delete = function(_, game, key) return storage:delete(game, key) end,
|
||||
},
|
||||
options = {
|
||||
define = function(_, schema)
|
||||
assert(type(schema) == "table", "options schema must be a table of rows")
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
-- Data-only per-mod persistence, scoped by game version and opaque playthrough.
|
||||
-- This module is engine-private; Loader exposes only the bound facade methods.
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
|
||||
local Storage = {}
|
||||
Storage.__index = Storage
|
||||
|
||||
local ROOT = "mod_storage"
|
||||
|
||||
local function failure(code, message)
|
||||
return nil, code, message
|
||||
end
|
||||
|
||||
local function validSegment(value)
|
||||
return type(value) == "string" and value ~= ""
|
||||
and value:match("^[%w_-]+$") ~= nil
|
||||
end
|
||||
|
||||
local function validKey(key, allowEmpty)
|
||||
if type(key) ~= "string" or (key == "" and not allowEmpty) then return false end
|
||||
if key == "" then return true end
|
||||
if key:sub(1, 1) == "/" or key:sub(-1) == "/" or key:find("//", 1, true) then
|
||||
return false
|
||||
end
|
||||
for segment in key:gmatch("[^/]+") do
|
||||
if not validSegment(segment) then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function ensureParent(fs, path)
|
||||
local dir = path:match("^(.*)/[^/]+$")
|
||||
if dir and fs.createDirectory then fs.createDirectory(dir) end
|
||||
end
|
||||
|
||||
local function remove(fs, path)
|
||||
if fs.remove then fs.remove(path) end
|
||||
end
|
||||
|
||||
local function decodeAt(fs, path)
|
||||
if not (fs.getInfo and fs.getInfo(path)) then return nil end
|
||||
local body = fs.read and fs.read(path)
|
||||
if type(body) ~= "string" then return nil end
|
||||
local data = SaveSerializer.decode(body)
|
||||
if not data then return nil end
|
||||
return data, body
|
||||
end
|
||||
|
||||
function Storage.new(modId, fs)
|
||||
assert(validSegment(modId), "Storage.new needs a safe mod id")
|
||||
return setmetatable({ modId = modId, injectedFs = fs }, Storage)
|
||||
end
|
||||
|
||||
function Storage:_scope(game)
|
||||
local save = game and game.save
|
||||
local meta = save and save.meta
|
||||
local version = save and save.version
|
||||
local playthroughId = meta and meta.playthroughId
|
||||
if not (save and validSegment(version) and validSegment(playthroughId)) then
|
||||
return failure("not_in_playthrough",
|
||||
"Storage is available only inside an identified playthrough.")
|
||||
end
|
||||
local fs = SaveData.persistenceFs(self.injectedFs)
|
||||
if not (fs and fs.read and fs.write and fs.getInfo) then
|
||||
return failure("storage_unavailable", "The persistence backend is unavailable.")
|
||||
end
|
||||
local base = table.concat({ ROOT, version, playthroughId, self.modId }, "/")
|
||||
return { gameVersion = version, playthroughId = playthroughId,
|
||||
base = base, fs = fs }
|
||||
end
|
||||
|
||||
function Storage:context(game)
|
||||
local scope, code, message = self:_scope(game)
|
||||
if not scope then return nil, code, message end
|
||||
return { gameVersion = scope.gameVersion, playthroughId = scope.playthroughId }
|
||||
end
|
||||
|
||||
function Storage:_names(game, key, allowEmpty)
|
||||
if not validKey(key, allowEmpty) then
|
||||
return failure("invalid_key",
|
||||
"Storage keys use nonempty letters, numbers, underscore, dash and slash segments.")
|
||||
end
|
||||
local scope, code, message = self:_scope(game)
|
||||
if not scope then return nil, code, message end
|
||||
local path = scope.base .. (key ~= "" and ("/" .. key) or "")
|
||||
return scope, path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp"
|
||||
end
|
||||
|
||||
function Storage:write(game, key, value)
|
||||
local scope, main, bak, tmp = self:_names(game, key, false)
|
||||
if not scope then return false, main, bak end
|
||||
if type(value) ~= "table" then
|
||||
return false, "encode_failed", "Storage values must be data-only tables."
|
||||
end
|
||||
local encodedOk, encoded = pcall(SaveSerializer.encode, value)
|
||||
if not encodedOk then
|
||||
return false, "encode_failed", "Storage value is not serializable data: "
|
||||
.. tostring(encoded)
|
||||
end
|
||||
|
||||
local fs = scope.fs
|
||||
ensureParent(fs, main)
|
||||
local _, previous = decodeAt(fs, main)
|
||||
if not previous then _, previous = decodeAt(fs, bak) end
|
||||
|
||||
local ok, err = fs.write(tmp, encoded)
|
||||
if not ok then
|
||||
return false, "write_failed", "Could not stage storage data: " .. tostring(err)
|
||||
end
|
||||
local staged = decodeAt(fs, tmp)
|
||||
if not staged then
|
||||
remove(fs, tmp)
|
||||
return false, "verify_failed", "Staged storage data could not be verified."
|
||||
end
|
||||
|
||||
if previous then fs.write(bak, previous) end
|
||||
ok, err = fs.write(main, encoded)
|
||||
if not ok then
|
||||
remove(fs, tmp)
|
||||
return false, "write_failed", "Could not replace storage data: " .. tostring(err)
|
||||
end
|
||||
local verified = decodeAt(fs, main)
|
||||
if not verified then
|
||||
remove(fs, main)
|
||||
remove(fs, tmp)
|
||||
return false, "verify_failed", "Replacement storage data could not be verified."
|
||||
end
|
||||
|
||||
-- At rest both main and backup hold the newest verified record. If a later
|
||||
-- write dies after rolling this copy aside, one verified generation remains.
|
||||
fs.write(bak, encoded)
|
||||
remove(fs, tmp)
|
||||
return true
|
||||
end
|
||||
|
||||
function Storage:read(game, key)
|
||||
local scope, main, bak, tmp = self:_names(game, key, false)
|
||||
if not scope then return nil, main, bak end
|
||||
local fs = scope.fs
|
||||
local data, body = decodeAt(fs, main)
|
||||
if data then return data end
|
||||
|
||||
data, body = decodeAt(fs, tmp)
|
||||
if not data then data, body = decodeAt(fs, bak) end
|
||||
if not data then
|
||||
return nil, "not_found", "No valid stored value exists for this key."
|
||||
end
|
||||
|
||||
-- Best-effort healing. The recovered copy remains in tmp/bak if promotion
|
||||
-- cannot land, so returning it is still safe and the next read can retry.
|
||||
ensureParent(fs, main)
|
||||
if fs.write(main, body) then fs.write(bak, body) end
|
||||
remove(fs, tmp)
|
||||
return data
|
||||
end
|
||||
|
||||
function Storage:list(game, prefix)
|
||||
prefix = prefix or ""
|
||||
local scope, main, codeOrBak = self:_names(game, prefix, true)
|
||||
if not scope then return nil, main, codeOrBak end
|
||||
local fs = scope.fs
|
||||
if not fs.getDirectoryItems then
|
||||
return nil, "storage_unavailable", "The persistence backend cannot enumerate keys."
|
||||
end
|
||||
|
||||
local base = scope.base
|
||||
local start = prefix == "" and base or (base .. "/" .. prefix)
|
||||
local out = {}
|
||||
|
||||
local function walk(path, logical)
|
||||
local info = fs.getInfo(path)
|
||||
if not info then return end
|
||||
if info.type == "file" then
|
||||
if path:sub(-4) == ".lua" then out[#out + 1] = logical:sub(1, -5) end
|
||||
return
|
||||
end
|
||||
for _, child in ipairs(fs.getDirectoryItems(path) or {}) do
|
||||
local childLogical = logical == "" and child or (logical .. "/" .. child)
|
||||
walk(path .. "/" .. child, childLogical)
|
||||
end
|
||||
end
|
||||
|
||||
-- A prefix may identify one exact key or a directory of keys.
|
||||
if fs.getInfo(start .. ".lua") then
|
||||
out[#out + 1] = prefix
|
||||
else
|
||||
walk(start, prefix)
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
|
||||
function Storage:delete(game, key)
|
||||
local scope, main, bak, tmp = self:_names(game, key, false)
|
||||
if not scope then return false, main, bak end
|
||||
local fs = scope.fs
|
||||
if not (fs.getInfo(main) or fs.getInfo(bak) or fs.getInfo(tmp)) then
|
||||
return false, "not_found", "No stored value exists for this key."
|
||||
end
|
||||
remove(fs, main)
|
||||
remove(fs, bak)
|
||||
remove(fs, tmp)
|
||||
return true
|
||||
end
|
||||
|
||||
return Storage
|
||||
@@ -0,0 +1,177 @@
|
||||
-- Public mod.storage contract: data-only transactions, namespace isolation,
|
||||
-- deterministic listing, recovery, and failure retention.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("mod storage")
|
||||
local Loader = require("src.mods.Loader")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
||||
|
||||
local function manifest(id)
|
||||
return ('{"id":"%s","name":"%s","version":"1.0.0",')
|
||||
:format(id, id) .. '"entry":"main.lua","api":2,"profile":"content"}'
|
||||
end
|
||||
|
||||
local function memfs(files)
|
||||
local fs = { files = files, failTmp = false, failMain = false }
|
||||
|
||||
function fs.read(path) return files[path] end
|
||||
function fs.write(path, body)
|
||||
if fs.failTmp and path:sub(-4) == ".tmp" then return false, "tmp denied" end
|
||||
if fs.failMain and path:sub(-4) == ".lua" then return false, "main denied" end
|
||||
files[path] = body
|
||||
return true
|
||||
end
|
||||
function fs.remove(path) files[path] = nil return true end
|
||||
function fs.createDirectory() return true end
|
||||
function fs.getInfo(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
|
||||
function fs.load(path)
|
||||
if not files[path] then return nil, "no file: " .. path end
|
||||
return load(files[path], path)
|
||||
end
|
||||
function fs.getDirectoryItems(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
|
||||
return fs
|
||||
end
|
||||
|
||||
local function game(version, playthroughId)
|
||||
return { save = {
|
||||
version = version,
|
||||
meta = { format = 4, mods = {}, playthroughId = playthroughId },
|
||||
} }
|
||||
end
|
||||
|
||||
local files = {
|
||||
["mods/alpha/manifest.json"] = manifest("alpha"),
|
||||
["mods/alpha/main.lua"] = [[
|
||||
return function(mod) _G.MOD_STORAGE_ALPHA = mod.storage end
|
||||
]],
|
||||
["mods/beta/manifest.json"] = manifest("beta"),
|
||||
["mods/beta/main.lua"] = [[
|
||||
return function(mod) _G.MOD_STORAGE_BETA = mod.storage end
|
||||
]],
|
||||
}
|
||||
local fs = memfs(files)
|
||||
local loader = Loader.new({ fs = fs })
|
||||
local current = game("red", "play-a")
|
||||
loader.game = current
|
||||
T.check(loader:load({}) == true, "storage fixture mods load")
|
||||
|
||||
local alpha, beta = _G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA
|
||||
T.check(type(alpha) == "table" and type(beta) == "table",
|
||||
"Loader exposes mod.storage through the public mod object")
|
||||
if type(alpha) ~= "table" or type(beta) ~= "table" then
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
_G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil
|
||||
T.finish()
|
||||
end
|
||||
|
||||
-- Removing scope identity or exposing a mutable private slot id breaks this.
|
||||
local context = alpha:context(current)
|
||||
T.same(context, { gameVersion = "red", playthroughId = "play-a" },
|
||||
"context exposes stable game/playthrough identity only")
|
||||
|
||||
-- Data-only write/read. The literal expected table is independent of storage.
|
||||
local payload = { format = 1, nested = { money = 1234 }, flags = { a = true } }
|
||||
local ok, code, message = alpha:write(current, "states/quick/q1", payload)
|
||||
T.check(ok == true, "data-only payload writes: " .. tostring(code or message))
|
||||
local loaded = alpha:read(current, "states/quick/q1")
|
||||
T.same(loaded, payload, "stored payload roundtrips as data")
|
||||
T.check(loaded ~= payload and loaded.nested ~= payload.nested,
|
||||
"read returns decoded data rather than the caller's live table")
|
||||
|
||||
local bad, badCode = alpha:write(current, "states/bad", { callback = function() end })
|
||||
T.check(not bad and badCode == "encode_failed",
|
||||
"functions are rejected with a stable data-only error")
|
||||
|
||||
local escaped, escapedCode = alpha:write(current, "../escape", {})
|
||||
T.check(not escaped and escapedCode == "invalid_key",
|
||||
"path traversal is rejected before persistence")
|
||||
|
||||
-- Logical enumeration is deterministic and prefix-scoped.
|
||||
T.check(alpha:write(current, "states/quick/zeta", { n = 2 }), "write zeta")
|
||||
T.check(alpha:write(current, "states/quick/alpha", { n = 1 }), "write alpha")
|
||||
T.check(alpha:write(current, "settings", { enabled = true }), "write settings")
|
||||
local keys = alpha:list(current, "states/quick")
|
||||
T.same(keys, { "states/quick/alpha", "states/quick/q1", "states/quick/zeta" },
|
||||
"list returns sorted logical keys under the requested prefix")
|
||||
|
||||
-- Mod, playthrough, and game namespaces cannot observe each other.
|
||||
local missing, missingCode = beta:read(current, "states/quick/q1")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"another mod cannot read the first mod's payload")
|
||||
missing, missingCode = alpha:read(game("red", "play-b"), "states/quick/q1")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"another playthrough cannot read the payload")
|
||||
missing, missingCode = alpha:read(game("blue", "play-a"), "states/quick/q1")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"another game version cannot read the payload")
|
||||
|
||||
-- Find the implementation-owned file only to inject corruption; assertions stay
|
||||
-- on public read behavior, not the path shape.
|
||||
local function mainFor(fragment)
|
||||
for path in pairs(files) do
|
||||
if path:find(fragment, 1, true) and path:sub(-4) == ".lua" then return path end
|
||||
end
|
||||
end
|
||||
|
||||
local q1Main = mainFor("q1")
|
||||
T.check(type(q1Main) == "string", "failure fixture locates the persisted q1")
|
||||
files[q1Main] = "not a serialized table"
|
||||
loaded, code = alpha:read(current, "states/quick/q1")
|
||||
T.same(loaded, payload, "corrupt main recovers the last verified payload")
|
||||
T.eq(code, nil, "successful recovery is a normal read")
|
||||
|
||||
-- A failed replacement cannot destroy the prior verified value.
|
||||
T.check(alpha:write(current, "replace", { version = 1 }), "seed replace value")
|
||||
fs.failTmp = true
|
||||
ok, code = alpha:write(current, "replace", { version = 2 })
|
||||
fs.failTmp = false
|
||||
T.check(not ok and code == "write_failed", "staging failure is reported")
|
||||
T.same(alpha:read(current, "replace"), { version = 1 },
|
||||
"staging failure leaves the prior value readable")
|
||||
|
||||
-- Delete is exact and idempotent-not-found is explicit.
|
||||
T.check(alpha:write(current, "delete/me", { yes = true }), "seed delete target")
|
||||
T.check(alpha:write(current, "delete/keep", { yes = true }), "seed delete neighbor")
|
||||
T.check(alpha:delete(current, "delete/me") == true, "delete removes its target")
|
||||
missing, missingCode = alpha:read(current, "delete/me")
|
||||
T.check(missing == nil and missingCode == "not_found", "deleted key is unavailable")
|
||||
T.same(alpha:read(current, "delete/keep"), { yes = true },
|
||||
"delete leaves neighboring keys untouched")
|
||||
|
||||
-- No-mod parity: constructing/loading an empty loader creates no storage bytes.
|
||||
local emptyFiles, emptyFs = {}, nil
|
||||
emptyFs = memfs(emptyFiles)
|
||||
local emptyLoader = Loader.new({ fs = emptyFs })
|
||||
emptyLoader.game = current
|
||||
T.check(emptyLoader:load({}) == true, "no-mod loader still boots")
|
||||
T.eq(next(emptyFiles), nil, "no-mod boot creates no storage paths or files")
|
||||
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
Runtime.currentMod = nil
|
||||
_G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil
|
||||
|
||||
T.finish()
|
||||
Reference in New Issue
Block a user