feat: add playthrough-scoped mod storage

This commit is contained in:
MaxTomahawk
2026-08-07 14:43:04 +02:00
parent 726ed1102e
commit 0399ad040f
4 changed files with 404 additions and 0 deletions
+7
View File
@@ -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()
+12
View File
@@ -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")
+208
View File
@@ -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
+177
View File
@@ -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()