local C, P = core, hearth.persistence local map_limit = tonumber(C.settings and C.settings:get("mapgen_limit")) or 512 local max_coord = math.min(31000, map_limit + 32) local spawn_radius = 64 -- Two clear nodes above the highest surface stay inside the shared emerge box. local MAX_SURFACE_Y = 318 local spawn = P.load_world("spawn") local waiting, emerging = {}, {} local function stop_player(player, operation) local name = player:get_player_name() hearth.players[name], hearth.sessions[name], waiting[name] = nil, nil, nil C.log("error", "[hearth] Player " .. operation .. " failed; save retained for recovery.") C.kick_player(name, "Last Hearth could not " .. operation .. " your data. Please contact the administrator for recovery.") end function hearth.player_save(player) local state = hearth.players[player:get_player_name()] if not state then return false end local ok, result = pcall(P.save_player, player, state) if not ok or result == false then stop_player(player, "save"); return false end return true end local function node_at(pos) local node = C.get_node_or_nil and C.get_node_or_nil(pos) or nil if not node or node.name == "ignore" then return nil, true end local def = C.registered_nodes[node.name] if not def then return nil, false end return def, false end local function liquid(def) local groups = def.groups or {} return (def.liquidtype and def.liquidtype ~= "none") or (groups.liquid or 0) > 0 or (groups.water or 0) > 0 or (groups.lava or 0) > 0 end local function clear_node(pos) local def, unloaded = node_at(pos) if unloaded then return false, true end if not def or def.walkable ~= false or liquid(def) or (def.damage_per_second or 0) > 0 then return false, false end return true, false end local function safe_exact(pos, name, avoid) if type(pos) ~= "table" or type(pos.x) ~= "number" or type(pos.y) ~= "number" or type(pos.z) ~= "number" or math.abs(pos.x) > max_coord or math.abs(pos.y) > max_coord or math.abs(pos.z) > max_coord then return false, false end local foot = {x = math.floor(pos.x + 0.5), y = math.floor(pos.y + 0.5), z = math.floor(pos.z + 0.5)} local support, support_unloaded = node_at({x = foot.x, y = foot.y - 1, z = foot.z}) local feet_ok, feet_unloaded = clear_node(foot) local head_ok, head_unloaded = clear_node({x = foot.x, y = foot.y + 1, z = foot.z}) if not support or support.walkable ~= true or liquid(support) or (support.damage_per_second or 0) > 0 or not feet_ok or not head_ok then return false, support_unloaded or feet_unloaded or head_unloaded end local candidate = {x = foot.x, y = foot.y, z = foot.z} if name and hearth.claim_allowed and not hearth.claim_allowed(candidate, name, "arrival") then return false, false end if avoid then for _, center in ipairs(avoid) do if center and math.abs(candidate.x - center.x) <= 12 and math.abs(candidate.y - center.y) <= 8 and math.abs(candidate.z - center.z) <= 12 then return false, false end end end for _, other in ipairs(C.get_connected_players()) do if other:get_player_name() ~= name then local p = other:get_pos() if math.abs(candidate.x - p.x) < 1 and math.abs(candidate.y - p.y) < 2 and math.abs(candidate.z - p.z) < 1 then return false, false end end end if C.get_objects_inside_radius then local self_object = name and C.get_player_by_name(name) for _, object in ipairs(C.get_objects_inside_radius(candidate, 1.5)) do if object ~= self_object then return false, false end end end return candidate, false end local function natural_surface(def) if not def or def.walkable ~= true or liquid(def) or (def.damage_per_second or 0) > 0 then return false end local groups = def.groups or {} for _, group in ipairs({"tree", "leaves", "wood", "hearth_canopy", "hearth_building", "hearth_non_support", "flora", "hearth_ecosystem"}) do if (groups[group] or 0) > 0 then return false end end -- Includes biome soils/geology and loose snow, scree, gravel and clay. return (groups.soil or 0) > 0 or (groups.stone or 0) > 0 or (groups.sand or 0) > 0 or (groups.crumbly or 0) > 0 end local function safe_surface(pos, name, avoid) local safe, missing = safe_exact(pos, name, avoid) if not safe then return nil, missing end -- A small dry landing patch, not a trunk, canopy, pillar or cliff edge. for dx = -1, 1 do for dz = -1, 1 do local x, z = safe.x + dx, safe.z + dz local support = node_at({x = x, y = safe.y - 1, z = z}) if not natural_surface(support) then local clear = clear_node({x = x, y = safe.y - 1, z = z}) if not clear or not natural_surface(node_at({x = x, y = safe.y - 2, z = z})) then return nil, false end end if not clear_node({x = x, y = safe.y, z = z}) or not clear_node({x = x, y = safe.y + 1, z = z}) then return nil, false end end end return safe, false end local function offsets(radius) local result = {{0, 0}} for distance = 1, radius do for dx = -distance, distance do local dz = distance - math.abs(dx) result[#result + 1] = {dx, dz} if dz ~= 0 then result[#result + 1] = {dx, -dz} end end end return result end local horizontal_offsets = offsets(5) local vertical_offsets = {0, 1, -1, 2, -2, 3, -3, 4, -4, 5, -5, 6, -6} local function nearby_safe(desired, name, avoid, surface) local x, y, z = math.floor(desired.x + 0.5), math.floor(desired.y + 0.5), math.floor(desired.z + 0.5) local unloaded = false for _, delta in ipairs(vertical_offsets) do for _, offset in ipairs(horizontal_offsets) do local check = surface and safe_surface or safe_exact local pos, missing = check({x = x + offset[1], y = y + delta, z = z + offset[2]}, name, avoid) if pos then return pos, false end unloaded = unloaded or missing end end return nil, unloaded end local function set_waiting(name, value) local session = hearth.sessions[name] if session then session.waiting = value end end local function connected_waiter(name, entry) local player = C.get_player_by_name(name) return player and hearth.sessions[name] == entry.session and player == entry.player and player end local function finish_waiter(name, entry, position, reason) if waiting[name] ~= entry then return end local player = connected_waiter(name, entry) waiting[name] = nil if not player then return end if not position then set_waiting(name, true) if entry.message then hearth.notify(player, reason or "No safe spawn found. Please choose another world seed.") end return end local verified = safe_exact(position, name, entry.avoid) if not verified then set_waiting(name, true) if entry.message then hearth.notify(player, "That arrival became unsafe; try /home again.") end return end local ok = pcall(function() player:set_pos(verified) player:set_velocity({x = 0, y = 0, z = 0}) if entry.initial then player:get_meta():set_int("hearth:arrived", 1) end end) if not ok then set_waiting(name, true); return end set_waiting(name, false) entry.session.recovery_avoid = {x = verified.x, y = verified.y, z = verified.z, until_us = C.get_us_time() + 30000000} if entry.message then hearth.notify(player, entry.success or "You arrived at a verified safe location.") end end local function save_spawn(position) if not spawn or spawn.x ~= position.x or spawn.y ~= position.y or spawn.z ~= position.z then local ok = pcall(P.save_world, "spawn", position) if not ok then return false end spawn = {x = position.x, y = position.y, z = position.z} end return true end local function bounded_spawn(name, avoid) local candidates = {} for x = -spawn_radius, spawn_radius, 4 do for z = -spawn_radius, spawn_radius, 4 do for ground_y = MAX_SURFACE_Y, 0, -1 do local def, missing = node_at({x = x, y = ground_y, z = z}) if missing or not def then break end local groups = def.groups or {} if natural_surface(def) then local safe if math.abs(x) < spawn_radius and math.abs(z) < spawn_radius then safe = safe_surface({x = x, y = ground_y + 1, z = z}, name, avoid) end if safe then candidates[#candidates + 1] = safe end -- Never descend through terrain to an arbitrary cave floor. break elseif liquid(def) or (def.damage_per_second or 0) > 0 or (def.walkable ~= false and (groups.tree or 0) <= 0 and (groups.leaves or 0) <= 0 and (groups.hearth_canopy or 0) <= 0) then break end end end end if #candidates == 0 then return nil end return candidates[(math.floor(C.get_us_time() / 1000) % #candidates) + 1] end local process_all local function mark_emerged(key) for _, entry in pairs(waiting) do if entry.emerged[key] == "pending" then entry.emerged[key] = "done" end end end local function emerge_once(key, minimum, maximum) if emerging[key] then return end emerging[key] = true C.emerge_area(minimum, maximum, function(_, action, remaining) if action == C.EMERGE_ERRORED or action == C.EMERGE_CANCELLED then remaining = 0 end if remaining ~= 0 then return end emerging[key] = nil mark_emerged(key) C.after(0, process_all) end) end local function target_key(pos) return math.floor(pos.x + 0.5) .. ":" .. math.floor(pos.y + 0.5) .. ":" .. math.floor(pos.z + 0.5) end local function schedule_process(name, entry) if entry.scheduled then return end entry.scheduled = true C.after(0, function() if waiting[name] == entry then entry.scheduled = false; process_all() end end) end process_all = function() for name, entry in pairs(waiting) do if not connected_waiter(name, entry) then waiting[name] = nil else local target = entry.targets[entry.index] if target then local resolved, missing if target.kind == "rescan" then -- Probe the entire bounded volume, not just the old low-altitude hint. missing = entry.emerged["bounded-spawn-rescan"] ~= "done" else resolved, missing = nearby_safe(target.pos, name, entry.avoid, target.kind == "spawn") end if resolved then if (target.kind == "spawn" or target.kind == "rescan") and not save_spawn(resolved) then finish_waiter(name, entry, nil, "Safe arrival storage failed; your data was retained.") else finish_waiter(name, entry, resolved) end elseif target.kind == "rescan" then local key = "bounded-spawn-rescan" if missing and entry.emerged[key] == "pending" then -- A shared emergence is already servicing this waiter. elseif missing and entry.emerged[key] ~= "done" then entry.emerged[key] = "pending" emerge_once(key, {x = -spawn_radius, y = 0, z = -spawn_radius}, {x = spawn_radius, y = MAX_SURFACE_Y + 2, z = spawn_radius}) else local candidate = bounded_spawn(name, entry.avoid) if candidate and save_spawn(candidate) then finish_waiter(name, entry, candidate) elseif candidate then finish_waiter(name, entry, nil, "Safe arrival storage failed; your data was retained.") else finish_waiter(name, entry, nil, "No safe arrival was found in the bounded spawn area. Stay safe and try /home again.") end end elseif missing then local key = target_key(target.pos) if entry.emerged[key] == "done" then entry.index = entry.index + 1 schedule_process(name, entry) elseif not entry.emerged[key] then entry.emerged[key] = "pending" emerge_once(key, {x = target.pos.x - 8, y = target.pos.y - 8, z = target.pos.z - 8}, {x = target.pos.x + 8, y = target.pos.y + 8, z = target.pos.z + 8}) end elseif target.kind ~= "rescan" then entry.index = entry.index + 1 schedule_process(name, entry) end else finish_waiter(name, entry, nil) end end end end local function begin_recovery(player, initial, message, prefer_home, avoid_current) local name = player:get_player_name() local session = hearth.sessions[name] if not session then return false end local now, avoid = C.get_us_time(), {} if avoid_current then local current = player:get_pos() avoid[#avoid + 1] = {x = current.x, y = current.y, z = current.z} end if avoid_current and session.recovery_avoid and now < session.recovery_avoid.until_us then avoid[#avoid + 1] = session.recovery_avoid end local targets = {} if prefer_home then local home = hearth.home_for(name) if home then targets[#targets + 1] = {kind = "home", pos = home} end end if spawn then targets[#targets + 1] = {kind = "spawn", pos = {x = spawn.x, y = spawn.y, z = spawn.z}} end targets[#targets + 1] = {kind = "rescan", pos = {x = 0, y = 32, z = 0}} local entry = {player = player, session = session, initial = initial, message = message, success = "You arrived at a verified safe location.", avoid = avoid, targets = targets, index = 1, emerged = {}} waiting[name] = entry set_waiting(name, true) process_all() return true end function hearth.safe_arrival(player) return begin_recovery(player, false, false, true, true) end function hearth.set_home(player) local name = player:get_player_name() local session = hearth.sessions[name] if not session or session.waiting then return false, "Wait until your arrival is safe before setting a home." end local pos = player:get_pos() local point = {x = math.floor(pos.x + 0.5), y = math.floor(pos.y + 0.5), z = math.floor(pos.z + 0.5)} if not hearth.in_own_claim(point, name) then return false, "Stand inside your own homestead claim before setting a home." end local safe = safe_exact(point, name) if not safe then return false, "Home must be dry, grounded, supported, and clear above your claim." end hearth.store_home(name, safe) return true, "Safe home saved inside your homestead." end function hearth.go_home(player) local name = player:get_player_name() if not hearth.sessions[name] then return false, "Join the world first." end begin_recovery(player, false, true, true, false) if hearth.home_for(name) then return true, "Checking your home and safe fallback before moving you." end return true, "No personal home is saved; checking world spawn and safe fallback." end function hearth.player_join(player) local name = player:get_player_name() local ok, saved = pcall(P.load_player, player) if not ok or type(saved) ~= "table" then stop_player(player, "load"); return end local meta = player:get_meta() hearth.players[name], hearth.sessions[name] = saved, {page = 1, damage = 0, save = 0, ready = false} local session = hearth.sessions[name] local inv = player:get_inventory() inv:set_size("main", 40); inv:set_size("craft", 9); inv:set_width("craft", 3) inv:set_size("craftpreview", 1); inv:set_size("craftresult", 1) player:set_properties({visual = "mesh", mesh = "hearth_person.b3d", textures = {"hearth_person.png"}, visual_size = {x = 1, y = 1}, collisionbox = {-0.3, 0, -0.3, 0.3, 1.75, 0.3}, stepheight = 0.6, eye_height = 1.55, hp_max = 20}) if hearth.character then hearth.character.on_join(player) end player:set_armor_groups({fleshy = 100}) hearth.inventory(player); hearth.make_hud(player) player:set_clouds({density = 0.35, color = "#bec4c8e0", height = 140, speed = {x = 2, z = -1}}) if meta:get_int("hearth:arrived") == 0 then begin_recovery(player, true, false, true, false) end C.after(1, function() local current = C.get_player_by_name(name) if not current or hearth.sessions[name] ~= session then return end if not hearth.has_location and C.is_singleplayer() then hearth.location_form(current) else hearth.guide(current) end end) end function hearth.player_leave(player) hearth.player_save(player) local name = player:get_player_name() hearth.players[name], hearth.sessions[name], waiting[name] = nil, nil, nil end function hearth.player_respawn(player) local name = player:get_player_name() local state, session = hearth.players[name], hearth.sessions[name] if not state or not session then return true end state.stamina = 35 state.warmth = math.min(state.warmth, 60) begin_recovery(player, false, false, true, true) return true end C.register_on_joinplayer(hearth.player_join) C.register_on_leaveplayer(hearth.player_leave) C.register_on_shutdown(function() for _, player in ipairs(C.get_connected_players()) do hearth.player_save(player) end end) C.register_on_respawnplayer(hearth.player_respawn) C.register_on_punchplayer(function() return true end)