-- Pure save validation: no engine, JSON, filesystem, or global game state. local S = {} local needs = {food = 85, water = 85, warmth = 80, wet = 0, stamina = 100} local kinds = {needs = true, claims = true, homes = true, access = true, location = true, spawn = true} local function invalid() error("Last Hearth: invalid save schema or data", 0) end local function record(value, fields) if type(value) ~= "table" or getmetatable(value) ~= nil then invalid() end for key in pairs(value) do if not fields[key] then invalid() end end end local function number(value, low, high, integer) if type(value) ~= "number" or value ~= value or value < low or value > high or (integer and value % 1 ~= 0) then invalid() end return value end local function position(value, integer) record(value, {x = true, y = true, z = true}) return {x = number(value.x, -31000, 31000, integer), y = number(value.y, -31000, 31000, integer), z = number(value.z, -31000, 31000, integer)} end local function player_name(value) return type(value) == "string" and #value > 0 and #value <= 32 and value:match("^[%w_%-]+$") ~= nil end local function normalize(kind, value) if kind == "needs" then record(value, needs) local result = {} for key in pairs(needs) do result[key] = number(value[key], 0, 100) end return result elseif kind == "location" then record(value, {latitude = true, longitude = true}) return {latitude = number(value.latitude, -90, 90), longitude = number(value.longitude, -180, 180)} elseif kind == "spawn" then return position(value, false) elseif kind == "access" then if type(value) ~= "table" or getmetatable(value) ~= nil then invalid() end local result = {} for owner, members in pairs(value) do if not player_name(owner) or type(members) ~= "table" or getmetatable(members) ~= nil then invalid() end local normalized, count = {}, 0 for member, role in pairs(members) do if not player_name(member) or member == owner or (role ~= "resident" and role ~= "storekeeper") then invalid() end count = count + 1 if count > 16 then invalid() end normalized[member] = role end result[owner] = normalized end return result end -- JSON objects and empty arrays can both become {} in the engine. Nonempty -- arrays are rejected by the string-key rule; an empty table is valid maps. if type(value) ~= "table" or getmetatable(value) ~= nil then invalid() end local result = {} for owner, pos in pairs(value) do if type(owner) ~= "string" or owner == "" then invalid() end result[owner] = position(pos, true) end return result end -- value is already parsed JSON, NOT JSON text. Missing storage is the adapter's -- responsibility. Legacy records must also be complete and valid: never clamp, -- coerce strings, silently fill fields, or downgrade an unrecognized envelope. function S.decode(kind, value) if type(kind) ~= "string" or not kinds[kind] or type(value) ~= "table" or getmetatable(value) ~= nil then invalid() end local migrated = true local envelope = value.schema ~= nil or value.kind ~= nil or value.data ~= nil if kind == "claims" or kind == "homes" or kind == "access" then -- Players may legitimately be named "schema", "kind", or "data". -- Their legacy claim values are positions, never envelope scalars. envelope = (value.schema ~= nil and type(value.schema) ~= "table") or (value.kind ~= nil and type(value.kind) ~= "table") or (value.data ~= nil and type(value.data) ~= "table") end if envelope then record(value, {schema = true, kind = true, data = true}) if type(value.schema) ~= "number" or value.schema ~= 1 or value.kind ~= kind then invalid() end value, migrated = value.data, false end return {schema = 1, kind = kind, data = normalize(kind, value)}, migrated end -- Each call owns its copy; callers cannot mutate future defaults. function S.defaults() local result = {} for key, value in pairs(needs) do result[key] = value end return result end return S