Files
last-hearth/mods/hearth/persistence.lua
T
2026-11-03 12:41:09 -05:00

127 lines
4.8 KiB
Lua
Executable File

-- Engine adapter. dofile returns factory(schema, core, mod_storage).
-- Callers must fail startup on world errors and catch/kick on player load errors.
-- No logging of JSON, player identities, parser errors, or location data.
return function(schema, core, storage)
local P = {}
local world_kinds = {claims = true, homes = true, access = true, location = true, spawn = true}
local blocked = {}
local player_stores = setmetatable({}, {__mode = "k"})
local function fail()
error("Last Hearth: persistence failed; save retained for recovery", 0)
end
local function call(fn, ...)
local ok, value, second = pcall(fn, ...)
if not ok then fail() end
return value, second
end
local function read(store, key)
if blocked[store] and blocked[store][key] then fail() end
local raw = call(store.get_string, store, key)
if type(raw) ~= "string" then fail() end
return raw
end
local function decode(kind, raw)
local value = call(core.parse_json, raw, nil, true)
if type(value) ~= "table" then fail() end
return call(schema.decode, kind, value)
end
local function equal(a, b)
if type(a) ~= type(b) then return false end
if type(a) ~= "table" then return a == b end
for k, v in pairs(a) do if not equal(v, b[k]) then return false end end
for k in pairs(b) do if a[k] == nil then return false end end
return true
end
local function encode(kind, envelope)
local raw = call(core.write_json, envelope)
if type(raw) ~= "string" or raw == "" then fail() end
-- Validate the actual serialized result before touching either key.
local roundtrip, migrated = decode(kind, raw)
if migrated ~= false or not equal(envelope, roundtrip) then fail() end
return raw
end
local function backup(store, key, original)
-- Nonempty backup bytes are immutable, even if they are not valid JSON.
if read(store, key) ~= "" then return end
local result = call(store.set_string, store, key, original)
if result == false or read(store, key) ~= original then fail() end
end
local function commit(store, key, original, raw)
local ok = pcall(function()
local result = call(store.set_string, store, key, raw)
if result == false or read(store, key) ~= raw then fail() end
end)
if ok then return end
-- Engine metadata offers no transaction/CAS or crash-durability guarantee.
-- Best-effort byte-exact rollback must itself be verified. On failure the
-- key is quarantined for this adapter's lifetime; caller must stop/kick.
local restored = pcall(function()
if read(store, key) ~= original then
local result = call(store.set_string, store, key, original)
if result == false then fail() end
end
if read(store, key) ~= original then fail() end
end)
if not restored then
blocked[store] = blocked[store] or {}
blocked[store][key] = true
end
fail()
end
local function load(store, key, backup_key, kind)
local original = read(store, key)
if original == "" then
if kind == "needs" then return call(schema.defaults) end
if kind == "claims" or kind == "homes" or kind == "access" then return {} end
return nil
end
local envelope, migrated = decode(kind, original)
if migrated then
local raw = encode(kind, envelope)
backup(store, backup_key, original)
commit(store, key, original, raw)
end
return envelope.data
end
local function save(store, key, backup_key, kind, data)
local envelope = call(schema.decode, kind, {schema = 1, kind = kind, data = data})
local raw = encode(kind, envelope)
local original = read(store, key)
local migrated = false
if original ~= "" then
local previous, legacy = decode(kind, original)
if not previous then fail() end
migrated = legacy
end
if migrated then backup(store, backup_key, original) end
commit(store, key, original, raw)
end
local function world_kind(kind)
if type(kind) ~= "string" or not world_kinds[kind] then fail() end
end
local function metadata(player)
-- get_meta() may return a fresh userdata wrapper. Keep the same reference
-- for a player's session so failed-rollback quarantine cannot be bypassed.
if player_stores[player] then return player_stores[player] end
local meta = call(player.get_meta, player)
if meta == nil then fail() end
player_stores[player] = meta
return meta
end
function P.load_world(kind)
world_kind(kind)
return load(storage, kind, "m0_backup:" .. kind, kind)
end
function P.save_world(kind, data)
world_kind(kind)
save(storage, kind, "m0_backup:" .. kind, kind, data)
end
function P.load_player(player)
return load(metadata(player), "hearth:needs", "hearth:m0_backup:needs", "needs")
end
function P.save_player(player, needs)
save(metadata(player), "hearth:needs", "hearth:m0_backup:needs", "needs", needs)
end
return P
end