-- Standalone Lua 5.1+ lifecycle regressions; arg[1] is players.lua. local target = assert(arg[1], "players.lua path required") local root = assert(target:match("^(.*[/\\])"), "module directory required") local schema = dofile(root .. "schema.lua") local persistence = dofile(root .. "persistence.lua") local total = 0 local function test(name, fn) local old_core, old_hearth, old_vector = core, hearth, vector local ok, message = pcall(fn) core, hearth, vector = old_core, old_hearth, old_vector assert(ok, name .. ": " .. tostring(message)); total = total + 1 end local function copy(value) if type(value) ~= "table" then return value end local result = {}; for k, v in pairs(value) do result[k] = copy(v) end; return result end local spawn = {x = 4, y = 4, z = -8} local function harness(saved_spawn) local h = {callbacks = {}, after = {}, logs = {}, kicks = {}, connected = {}, moves = {}, gui = {}, messages = {}, loaded = true, ground = true, clock = 1000000, emerges = {}, homes = {}} local function store() local m = {values = {}, ints = {}, writes = 0} function m:get_string(key) if self.read_fail then error("PRIVATE read payload") end return self.values[key] or "" end function m:set_string(key, value) self.writes = self.writes + 1 if self.write_fail then error("PRIVATE save payload") end self.values[key] = value end function m:get_int(key) return self.ints[key] or 0 end function m:set_int(key, value) if key == "hearth:arrived" then h.moves[#h.moves + 1] = "arrived" end self.ints[key] = value end return m end local json, serial = {}, 0 local C = {registered_nodes = { air = {walkable = false}, ground = {walkable = true, groups = {soil = 1}}, leaves = {walkable = true, groups = {leaves = 1, hearth_canopy = 1}}, log = {walkable = true, groups = {tree = 1}}, floor = {walkable = true, groups = {stone = 1, hearth_building = 1}}, water = {walkable = false, liquidtype = "source", groups = {water = 1}}, fire = {walkable = false, damage_per_second = 4}, }} function C.write_json(value) serial = serial + 1; local raw = "fixture:" .. serial; json[raw] = copy(value); return raw end function C.parse_json(raw, _, return_error) assert(return_error == true, "Persistence must suppress parser payload logging"); return copy(json[raw]) end function C.get_node_or_nil(pos) if not h.loaded then return nil end if h.node_at then return h.node_at(pos) end if h.water_at and h.water_at(pos) then return {name = "water"} end if h.fire_at and h.fire_at(pos) then return {name = "fire"} end if h.ground and pos.y == 3 then return {name = "ground"} end return {name = "air"} end function C.get_mod_storage() return h.world end function C.register_on_joinplayer(fn) h.callbacks.join = fn end function C.register_on_leaveplayer(fn) h.callbacks.leave = fn end function C.register_on_shutdown(fn) h.callbacks.shutdown = fn end function C.register_on_respawnplayer(fn) h.callbacks.respawn = fn end function C.register_on_punchplayer(fn) h.callbacks.punch = fn end C.get_connected_players = function() return h.connected end C.get_player_by_name = function(name) for _, p in ipairs(h.connected) do if p.name == name then return p end end end C.log = function(level, text) assert(level == "error"); h.logs[#h.logs + 1] = text end C.kick_player = function(name, text) h.kicks[#h.kicks + 1] = text end C.after = function(delay, fn) h.after[#h.after + 1] = {delay = delay, fn = fn} end C.get_us_time = function() h.clock = h.clock + 1000; return h.clock end C.settings = {get = function(_, key) if key == "mapgen_limit" then return "512" end end} C.emerge_area = function(minimum, maximum, fn) h.emerges[#h.emerges + 1] = {minimum = copy(minimum), maximum = copy(maximum), callback = fn} end h.world = store() if saved_spawn then h.world.values.spawn = C.write_json({schema = 1, kind = "spawn", data = saved_spawn}) end local function claim_allowed() return true end core, vector = C, {round = function(p) return {x = math.floor(p.x + 0.5), y = math.floor(p.y + 0.5), z = math.floor(p.z + 0.5)} end} hearth = {players = {}, sessions = {}, schema = schema, has_location = true, claim_allowed = claim_allowed} hearth.persistence = persistence(schema, C, h.world) hearth.home_for = function(name) local p = h.homes[name]; return p and copy(p) end hearth.in_own_claim = function(pos, name) return h.claim_owner == name and h.in_claim ~= false end hearth.store_home = function(name, pos) assert(hearth.in_own_claim(pos, name)); h.homes[name] = copy(pos); h.home_saves = (h.home_saves or 0) + 1 end hearth.notify = function(_, message) h.messages[#h.messages + 1] = message end hearth.texture = function() return "texture.png" end hearth.inventory = function(p) p.inventory_calls = p.inventory_calls + 1 end hearth.make_hud = function(p) p.hud_calls = p.hud_calls + 1 end hearth.guide = function(p) h.gui[#h.gui + 1] = {kind = "guide", player = p} end hearth.location_form = function(p) h.gui[#h.gui + 1] = {kind = "location", player = p} end function h.player(name) local meta = store() local p = {name = name or "fixture-player", meta = meta, pos = {x = 0, y = 4, z = 0}, inventory_calls = 0, hud_calls = 0, sizes = {}, widths = {}, hp = 20} function p:get_player_name() return self.name end function p:get_meta() return self.meta end function p:get_pos() return self.pos end function p:get_inventory() return {set_size = function(_, key, value) self.sizes[key] = value end, set_width = function(_, key, value) self.widths[key] = value end} end function p:set_properties(value) self.property_calls = (self.property_calls or 0) + 1 self.properties = self.properties or {} for k, v in pairs(value) do self.properties[k] = copy(v) end end function p:set_armor_groups(value) self.armor = value end function p:set_clouds(value) self.clouds = value end function p:set_pos(value) self.pos = copy(value); h.moves[#h.moves + 1] = "pos" end function p:set_velocity(value) self.velocity = copy(value); h.moves[#h.moves + 1] = "velocity" end function p:get_hp() return self.hp end function p:set_hp(value) self.hp = value end h.connected[#h.connected + 1] = p return p, meta end function h.drain_zero() local count = 0 while count < 100 do local item for i, queued in ipairs(h.after) do if queued.delay == 0 then item = table.remove(h.after, i); break end end if not item then return end count = count + 1; item.fn() end error("Unbounded scheduled callback loop") end dofile(target) return h end local function no_private(h) for _, messages in ipairs({h.logs, h.kicks}) do for _, message in ipairs(messages) do assert(not message:find("PRIVATE", 1, true)) end end end test("lifecycle callbacks expose safe handlers and disable PvP", function() local h = harness(spawn) assert(h.callbacks.join == hearth.player_join and h.callbacks.leave == hearth.player_leave) assert(h.callbacks.respawn == hearth.player_respawn and h.callbacks.punch() == true) assert(type(h.callbacks.shutdown) == "function" and type(hearth.safe_arrival) == "function") end) test("initial arrival validates spawn and only then marks arrived", function() local h = harness(spawn); local p, meta = h.player(); h.callbacks.join(p) assert(meta:get_int("hearth:arrived") == 1 and p.pos.x == spawn.x and p.pos.y == spawn.y) assert(table.concat(h.moves, ",") == "pos,velocity,arrived") assert(not hearth.sessions[p.name].waiting and #h.kicks == 0) end) test("normal reconnect preserves saved location without home/spawn teleport", function() local h = harness(spawn); local p, meta = h.player(); meta.ints["hearth:arrived"] = 1 p.pos = {x = 123, y = -40, z = 456}; h.callbacks.join(p) assert(#h.moves == 0 and p.pos.x == 123 and p.pos.y == -40 and p.pos.z == 456) assert(not hearth.sessions[p.name].waiting) end) test("initial arrival waits for emergence and does not set arrived early", function() local h = harness(); h.loaded = false local p, meta = h.player(); h.callbacks.join(p) assert(meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting and #h.moves == 0) assert(#h.emerges == 1 and h.emerges[1].minimum.x == -64 and h.emerges[1].minimum.z == -64 and h.emerges[1].maximum.x == 64 and h.emerges[1].maximum.z == 64 and h.emerges[1].minimum.y == 0 and h.emerges[1].maximum.y == 320, "Bounded arrival must emerge surfaces through 318 plus feet/head clearance") h.loaded, h.ground = true, true h.emerges[1].callback(nil, nil, 0); h.drain_zero() assert(meta:get_int("hearth:arrived") == 1 and not hearth.sessions[p.name].waiting) assert(p.pos.y == 4 and #h.kicks == 0) end) test("shared emergence resolves every waiting player without kicking", function() local h = harness(); h.loaded = false local p, m = h.player("one"); local q, n = h.player("two") h.callbacks.join(p); h.callbacks.join(q) assert(#h.emerges == 1 and hearth.sessions.one.waiting and hearth.sessions.two.waiting) h.loaded, h.ground = true, true h.emerges[1].callback(nil, nil, 0); h.drain_zero() assert(m:get_int("hearth:arrived") == 1 and n:get_int("hearth:arrived") == 1) assert(not hearth.sessions.one.waiting and not hearth.sessions.two.waiting and #h.kicks == 0) assert(p.pos.x ~= q.pos.x or p.pos.z ~= q.pos.z, "Arrival must not overlap another player") end) test("leaving during emergence removes that waiter and stale callback cannot move them", function() local h = harness(); h.loaded = false local p, meta = h.player(); h.callbacks.join(p); h.callbacks.leave(p) h.loaded = true; h.emerges[1].callback(nil, nil, 0); h.drain_zero() assert(#h.moves == 0 and meta:get_int("hearth:arrived") == 0 and not hearth.sessions[p.name]) end) test("no safe bounded location leaves player waiting and preserves metadata", function() local h = harness(); h.loaded = false; h.ground = false local p, meta = h.player(); h.callbacks.join(p) h.loaded = true; h.ground = false; h.emerges[1].callback(nil, nil, 0); h.drain_zero() assert(meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting) assert(#h.kicks == 0 and #h.moves == 0 and hearth.players[p.name].food == 85) end) test("high altitude patch is found without settling on leaves or logs", function() local h = harness(); local p, meta = h.player() h.node_at = function(pos) assert(math.abs(pos.x) <= 64 and math.abs(pos.z) <= 64 and pos.y <= 320, "Surface search must remain bounded") if pos.y == 318 and math.abs(pos.x) <= 2 and math.abs(pos.z) <= 2 then return {name = "ground"} end if pos.y == 290 then return {name = pos.x < 0 and "leaves" or "log"} end return {name = "air"} end h.callbacks.join(p) assert(#h.moves == 0 and #h.emerges == 1) h.emerges[1].callback(nil, nil, 0); h.drain_zero() assert(meta:get_int("hearth:arrived") == 1 and p.pos.y == 319 and p.pos.x == 0 and p.pos.z == 0) end) test("natural biome surface groups support high altitude arrival", function() for _, groups in ipairs({{soil = 1, hearth_habitat = 1}, {stone = 1, hearth_geology = 1}, {sand = 1, falling_node = 1}, {crumbly = 3}, {crumbly = 3, falling_node = 1}, {clay = 1, crumbly = 2}}) do local h = harness(); core.registered_nodes.variant = {walkable = true, groups = groups} h.node_at = function(pos) return {name = pos.y == 250 and "variant" or "air"} end local p, meta = h.player(); h.callbacks.join(p) h.emerges[1].callback(nil, nil, 0); h.drain_zero() assert(meta:get_int("hearth:arrived") == 1 and p.pos.y == 251) end end) test("canopy or log only worlds never fabricate a safe fallback", function() for _, node in ipairs({"leaves", "log", "floor"}) do local h = harness() h.node_at = function(pos) return {name = pos.y == 250 and node or "air"} end local p, meta = h.player(); h.callbacks.join(p) h.emerges[1].callback(nil, nil, 0); h.drain_zero() assert(#h.moves == 0 and meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting) end end) test("isolated high cliff pillar is not a landing patch", function() local h = harness() h.node_at = function(pos) return {name = pos.x == 0 and pos.z == 0 and pos.y <= 250 and "ground" or "air"} end local p, meta = h.player(); h.callbacks.join(p) h.emerges[1].callback(nil, nil, 0); h.drain_zero() assert(#h.moves == 0 and meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting) end) test("unsafe upper surfaces cannot fall through to underground cave floors", function() local h = harness() h.node_at = function(pos) if pos.y >= 260 or pos.y == 200 then return {name = "ground"} end return {name = "air"} end local p, meta = h.player(); h.callbacks.join(p) h.emerges[1].callback(nil, nil, 0); h.drain_zero() assert(#h.moves == 0 and meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting) end) test("high altitude wet and damaging surfaces remain unsafe", function() for _, hazard in ipairs({"water", "fire"}) do local h = harness() h.node_at = function(pos) if pos.y == 250 then return {name = "ground"} end return {name = pos.y == 251 and hazard or "air"} end local p, meta = h.player(); h.callbacks.join(p) h.emerges[1].callback(nil, nil, 0); h.drain_zero() assert(#h.moves == 0 and meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting) end end) test("bounded arrivals authorize unclaimed land but reject foreign claims", function() for _, claimed in ipairs({false, true}) do local h = harness() local claims = claimed and {owner = {x = 0, y = 251, z = 0}} or {} local policy = dofile(root .. "access.lua")(claims, {owner = {newcomer = "resident"}}) local checks = 0 hearth.claim_allowed = function(pos, name, action) assert(action == "arrival" and name == "newcomer"); checks = checks + 1 return policy.allowed(pos, name, action) end h.node_at = function(pos) return {name = pos.y == 250 and math.abs(pos.x) <= 2 and math.abs(pos.z) <= 2 and "ground" or "air"} end local p, meta = h.player("newcomer"); h.callbacks.join(p) h.emerges[1].callback(nil, nil, 0); h.drain_zero() assert(checks > 0) assert(meta:get_int("hearth:arrived") == (claimed and 0 or 1)) assert(hearth.sessions[p.name].waiting == claimed) end end) test("saved home retains safe constructed floor support", function() local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p) h.claim_owner = p.name h.node_at = function(pos) return {name = pos.y == 249 and "floor" or "air"} end p.pos = {x = 20, y = 250, z = 20} assert(hearth.set_home(p)) p.pos = {x = 100, y = 4, z = 100} assert(hearth.go_home(p)); h.drain_zero() assert(p.pos.x == 20 and p.pos.y == 250 and p.pos.z == 20) end) test("sethome requires own claim and dry grounded support, then stores safe integer position", function() local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p) assert(not hearth.set_home(p) and (h.home_saves or 0) == 0) p.pos = {x = 0, y = 4, z = 0} h.claim_owner = p.name local ok, message = hearth.set_home(p) assert(ok and message:find("Safe home", 1, true) and h.home_saves == 1) assert(h.homes[p.name].x == 0 and h.homes[p.name].y == 4 and h.homes[p.name].z == 0) h.water_at = function(pos) return pos.y == 4 end assert(not hearth.set_home(p) and h.home_saves == 1) end) test("sethome rejects foreign claim and unsupported or obstructed positions", function() local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p) h.claim_owner = "another-owner"; assert(not hearth.set_home(p)) h.claim_owner = p.name; h.ground = false; assert(not hearth.set_home(p)) h.ground = true; h.node_at = function(pos) if pos.y == 3 then return {name = "ground"} end if pos.y == 5 then return {name = "ground"} end return {name = "air"} end assert(not hearth.set_home(p)) end) test("home command revalidates home and moves only on explicit request", function() local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p) h.homes[p.name] = {x = 20, y = 4, z = 20} p.pos = {x = 100, y = 4, z = 100} local before = #h.moves local ok = hearth.go_home(p) assert(ok and #h.moves == before + 2 and p.pos.x == 20 and p.pos.z == 20) assert(h.messages[#h.messages]:find("verified safe", 1, true)) end) test("home command without a saved home still performs safe world-spawn recovery", function() local h = harness(spawn); local p = h.player("new-player"); h.callbacks.join(p) p.pos = {x = 100, y = 4, z = 100} local ok, message = hearth.go_home(p) h.drain_zero() assert(ok and message:find("No personal home", 1, true)) assert(p.pos.x == spawn.x and p.pos.y == spawn.y and p.pos.z == spawn.z) end) test("unsafe saved home falls back to validated spawn without teleporting into fluid", function() local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p) h.homes[p.name] = {x = 20, y = 4, z = 20} h.water_at = function(pos) return pos.x >= 15 and pos.x <= 25 and pos.z >= 15 and pos.z <= 25 and (pos.y == 4 or pos.y == 5) end assert(hearth.go_home(p)); h.drain_zero() assert(p.pos.x == spawn.x and p.pos.y == spawn.y and p.pos.z == spawn.z) end) test("respawn preserves food, water, wetness and inventory while limiting recovery needs", function() local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p) local state = hearth.players[p.name] state.food, state.water, state.wet, state.warmth, state.stamina = 19, 27, 44, 35, 2 p.pos = {x = 200, y = 4, z = 200} assert(h.callbacks.respawn(p) == true) h.drain_zero() assert(#h.emerges == 1 and hearth.sessions[p.name].waiting) h.emerges[1].callback(nil, nil, 0); h.drain_zero() assert(state.food == 19 and state.water == 27 and state.wet == 44) assert(state.warmth == 35 and state.stamina == 35 and p.inventory_calls == 1) assert(p.pos.y == 4 and not hearth.sessions[p.name].waiting and #h.kicks == 0) end) test("death recovery avoids a recently lethal home and uses emergence-safe fallback", function() local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p) h.homes[p.name] = {x = 20, y = 4, z = 20}; p.pos = {x = 20, y = 4, z = 20} hearth.sessions[p.name].recovery_avoid = {x = 20, y = 4, z = 20, until_us = h.clock + 10000000} h.callbacks.respawn(p) h.drain_zero() assert((p.pos.x ~= 20 or p.pos.z ~= 20) and not hearth.sessions[p.name].waiting) end) test("failed player load is sanitized and cannot establish a session", function() local h = harness(spawn); local p, meta = h.player(); meta.read_fail = true; h.callbacks.join(p) assert(not hearth.players[p.name] and not hearth.sessions[p.name] and #h.kicks == 1) assert(#h.moves == 0 and meta.writes == 0); no_private(h) end) test("saved spawn and home never accept unloaded, liquid, unsupported, or damaging nodes", function() local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p) h.node_at = function(pos) if pos.y == 3 then return {name = "ground"} end if pos.x == spawn.x and pos.z == spawn.z and (pos.y == 4 or pos.y == 5) then return {name = "water"} end return {name = "air"} end h.homes[p.name] = {x = spawn.x, y = spawn.y, z = spawn.z} p.pos = {x = 100, y = 4, z = 100} assert(hearth.go_home(p)); h.drain_zero() assert(p.pos.x ~= spawn.x or p.pos.z ~= spawn.z) assert(#h.kicks == 0) end) test("character join hook runs after session and base properties without taking form priority", function() local h = harness(spawn); local p, meta = h.player() local calls = 0 hearth.character = {on_join = function(player) assert(player == p and hearth.sessions[p.name] and p.properties.visual == "mesh") assert(p.properties.mesh == "hearth_person.b3d" and p.properties.eye_height == 1.55) calls = calls + 1 player:set_properties({mesh = "hearth_person_compact.b3d", textures = {"fixture_appearance.png"}}) end} h.callbacks.join(p) assert(calls == 1 and p.properties.mesh == "hearth_person_compact.b3d") assert(p.properties.eye_height == 1.55 and p.properties.visual_size.x == 1 and p.properties.collisionbox[5] == 1.75 and p.properties.hp_max == 20) assert(meta:get_int("hearth:arrived") == 1 and #h.gui == 0) hearth.has_location = false; core.is_singleplayer = function() return true end for _, entry in ipairs(h.after) do if entry.delay == 1 then entry.fn() end end assert(#h.gui == 1 and h.gui[1].kind == "location", "Location must retain join form priority") end) test("real character factory restores saved metadata during player initialization", function() local h = harness(spawn); local p, meta = h.player() core.register_on_player_receive_fields = function() end core.register_chatcommand = function() end hearth.character = dofile(root .. "character.lua")(hearth, core) local d = hearth.character.defaults(); d.body = "broad"; d.skin = "umber" meta.values["hearth:appearance"] = assert(hearth.character.encode(d)) h.callbacks.join(p) assert(p.properties.mesh == "hearth_person_broad.b3d" and p.property_calls == 2) assert(p.properties.textures[1]:find("hearth_char_skin_umber.png",1,true)) assert(p.properties.eye_height == 1.55 and p.properties.stepheight == 0.6) assert(meta.writes == 0, "Joining with saved appearance must not rewrite metadata") assert(#h.kicks == 0 and not hearth.character.state(p).warning) end) test("malformed saved world spawn fails module startup without overwrite", function() local h = harness(spawn); h.world.values.spawn = "PRIVATE malformed" local ok, message = pcall(dofile, target) assert(not ok and not tostring(message):find("PRIVATE", 1, true)) assert(h.world.values.spawn == "PRIVATE malformed" and h.world.writes == 0); no_private(h) end) assert(total == 27, "Unexpected lifecycle regression count") print("PASS " .. total .. " player lifecycle safety tests")