local factory = dofile(assert(arg[1], "persistence.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 clone(value) if type(value) ~= "table" then return value end local result = {}; for k, v in pairs(value) do result[k] = clone(v) end; return result end local defaults = {food = 85, water = 85, warmth = 80, wet = 0, stamina = 100} -- Adapter unit tests inject this contract double, not a second schema -- implementation. schema_spec owns numeric/field validation. An explicit second -- argument (or a real hearth.schema in integration smoke) uses the real module. local schema = { defaults = function() return clone(defaults) end, decode = function(kind, value) assert(type(value) == "table", "PRIVATE schema error") local migrated = value.schema == nil local data = value if not migrated then assert(value.schema == 1 and value.kind == kind, "PRIVATE schema error") data = value.data end assert(type(data) == "table" and not data.invalid, "PRIVATE schema error") return {schema = 1, kind = kind, data = clone(data)}, migrated end, } if arg[2] then schema = dofile(arg[2]) elseif hearth and hearth.schema then schema = hearth.schema end local fixtures = { needs = {raw = ' { "food":85, "water":85, "warmth":80, "wet":0, "stamina":100 }\n', data = defaults}, claims = {raw = '{ "fixture_player" : {"x":-12,"y":4,"z":8} }', data = {fixture_player = {x = -12, y = 4, z = 8}}}, homes = {raw = '{ "fixture_player" : {"x":-12,"y":5,"z":8} }', data = {fixture_player = {x = -12, y = 5, z = 8}}}, access = {raw = '{ "fixture_player" : {"housemate":"resident"} }', data = {fixture_player = {housemate = "resident"}}}, location = {raw = ' {"latitude":45.25, "longitude":-73.5} ', data = {latitude = 45.25, longitude = -73.5}}, spawn = {raw = '{"x":0.5,"y":5.25,"z":-0.125}\n', data = {x = 0.5, y = 5.25, z = -0.125}}, } if arg[3] then local historic = dofile(arg[3]) for kind, fixture in pairs(historic) do fixtures[kind] = fixture end end local function fingerprint(value) if type(value) ~= "table" then return type(value) .. ":" .. tostring(value) end local keys, parts = {}, {} for key in pairs(value) do keys[#keys + 1] = key end table.sort(keys) for _, key in ipairs(keys) do parts[#parts + 1] = key .. "=" .. fingerprint(value[key]) end return "{" .. table.concat(parts, ";") .. "}" end local function harness() local json = {} for _, fixture in pairs(fixtures) do json[fixture.raw] = clone(fixture.data) end json.future = {schema = 2, kind = "claims", data = {}} json.invalid = {schema = 1, kind = "needs", data = {invalid = true}} json.scalar = false local C = {} function C.parse_json(raw) if raw == "throw" then error("PRIVATE parser input") end return clone(json[raw]) end function C.write_json(value) local raw = "encoded:" .. fingerprint(value) json[raw] = clone(value); return raw end local function store() local s = {values = {}, writes = {}} function s:get_string(key) if self.read_hook then self.read_hook(self, key) end return self.values[key] or "" end function s:set_string(key, value) self.writes[#self.writes + 1] = {key = key, value = value} if self.hook and self.hook(self, key, value) then return end self.values[key] = value end return s end local world, meta = store(), store() local player = {get_meta = function() return meta end} return factory(schema, C, world), world, meta, player, C, json end local function rejects(fn) local ok, err = pcall(fn) assert(not ok and err == "Last Hearth: persistence failed; save retained for recovery", "Expected sanitized fail-closed persistence error") end test("missing records return independent defaults without writes", function() local p, w, m, player = harness() assert(next(p.load_world("claims")) == nil) assert(next(p.load_world("access")) == nil) assert(p.load_world("location") == nil and p.load_world("spawn") == nil) local a = p.load_player(player); assert(a.food == 85 and a.stamina == 100) a.food = 0; assert(p.load_player(player).food == 85) assert(#w.writes == 0 and #m.writes == 0) end) for _, kind in ipairs({"claims", "homes", "access", "location", "spawn", "needs"}) do test(kind .. " backs up exact legacy bytes before migration and reloads read-only", function() local p, w, m, player, C = harness() local s = kind == "needs" and m or w local key = kind == "needs" and "hearth:needs" or kind local bk = kind == "needs" and "hearth:m0_backup:needs" or "m0_backup:" .. kind s.values[key] = fixtures[kind].raw local function load() return kind == "needs" and p.load_player(player) or p.load_world(kind) end assert(fingerprint(load()) == fingerprint(fixtures[kind].data)) assert(#s.writes == 2 and s.writes[1].key == bk and s.writes[2].key == key) assert(s.values[bk] == fixtures[kind].raw) local envelope = C.parse_json(s.values[key]) assert(envelope.schema == 1 and envelope.kind == kind) assert(fingerprint(load()) == fingerprint(fixtures[kind].data) and #s.writes == 2) -- A restored legacy primary must never replace the first migration backup. s.values[key] = fixtures[kind].raw load(); assert(#s.writes == 3 and s.values[bk] == fixtures[kind].raw) end) end test("existing backup remains immutable even if it is not JSON", function() local p, w = harness() w.values.claims, w.values["m0_backup:claims"] = fixtures.claims.raw, "PRIVATE recovery bytes" p.load_world("claims") assert(#w.writes == 1 and w.values["m0_backup:claims"] == "PRIVATE recovery bytes") end) test("empty legacy claims migrate without being mistaken for absent storage", function() local p, w, _, _, _, json = harness() json["{}"] = {}; w.values.claims = "{}" assert(next(p.load_world("claims")) == nil and #w.writes == 2) assert(w.values["m0_backup:claims"] == "{}") end) test("malformed scalar and future world records fail load and save with zero writes", function() for _, raw in ipairs({"malformed PRIVATE", "throw", "scalar", "future"}) do local p, w = harness(); w.values.claims = raw rejects(function() p.load_world("claims") end) rejects(function() p.save_world("claims", {}) end) assert(w.values.claims == raw and #w.writes == 0) end end) test("invalid player saves remain byte exact on both load and save", function() for _, raw in ipairs({"malformed PRIVATE", "throw", "scalar", "future", "invalid"}) do local p, _, m, player = harness(); m.values["hearth:needs"] = raw rejects(function() p.load_player(player) end) rejects(function() p.save_player(player, clone(defaults)) end) assert(m.values["hearth:needs"] == raw and #m.writes == 0) end end) test("invalid new data and unknown world kinds never write", function() local p, w, m, player = harness() rejects(function() p.save_world("claims", {invalid = true}) end) rejects(function() p.save_player(player, {invalid = true}) end) for _, kind in ipairs({"needs", "PRIVATE", false, {}}) do rejects(function() p.load_world(kind) end) rejects(function() p.save_world(kind, {}) end) end assert(#w.writes == 0 and #m.writes == 0) end) test("all world kinds and player needs save current envelopes", function() local p, w, m, player, C = harness() for _, kind in ipairs({"claims", "homes", "access", "location", "spawn"}) do p.save_world(kind, fixtures[kind].data) local value = C.parse_json(w.values[kind]) assert(value.schema == 1 and value.kind == kind) assert(w.values["m0_backup:" .. kind] == nil) end p.save_player(player, clone(defaults)) assert(C.parse_json(m.values["hearth:needs"]).kind == "needs" and #m.writes == 1) p.save_player(player, clone(defaults)); assert(#m.writes == 2) end) test("direct save of legacy state still backs up the old bytes", function() local p, w, m, player = harness() w.values.claims = fixtures.claims.raw p.save_world("claims", {}) assert(w.values["m0_backup:claims"] == fixtures.claims.raw and #w.writes == 2) m.values["hearth:needs"] = fixtures.needs.raw local updated = clone(defaults); updated.food = 25 p.save_player(player, updated) assert(m.values["hearth:m0_backup:needs"] == fixtures.needs.raw) assert(p.load_player(player).food == 25 and #m.writes == 2) end) test("writer failure nonstrings and invalid roundtrips never touch originals or backups", function() for _, mode in ipairs({"throw", "nil", "number", "empty", "malformed", "legacy", "changed", "future"}) do local p, w, _, _, C, json = harness(); w.values.claims = fixtures.claims.raw C.write_json = function(value) if mode == "throw" then error("PRIVATE writer error") end if mode == "nil" then return nil end if mode == "number" then return 42 end if mode == "empty" then return "" end if mode == "legacy" then return fixtures.claims.raw end if mode == "future" then return "future" end if mode == "changed" then json.changed = {schema = 1, kind = "claims", data = {}} return "changed" end return "malformed PRIVATE" end rejects(function() p.load_world("claims") end) rejects(function() p.save_world("claims", fixtures.claims.data) end) assert(w.values.claims == fixtures.claims.raw and #w.writes == 0) end end) test("backup failure prevents primary writes", function() for _, mode in ipairs({"throw", "ignore", "corrupt"}) do local p, w = harness(); w.values.claims = fixtures.claims.raw w.hook = function(s, key) assert(key == "m0_backup:claims") if mode == "throw" then error("PRIVATE storage error") end if mode == "corrupt" then s.values[key] = "PRIVATE truncated" end return true end rejects(function() p.load_world("claims") end) assert(w.values.claims == fixtures.claims.raw and #w.writes == 1) end end) test("primary corruption or throw-after-write restores original and retains backup", function() for _, mode in ipairs({"corrupt", "throw", "ignore"}) do local p, w = harness(); w.values.claims = fixtures.claims.raw local once = true w.hook = function(s, key, value) if key == "claims" and once then once = false if mode == "throw" then s.values[key] = value; error("PRIVATE storage error") end if mode == "corrupt" then s.values[key] = "PRIVATE truncated" end return true end end rejects(function() p.load_world("claims") end) assert(w.values.claims == fixtures.claims.raw) assert(w.values["m0_backup:claims"] == fixtures.claims.raw) end end) test("failed rollback quarantines key even if damaged readback looks valid", function() local p, w, _, _, C = harness(); w.values.claims = fixtures.claims.raw local replacement = C.write_json({schema = 1, kind = "claims", data = {}}) w.hook = function(s, key) if key == "claims" then s.values[key] = replacement; error("PRIVATE persistent failure") end end rejects(function() p.load_world("claims") end) local count = #w.writes; w.hook = nil rejects(function() p.load_world("claims") end) rejects(function() p.save_world("claims", {}) end) assert(#w.writes == count and w.values["m0_backup:claims"] == fixtures.claims.raw) end) test("player migration write failure rolls back only player metadata", function() local p, w, m, player = harness(); m.values["hearth:needs"] = fixtures.needs.raw local once = true m.hook = function(s, key) if key == "hearth:needs" and once then once = false; s.values[key] = "PRIVATE partial"; return true end end rejects(function() p.load_player(player) end) assert(m.values["hearth:needs"] == fixtures.needs.raw) assert(m.values["hearth:m0_backup:needs"] == fixtures.needs.raw and #w.writes == 0) end) test("failed first save restores missing storage and creates no migration backup", function() local p, w = harness(); local once = true w.hook = function(s, key) if once then once = false; s.values[key] = "PRIVATE partial"; return true end end rejects(function() p.save_world("claims", {}) end) assert(w.values.claims == "" and w.values["m0_backup:claims"] == nil) end) test("failed current-schema save restores exact prior bytes without a migration backup", function() local p, w, _, _, C = harness() local original = C.write_json({schema = 1, kind = "claims", data = fixtures.claims.data}) w.values.claims = original local once = true w.hook = function(s, key) if once then once = false; s.values[key] = "PRIVATE truncated"; return true end end rejects(function() p.save_world("claims", {}) end) assert(w.values.claims == original and w.values["m0_backup:claims"] == nil) end) test("explicit false setter result fails and rolls back", function() local p, w = harness(); local setter = w.set_string; local once = true w.set_string = function(self, key, value) setter(self, key, value) if once then once = false; return false end end rejects(function() p.save_world("claims", {}) end) assert(w.values.claims == "" and #w.writes == 2) end) test("readback exception triggers verified rollback", function() local p, w = harness(); local once = true w.read_hook = function(s, key) if key == "claims" and #s.writes == 1 and once then once = false; error("PRIVATE readback error") end end rejects(function() p.save_world("claims", {}) end) assert(w.values.claims == "" and #w.writes == 2) end) test("player quarantine survives fresh metadata wrappers", function() local p, _, m, _, C = harness() m.values["hearth:needs"] = fixtures.needs.raw local calls = 0 local player = {get_meta = function() calls = calls + 1 return {get_string = function(_, key) return m:get_string(key) end, set_string = function(_, key, value) return m:set_string(key, value) end} end} local changed = clone(defaults); changed.food = 25 local replacement = C.write_json({schema = 1, kind = "needs", data = changed}) m.hook = function(s, key) if key == "hearth:needs" then s.values[key] = replacement; error("PRIVATE write error") end end rejects(function() p.load_player(player) end) local count = #m.writes; m.hook = nil rejects(function() p.load_player(player) end) rejects(function() p.save_player(player, clone(defaults)) end) assert(calls == 1 and #m.writes == count) assert(m.values["hearth:m0_backup:needs"] == fixtures.needs.raw) end) test("read errors and metadata errors are sanitized without writes", function() local p, w, m = harness() w.read_hook = function() error("PRIVATE read error") end rejects(function() p.load_world("claims") end) rejects(function() p.save_world("claims", {}) end) rejects(function() p.load_player({get_meta = function() error("PRIVATE player error") end}) end) assert(#w.writes == 0 and #m.writes == 0) end) print("PASS " .. total .. " persistence tests")