local S = dofile(assert(arg[1], "schema.lua path required")) local total = 0 local function test(name, fn) local ok, err = pcall(fn) assert(ok, name .. ": " .. tostring(err)); total = total + 1 end local function reject(kind, value) local ok, err = pcall(S.decode, kind, value) assert(not ok and err == "Last Hearth: invalid save schema or data", "Expected sanitized rejection") end local function v1(kind, data) return {schema = 1, kind = kind, data = data} end local samples = { needs = {food = 85, water = 85, warmth = 80, wet = 0, stamina = 100}, claims = {fixture_player = {x = -12, y = 4, z = 8}}, homes = {fixture_player = {x = -12, y = 5, z = 8}}, access = {fixture_player = {housemate = "resident", keeper = "storekeeper"}}, location = {latitude = 45.25, longitude = -73.5}, spawn = {x = 0.5, y = 5.25, z = -0.125}, } test("defaults are exact independent needs copies", function() local a, b = S.defaults(), S.defaults() for key, value in pairs(samples.needs) do assert(a[key] == value and b[key] == value) end a.food = 0; assert(b.food == 85 and S.defaults().food == 85) end) for _, kind in ipairs({"needs", "claims", "homes", "access", "location", "spawn"}) do test(kind .. " migrates without mutating or aliasing input", function() local source = samples[kind] local envelope, migrated = S.decode(kind, source) assert(migrated == true and envelope.schema == 1 and envelope.kind == kind) assert(envelope.data ~= source and source.schema == nil) if kind == "claims" or kind == "homes" or kind == "access" then assert(envelope.data.fixture_player ~= source.fixture_player) end local current, again = S.decode(kind, envelope) assert(again == false and current ~= envelope and current.data ~= envelope.data) end) test(kind .. " rejects future versions, wrong kinds and extra envelope fields", function() for _, version in ipairs({0, -1, 2, 1.5, "1", false, math.huge, 0 / 0}) do reject(kind, {schema = version, kind = kind, data = samples[kind]}) end reject(kind, {schema = 1, kind = "unknown", data = samples[kind]}) reject(kind, {schema = 1, kind = kind}) reject(kind, {schema = 1, data = samples[kind]}) reject(kind, {kind = kind, data = samples[kind]}) reject(kind, {schema = 1, kind = kind, data = samples[kind], private = "PRIVATE"}) end) test(kind .. " rejects scalar values and missing data", function() reject(kind, nil) for _, value in ipairs({false, 0, "PRIVATE", function() end}) do reject(kind, value); reject(kind, v1(kind, value)) end end) end test("unknown kinds and metatables fail without executing input", function() reject("PRIVATE", {}) reject({}, {}) local hostile = setmetatable({}, {__index = function() error("PRIVATE") end}) reject("needs", hostile); reject("needs", v1("needs", hostile)) reject("claims", {owner = hostile}) end) test("needs require all five fields and reject unknown fields", function() for key in pairs(samples.needs) do local data = S.defaults(); data[key] = nil; reject("needs", data) end local data = S.defaults(); data.private = "PRIVATE"; reject("needs", v1("needs", data)) reject("needs", {}); reject("needs", {85, 85, 80, 0, 100}) end) test("needs boundaries and fractional finite numbers are valid", function() local data = {food = 0, water = 100, warmth = 0.125, wet = 99.5, stamina = 50} assert(S.decode("needs", v1("needs", data)).data.warmth == 0.125) end) test("every needs field rejects nonnumeric nonfinite or out of range values", function() for key in pairs(samples.needs) do for _, value in ipairs({"85", true, {}, -0.001, 100.001, math.huge, -math.huge, 0 / 0}) do local data = S.defaults(); data[key] = value reject("needs", v1("needs", data)); reject("needs", data) end end end) for _, kind in ipairs({"claims", "homes", "access"}) do test("empty " .. kind .. " and envelope-field player names remain valid", function() assert(next(S.decode(kind, {}).data) == nil) assert(next(S.decode(kind, v1(kind, {})).data) == nil) local claims if kind == "access" then claims = {schema = {}, kind = {}, data = {}} else claims = {schema = {x = 0, y = 0, z = 0}, kind = {x = 1, y = 2, z = 3}, data = {x = -1, y = -2, z = -3}} end local decoded, migrated = S.decode(kind, claims) assert(migrated) if kind == "access" then assert(decoded.data.schema and decoded.data.kind and decoded.data.data) else assert(decoded.data.schema.x == 0 and decoded.data.data.z == -3) end end) if kind ~= "access" then test(kind .. " require nonempty string owners and integer complete positions", function() reject(kind, {{x = 0, y = 0, z = 0}}) reject(kind, {[""] = {x = 0, y = 0, z = 0}}) reject(kind, {owner = {x = 0.5, y = 0, z = 0}}) reject(kind, {owner = {x = 0, y = 0}}) reject(kind, {owner = {x = 0, y = 0, z = 0, private = true}}) reject(kind, {owner = "PRIVATE"}) end) end end test("access map accepts only bounded usernames and explicit household roles", function() local access = {owner = {resident = "resident", keeper = "storekeeper"}} local decoded = S.decode("access", access) assert(decoded.data.owner.resident == "resident" and decoded.data.owner.keeper == "storekeeper") for _, value in ipairs({ {[""] = {}}, {owner = {bad = "admin"}}, {owner = {"resident"}}, {owner = {owner = "resident"}}, {owner = {['bad/name'] = "resident"}}, {owner = "resident"}, {owner = setmetatable({}, {})}, }) do reject("access", value); reject("access", v1("access", value)) end local long_name = string.rep("a", 33) reject("access", {[long_name] = {member = "resident"}}) local too_many = {owner = {}} for i = 1, 17 do too_many.owner["member" .. i] = "resident" end reject("access", too_many) end) for _, kind in ipairs({"spawn", "claims", "homes"}) do test(kind .. " enforces all coordinate bounds and numeric types", function() local function wrap(pos) return (kind == "claims" or kind == "homes") and {owner = pos} or pos end assert(S.decode(kind, wrap({x = -31000, y = 31000, z = 0}))) for _, axis in ipairs({"x", "y", "z"}) do for _, value in ipairs({"0", true, {}, -31000.001, 31000.001, math.huge, -math.huge, 0 / 0}) do local pos = {x = 0, y = 0, z = 0}; pos[axis] = value reject(kind, wrap(pos)); reject(kind, v1(kind, wrap(pos))) end local pos = {x = 0, y = 0, z = 0}; pos[axis] = nil; reject(kind, wrap(pos)) end end) end test("spawn supports floats but rejects unknown fields", function() assert(S.decode("spawn", samples.spawn).data.y == 5.25) reject("spawn", {x = 0, y = 0, z = 0, private = true}) end) test("location accepts boundaries and rejects unknown or missing fields", function() assert(S.decode("location", {latitude = -90, longitude = 180})) assert(S.decode("location", {latitude = 90, longitude = -180})) reject("location", {latitude = 0}); reject("location", {longitude = 0}) reject("location", {latitude = 0, longitude = 0, private = "PRIVATE"}) end) test("every location coordinate rejects strings nonfinite values and overflow", function() for _, key in ipairs({"latitude", "longitude"}) do local limit = key == "latitude" and 90 or 180 for _, value in ipairs({"0", false, {}, limit + 0.001, -limit - 0.001, math.huge, -math.huge, 0 / 0}) do local data = {latitude = 0, longitude = 0}; data[key] = value reject("location", data); reject("location", v1("location", data)) end end end) -- Optional explicit fixture path for standalone/integration runners. The engine's -- restricted one-module smoke harness exercises the same values above without -- needing secondary file access or changing live globals. if arg[2] then local fixtures = dofile(arg[2]) test("historic fixture records migrate to schema1", function() for kind in pairs(fixtures) do if samples[kind] then local decoded, migrated = S.decode(kind, fixtures[kind].data) assert(migrated and decoded.kind == kind and type(fixtures[kind].raw) == "string") end end assert(next(S.decode("claims", fixtures.empty_claims.data).data) == nil) end) end print("PASS " .. total .. " schema tests")