127 lines
4.8 KiB
Lua
Executable File
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
|