mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02: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
|
||||
Reference in New Issue
Block a user