mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-28 06:49:29 -04:00
221 lines
6.9 KiB
Lua
221 lines
6.9 KiB
Lua
-- 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 Version = require("src.core.Version")
|
|
|
|
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
|
|
if not (save and validSegment(version)) then
|
|
return failure("not_in_playthrough",
|
|
"Storage is available only inside an identified playthrough.")
|
|
end
|
|
local playthroughId = meta and meta.playthroughId
|
|
if not validSegment(playthroughId) then
|
|
playthroughId = SaveData.ensurePlaythroughId(save, self.injectedFs)
|
|
end
|
|
if not validSegment(playthroughId) then
|
|
return failure("not_in_playthrough",
|
|
"Storage could not identify the active 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 {
|
|
engineVersion = Version.engine,
|
|
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
|