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