-- 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