Files
last-hearth/mods/hearth/construction.lua
T
2026-11-03 12:41:09 -05:00

229 lines
9.2 KiB
Lua
Executable File

-- 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