-- Real-time hearth processes; construction supplies the shared access check. return function(hearth, api, construction) local allowed = construction.allowed local MAX_FUEL = 600 -- Exact names only: building timber, charges and arbitrary tree groups are not fuel. local FUEL = { ["hearth:sticks"] = 45, ["hearth:log"] = 180, ["hearth:charcoal"] = 90, ["hearth:coal"] = 240, ["hearth:oak_log"] = 180, ["hearth:birch_log"] = 180, ["hearth:pine_log"] = 180, ["hearth:willow_log"] = 180, ["hearth:alder_log"] = 180, ["hearth:beech_log"] = 180, ["hearth:aspen_log"] = 180, ["hearth:rowan_log"] = 180, ["hearth:hazel_log"] = 180, ["hearth:spruce_log"] = 180 } local JOBS = { ["hearth:raw_food"] = {kind = "cook", duration = 6, cost = 20, result = "hearth:cooked_food"}, ["hearth:raw_fish"] = {kind = "roast", duration = 8, cost = 20, result = "hearth:cooked_fish"}, ["hearth:water_raw"] = {kind = "boil", duration = 4, cost = 10, result = "hearth:water_clean"}, ["hearth:smelt_charge"] = {kind = "smelt", duration = 60, cost = 30, result = "hearth:iron_ingot"}, ["hearth:copper_charge"] = {kind = "smelt_copper", duration = 45, cost = 30, result = "hearth:copper_ingot"}, ["hearth:dried_food_charge"] = {kind = "dry", duration = 300, cost = 30, result = "hearth:dried_food"}, ["hearth:clay_jar_charge"] = {kind = "fire", duration = 120, cost = 30, result = "hearth:clay_jar"}, ["hearth:charcoal_charge"] = {kind = "char", duration = 60, cost = 30, result = "hearth:charcoal"}, ["hearth:fish_drying_charge"] = {kind = "dry_fish", duration = 300, cost = 30, result = "hearth:dried_fish"}, } local RESULTS = { ["hearth:cooked_food"] = true, ["hearth:water_clean"] = true, ["hearth:iron_ingot"] = true, ["hearth:copper_ingot"] = true, ["hearth:dried_food"] = true, ["hearth:clay_jar"] = true, ["hearth:charcoal"] = true, ["hearth:cooked_fish"] = true, ["hearth:dried_fish"] = true } local function is_fire(pos) local name = api.get_node(pos).name return name == "hearth:fire" or name == "hearth:fire_lit" end local function fuel_left(pos, now) local deadline = tonumber(api.get_meta(pos):get_string("fuel_until")) or 0 if deadline ~= deadline or deadline == math.huge or deadline == -math.huge then return 0 end return math.max(0, math.min(MAX_FUEL, deadline - now)) end local function job_deadline(meta) local raw = meta:get_string("job_until") if raw == "" then return 0 end local value = tonumber(raw) if not value or value ~= value or value == math.huge or value == -math.huge then return nil end return math.floor(value) end local function valid_job(meta) local kind = meta:get_string("job_kind") if kind == "" then return nil end local until_time, cost = job_deadline(meta), meta:get_int("job_cost") local result = meta:get_string("job_result") if not until_time or until_time <= 0 then return nil end for _, job in pairs(JOBS) do if kind == job.kind and cost == job.cost and result == job.result then return {kind = kind, until_time = until_time, cost = cost, result = result} end end return nil end local function clear_job(meta) meta:set_string("job_kind", "") meta:set_string("job_until", "0") meta:set_int("job_cost", 0) end local function pending_output(meta) local result = meta:get_string("job_result") if meta:get_string("job_kind") == "" and job_deadline(meta) == 0 and meta:get_int("job_cost") == 0 and RESULTS[result] then return result end return "" end local function schedule_timer(pos, remaining, now) local meta = api.get_meta(pos) local wait if remaining > 0 then wait = remaining end local job = valid_job(meta) if job then local job_wait = job.until_time - now if job_wait <= 0 then job_wait = 0.1 end wait = wait and math.min(wait, job_wait) or job_wait end local timer = api.get_node_timer(pos) if wait then timer:start(math.max(0.1, wait)) else timer:stop() end end local function refresh_fire(pos, remaining, now) local meta = api.get_meta(pos) now = now or os.time() remaining = remaining == nil and fuel_left(pos, now) or remaining if remaining <= 0 then remaining = 0 meta:set_string("fuel_until", "0") meta:set_int("lit", 0) end local job = valid_job(meta) local output = pending_output(meta) local lit = meta:get_int("lit") == 1 and remaining > 0 meta:set_int("lit", lit and 1 or 0) if output ~= "" and not job then meta:set_string("infotext", "Last Hearth: finished " .. output .. " ready to collect") elseif job then local verb = ({cook = "cooking", roast = "roasting fish", boil = "boiling", smelt = "smelting iron", smelt_copper = "smelting copper", dry = "drying food", dry_fish = "drying fish", fire = "firing", char = "making charcoal"})[job.kind] or "processing" meta:set_string("infotext", "Last Hearth: " .. verb .. "; " .. math.max(0, math.ceil(job.until_time - now)) .. "s remaining") elseif lit then meta:set_string("infotext", "Last Hearth: lit; " .. math.ceil(remaining) .. "s fuel; feed sticks/logs/charcoal/coal or process a charge") elseif remaining > 0 then meta:set_string("infotext", "Fueled but cold hearth: ignite with a firestarter") else meta:set_string("infotext", "Cold hearth: feed dry sticks or a log") end local node = api.get_node(pos) local name = lit and "hearth:fire_lit" or "hearth:fire" if node.name ~= name then api.swap_node(pos, {name = name, param2 = node.param2}) end schedule_timer(pos, remaining, now) end local function finish_job(_, meta, job) clear_job(meta) -- Clear the reservation before committing its one output. meta:set_string("job_result", job.result) end local function reconcile_job(pos, now) local meta = api.get_meta(pos) local kind = meta:get_string("job_kind") if kind == "" then local output = meta:get_string("job_result") if (output ~= "" and pending_output(meta) == "") or job_deadline(meta) ~= 0 or meta:get_int("job_cost") ~= 0 then clear_job(meta) meta:set_string("job_result", "") end return end local job = valid_job(meta) if not job or job.until_time <= 0 then clear_job(meta) meta:set_string("job_result", "") return end if job.until_time <= now then finish_job(pos, meta, job) end end local function fire_tick(pos) if not is_fire(pos) then return false end local now = os.time() reconcile_job(pos, now) refresh_fire(pos, fuel_left(pos, now), now) return false -- refresh_fire schedules only the next job/fuel deadline. end local function use_fire(pos, _, player, stack) if not is_fire(pos) or not allowed(pos, player, "use") then return stack end local now = os.time() local remaining = fuel_left(pos, now) local meta = api.get_meta(pos) if stack:is_empty() then local result = pending_output(meta) if result ~= "" then local inv = player:get_inventory() local output = ItemStack(result) if inv and inv:room_for_item("main", output) then local leftover = inv:add_item("main", output) if leftover:is_empty() then meta:set_string("job_result", "") end else hearth.notify(player, "Make inventory space to collect the hearth output.") end end refresh_fire(pos, remaining, now) return stack end local item = stack:get_name() local burn = FUEL[item] if burn then if remaining + burn <= MAX_FUEL then stack:take_item(1) remaining = remaining + burn meta:set_string("fuel_until", tostring(now + remaining)) else hearth.notify(player, "The hearth has no room for that much fuel yet.") end else local job = JOBS[item] if job then if meta:get_string("job_kind") ~= "" or meta:get_string("job_result") ~= "" then hearth.notify(player, "Collect the finished hearth output first.") elseif meta:get_int("lit") ~= 1 then hearth.notify(player, "Ignite the fueled hearth before cooking or boiling.") elseif remaining < job.duration + job.cost then hearth.notify(player, "Add more fuel to reserve the full hearth process.") else meta:set_string("job_kind", job.kind) meta:set_string("job_until", tostring(now + job.duration)) meta:set_int("job_cost", job.cost) meta:set_string("job_result", job.result) -- Reserve heat cost at queue time; elapsed processing burns -- naturally against the absolute UTC deadline. Paused servers do -- not grant free fuel, but a completed offline job remains queued. remaining = remaining - job.cost meta:set_string("fuel_until", tostring(now + remaining)) -- Start the timer before consuming input; queued output is persistent. schedule_timer(pos, remaining, now) stack:take_item(1) end end end refresh_fire(pos, remaining, now) return stack end local function ignite_fire(pos, player) if not is_fire(pos) or not allowed(pos, player, "use") then return false end local meta = api.get_meta(pos) local now = os.time() local remaining = fuel_left(pos, now) if meta:get_int("lit") == 1 or remaining <= 0 then refresh_fire(pos, remaining, now) return false end meta:set_int("lit", 1) refresh_fire(pos, remaining, now) if api.sound_play then api.sound_play("hearth_fire", {pos = pos, gain = 0.5, max_hear_distance = 16}) end return true end local function use_firestarter(stack, player, pointed) if pointed and pointed.type == "node" and ignite_fire(pointed.under, player) then stack:add_wear(math.ceil(65535 / 20)) end return stack end return {use_fire = use_fire, fire_tick = fire_tick, refresh_fire = refresh_fire, fuel_left = fuel_left, ignite_fire = ignite_fire, use_firestarter = use_firestarter} end