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

106 lines
4.0 KiB
Lua
Executable File

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