-- Construction helpers; dependencies are supplied by the content registrar. return function(hearth, api) local MAX_REACH = 6 local directions = { {x = 1, y = 0, z = 0}, {x = -1, y = 0, z = 0}, {x = 0, y = 0, z = 1}, {x = 0, y = 0, z = -1}, } local function near(player, pos) if not player or not player:is_player() then return false end local p = player:get_pos() if not p then return false end local dx, dy, dz = p.x - pos.x, p.y - pos.y, p.z - pos.z return dx * dx + dy * dy + dz * dz <= MAX_REACH * MAX_REACH end local function allowed(pos, player, action) if not near(player, pos) then return false end local name = player:get_player_name() local protected = hearth.external_protected or api.is_protected if (protected and protected(pos, name)) or not hearth.claim_allowed(pos, name, action or "build") then api.record_protection_violation(pos, name) return false end return true end local removal_safe local function can_dig(pos, player) if not allowed(pos, player, "dig") then return false end if removal_safe(pos, player) then return true end if player and hearth.feedback then hearth.feedback(player, "Keep the connected structure supported and within one homestead before removing this block.", "warning") elseif player then hearth.notify(player, "Keep connected construction supported before removing this block.") end return false end local function node_def(pos) local node = api.get_node_or_nil(pos) return node and api.registered_nodes[node.name], node end local function placement_pos(pointed) if not pointed or pointed.type ~= "node" then return nil end local under = node_def(pointed.under) local above = node_def(pointed.above) if not under or not above then return nil end if under.buildable_to then return pointed.under end if above.buildable_to then return pointed.above end end local function place(stack, player, pointed) if not pointed or pointed.type ~= "node" then return stack end local def = node_def(pointed.under) -- item_place dispatches right-click callbacks before attempting placement. if def and def.on_rightclick and player and not player:get_player_control().sneak then local action = def.groups and (def.groups.hearth_storage or 0) > 0 and "storage" or "use" if not allowed(pointed.under, player, action) then return stack end if action == "storage" then return def.on_rightclick(pointed.under, api.get_node(pointed.under), player, stack, pointed) or stack end return api.item_place(stack, player, pointed) end local pos = placement_pos(pointed) if not pos or not allowed(pos, player) then return stack end return api.item_place(stack, player, pointed) end local function is_building(name) return name == "hearth:wall" or name == "hearth:roof" or name == "hearth:foundation" or name == "hearth:frame" or name == "hearth:stairs" or name == "hearth:door" or name == "hearth:door_open" or name == "hearth:window" or name == "hearth:vent" end local function solid(def) return def and def.walkable ~= false and not def.buildable_to and (not def.liquidtype or def.liquidtype == "none") and not (def.groups and (def.groups.leaves or def.groups.hearth_non_support)) end local function same_pos(a, b) return a.x == b.x and a.y == b.y and a.z == b.z end local function household_owner(pos) return hearth.claim_owner_at and hearth.claim_owner_at(pos) or nil end local function same_household(target, support) local target_owner = household_owner(target) local support_owner = household_owner(support) return support_owner == nil or support_owner == target_owner end local function supported(pos, ignore) -- Search existing construction only: at most two horizontal links and -- sixteen blocks down. The horizontal budget never resets at a new block, -- so sequential placement cannot extend a cantilever indefinitely. local queue = {{pos = pos, span = 0, depth = 0}} local seen, index, unloaded = {}, 1, false while index <= #queue do local entry = queue[index] index = index + 1 local p = entry.pos local key = p.x .. ":" .. p.y .. ":" .. p.z .. ":" .. entry.span if not seen[key] then seen[key] = true local below = {x = p.x, y = p.y - 1, z = p.z} local def, node if ignore and same_pos(below, ignore) then def, node = api.registered_nodes.air or {walkable = false, buildable_to = true}, {name = "air"} else def, node = node_def(below) end if not def or node.name == "ignore" then unloaded = true end if solid(def) and node.name ~= "ignore" and same_household(pos, below) then if not is_building(node.name) then return true end if entry.depth < 15 then queue[#queue + 1] = {pos = below, span = entry.span, depth = entry.depth + 1} end end if entry.span < 2 then for _, d in ipairs(directions) do local adjacent = {x = p.x + d.x, y = p.y, z = p.z + d.z} local _, n if ignore and same_pos(adjacent, ignore) then n = {name = "air"} else _, n = node_def(adjacent) end if n and is_building(n.name) and same_household(pos, adjacent) then queue[#queue + 1] = {pos = adjacent, span = entry.span + 1, depth = entry.depth} end end end end end return false, unloaded and "unloaded" or "support" end -- Refuse support removal rather than cascading destruction. Check the maximum -- span/height a placement may legally depend on, and fail closed across claims. removal_safe = function(pos, player) local target = api.get_node_or_nil(pos) if not target or target.name == "ignore" then return false end local target_def = api.registered_nodes[target.name] if not solid(target_def) then return true end local name = player:get_player_name() local removed_owner = household_owner(pos) for y = 0, 16 do for dx = -2, 2 do for dz = -2, 2 do local candidate = {x = pos.x + dx, y = pos.y + y, z = pos.z + dz} if not same_pos(candidate, pos) then local node = api.get_node_or_nil(candidate) if not node or node.name == "ignore" then return false end if is_building(node.name) then local dependent_owner = household_owner(candidate) if dependent_owner and dependent_owner ~= removed_owner then return false end if not hearth.claim_allowed(candidate, name, "dig") then return false end local ok = supported(candidate, pos) if not ok then return false end end end end end end return true end -- pos is the resolved destination, not a pointed_thing. This read-only -- query deliberately does not call allowed(): previews must never record a -- violation, consume an ItemStack, play sound, or create node metadata. local function preview(pos, player, item) if type(pos) ~= "table" then return false, "invalid" end for _, axis in ipairs({"x", "y", "z"}) do local n = pos[axis] if type(n) ~= "number" or n ~= n or math.abs(n) > 31000 or n % 1 ~= 0 then return false, "invalid" end end if not near(player, pos) then return false, "distance" end local name = player:get_player_name() if api.is_protected(pos, name) or not hearth.claim_allowed(pos, name) then return false, "protected" end local def, node = node_def(pos) if not def or node.name == "ignore" then return false, "unloaded" end if not def.buildable_to then return false, "occupied" end local itemname = type(item) == "string" and item if not itemname and item and (type(item) == "table" or type(item) == "userdata") and type(item.get_name) == "function" then itemname = item:get_name() end if type(itemname) ~= "string" or not api.registered_nodes[itemname] then return false, "item" end if is_building(itemname) then local ok, reason = supported(pos) if not ok then return false, reason end end return true, "ok" end local function hint(player, message) -- Retain chat compatibility for existing callers/tests without feedback. if hearth.feedback then hearth.feedback(player, message, "warning") else hearth.notify(player, message) end if hearth.feedback_sound then hearth.feedback_sound("hearth_denial", player, 0.2) end end local function place_building(stack, player, pointed) if not pointed or pointed.type ~= "node" then return stack end local under = node_def(pointed.under) if under and under.on_rightclick and player and not player:get_player_control().sneak then if not allowed(pointed.under, player) then return stack end return api.item_place(stack, player, pointed) end local pos = placement_pos(pointed) if not pos or not allowed(pos, player) then return stack end if not supported(pos) then hint(player, "Needs solid support within two horizontal blocks (maximum height 16).") return stack end return api.item_place(stack, player, pointed) end local function owner(pos, player) if player and player:is_player() then api.get_meta(pos):set_string("owner", player:get_player_name()) end end return {near = near, allowed = allowed, can_dig = can_dig, place = place, place_building = place_building, owner = owner, preview = preview, removal_safe = removal_safe} end