Files
2026-11-03 12:41:09 -05:00

378 lines
16 KiB
Lua
Executable File

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)