-- Isolated M2 hearth process tests; pass mods/hearth/processes.lua as argument. assert(arg and arg[1], "Pass the path to mods/hearth/processes.lua") local now, game_now = 100000, 500 local protected, claimed, distant, violations = false, true, false, 0 local world, metadata, timers, sounds = {}, {}, {}, {} local function key(pos) return pos.x .. ":" .. pos.y .. ":" .. pos.z end local hearth_pos = {x = 0, y = 0, z = 0} local function stack(text) local name, count = (text or ""):match("^(%S+)%s*(%d*)$") return setmetatable({name = name or "", count = name and (tonumber(count) or 1) or 0, wear = 0}, { __index = { get_name = function(self) return self.name end, get_count = function(self) return self.count end, is_empty = function(self) return self.count == 0 end, take_item = function(self, amount) self.count = math.max(0, self.count - (amount or 1)) if self.count == 0 then self.name = "" end end, add_wear = function(self, amount) self.wear = self.wear + amount if self.wear >= 65535 then self.name, self.count = "", 0 end end, }, }) end ItemStack = stack local function meta_for(pos) local k = key(pos) if not metadata[k] then local fields = {} metadata[k] = { set_string = function(_, name, value) fields[name] = tostring(value) end, get_string = function(_, name) return fields[name] or "" end, set_int = function(_, name, value) fields[name] = math.floor(value) end, get_int = function(_, name) return tonumber(fields[name]) or 0 end, } end return metadata[k] end local api = {} function api.get_meta(pos) return meta_for(pos) end function api.get_node(pos) return world[key(pos)] or {name = "air"} end function api.swap_node(pos, node) world[key(pos)] = node end function api.get_gametime() return game_now end function api.get_node_timer(pos) local k = key(pos) if not timers[k] then timers[k] = { start = function(self, timeout) self.timeout = timeout end, stop = function(self) self.timeout = nil end, } end return timers[k] end function api.sound_play(name, params) sounds[#sounds + 1] = {name = name, params = params} end local player local function allowed(pos, who) local dx = pos.x - who.position.x local dy = pos.y - who.position.y local dz = pos.z - who.position.z if protected or not claimed or distant or dx * dx + dy * dy + dz * dz > 36 then violations = violations + 1 return false end return true end local construction = {allowed = allowed} local hearth = {} function hearth.notify(_, message) hearth.last_notice = message end local processes = dofile(arg[1])(hearth, api, construction) local inventory = {room = true, added = {}, reject_next = false} function inventory:room_for_item(_, item) return self.room and not item:is_empty() end function inventory:add_item(_, item) if self.reject_next then self.reject_next = false return stack(item:get_name()) end self.added[#self.added + 1] = item:get_name() return stack("") end player = {position = {x = 0, y = 0, z = 0}} function player:get_inventory() return inventory end local real_time = os.time os.time = function() return now end local function eq(actual, expected) assert(actual == expected, "expected " .. tostring(expected) .. ", got " .. tostring(actual)) end local passed = 0 local function reset() now, game_now = 100000, 500 protected, claimed, distant, violations = false, true, false, 0 world, metadata, timers, sounds = {}, {}, {}, {} inventory.room, inventory.added, inventory.reject_next = true, {}, false player.position = {x = 0, y = 0, z = 0} hearth.last_notice = nil world[key(hearth_pos)] = {name = "hearth:fire", param2 = 2} local meta = api.get_meta(hearth_pos) meta:set_int("lit", 0) meta:set_string("fuel_until", "0") end local function test(name, fn) reset() local ok, err = pcall(fn) if not ok then error(name .. ": " .. tostring(err), 0) end passed = passed + 1 print("ok " .. passed .. " - " .. name) end local function use(text) local item = stack(text) processes.use_fire(hearth_pos, api.get_node(hearth_pos), player, item) return item end local function starter(item, pos) return processes.use_firestarter(item, player, {type = "node", under = pos or hearth_pos}) end local function deadline(pos) return tonumber(api.get_meta(pos or hearth_pos):get_string("fuel_until")) or 0 end local function advance(seconds) now, game_now = now + seconds, game_now + seconds local timer = api.get_node_timer(hearth_pos) if timer.timeout then timer.timeout = timer.timeout - seconds if timer.timeout <= 0 then processes.fire_tick(hearth_pos) end end end test("fuel does not ignite itself and a firestarter is reusable", function() local firestarter = stack("hearth:firestarter") starter(firestarter) eq(firestarter.wear, 0) eq(api.get_node(hearth_pos).name, "hearth:fire") eq(use("hearth:sticks"):get_count(), 0) eq(api.get_node(hearth_pos).name, "hearth:fire") eq(api.get_meta(hearth_pos):get_int("lit"), 0) starter(firestarter) eq(api.get_node(hearth_pos).name, "hearth:fire_lit") eq(firestarter.wear, math.ceil(65535 / 20)) local second = {x = 1, y = 0, z = 0} world[key(second)] = {name = "hearth:fire", param2 = 0} api.get_meta(second):set_int("lit", 0) api.get_meta(second):set_string("fuel_until", "0") processes.use_fire(second, api.get_node(second), player, stack("hearth:sticks")) starter(firestarter, second) eq(firestarter.wear, math.ceil(65535 / 20) * 2) for _ = 3, 20 do local meta = api.get_meta(hearth_pos) meta:set_int("lit", 0) processes.refresh_fire(hearth_pos, processes.fuel_left(hearth_pos, now), now) starter(firestarter) end eq(firestarter.wear, math.ceil(65535 / 20) * 20) eq(firestarter:is_empty(), true) eq(#sounds, 20) eq(sounds[1].name, "hearth_fire") end) test("cooking waits six real seconds and reserves the heat cost at start", function() use("hearth:sticks") starter(stack("hearth:firestarter")) local input = use("hearth:raw_food 2") eq(input:get_count(), 1) eq(api.get_meta(hearth_pos):get_int("job_until"), now + 6) eq(api.get_meta(hearth_pos):get_int("job_cost"), 20) eq(deadline(), now + 25) eq(api.get_node_timer(hearth_pos).timeout, 6) advance(5) eq(api.get_meta(hearth_pos):get_string("job_kind"), "cook") eq(deadline(), now + 20) advance(1) eq(api.get_meta(hearth_pos):get_string("job_kind"), "") eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:cooked_food") eq(deadline(), now + 19) processes.fire_tick(hearth_pos) eq(deadline(), now + 19) end) test("boiling waits four gameplay seconds and charges its own fuel cost", function() use("hearth:sticks") starter(stack("hearth:firestarter")) use("hearth:water_raw") eq(api.get_meta(hearth_pos):get_int("job_until"), now + 4) eq(api.get_meta(hearth_pos):get_int("job_cost"), 10) advance(3) eq(api.get_meta(hearth_pos):get_string("job_kind"), "boil") eq(deadline(), now + 32) advance(1) eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:water_clean") eq(deadline(), now + 31) end) test("process start requires the complete duration-plus-cost fuel reserve", function() local meta = api.get_meta(hearth_pos) meta:set_int("lit", 1) meta:set_string("fuel_until", tostring(now + 25)) local too_little = use("hearth:raw_food") eq(too_little:get_count(), 1) eq(meta:get_string("job_kind"), "") meta:set_string("fuel_until", tostring(now + 27)) local enough = use("hearth:raw_food") eq(enough:get_count(), 0) eq(meta:get_string("job_kind"), "cook") advance(6) eq(deadline(), now + 1) eq(api.get_node(hearth_pos).name, "hearth:fire_lit") end) test("inventory rejection preserves completed output until a successful claim", function() use("hearth:sticks") starter(stack("hearth:firestarter")) use("hearth:raw_food") advance(6) inventory.room = false processes.use_fire(hearth_pos, api.get_node(hearth_pos), player, stack("")) eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:cooked_food") inventory.room, inventory.reject_next = true, true processes.use_fire(hearth_pos, api.get_node(hearth_pos), player, stack("")) eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:cooked_food") eq(#inventory.added, 0) processes.use_fire(hearth_pos, api.get_node(hearth_pos), player, stack("")) eq(api.get_meta(hearth_pos):get_string("job_result"), "") eq(#inventory.added, 1) eq(inventory.added[1], "hearth:cooked_food") end) test("a queued job and pending output block replacement or a second queue", function() use("hearth:sticks") starter(stack("hearth:firestarter")) use("hearth:raw_food") eq(use("hearth:water_raw"):get_count(), 1) eq(api.get_meta(hearth_pos):get_string("job_kind"), "cook") advance(6) eq(use("hearth:water_raw"):get_count(), 1) eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:cooked_food") end) test("load reconciliation resumes remaining time and completes overdue jobs once", function() use("hearth:sticks") starter(stack("hearth:firestarter")) use("hearth:water_raw") game_now, now = game_now + 2, now + 2 -- Simulate shutdown without timer callbacks. processes.fire_tick(hearth_pos) -- The registered LBM callback. eq(api.get_node_timer(hearth_pos).timeout, 2) advance(2) eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:water_clean") local charged = deadline() game_now, now = game_now + 20, now + 20 processes.fire_tick(hearth_pos) processes.fire_tick(hearth_pos) eq(deadline(), charged) eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:water_clean") end) test("charcoal sustains M4 iron-smelting charges and produces one ingot", function() use("hearth:charcoal") starter(stack("hearth:firestarter")) local charge = use("hearth:smelt_charge") eq(charge:get_count(), 0) eq(api.get_meta(hearth_pos):get_string("job_kind"), "smelt") eq(deadline(), now + 60) advance(60) eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:iron_ingot") use("") eq(inventory.added[1], "hearth:iron_ingot") eq(api.get_meta(hearth_pos):get_string("job_result"), "") end) for _, species in ipairs({"oak", "birch", "pine", "willow", "alder", "beech", "aspen", "rowan", "hazel", "spruce"}) do test(species .. " logs fuel directly for 180 seconds without ignition or overflow", function() eq(use("hearth:" .. species .. "_log 2"):get_count(), 1) eq(deadline(), now + 180) eq(api.get_meta(hearth_pos):get_int("lit"), 0) use("hearth:" .. species .. "_log") use("hearth:" .. species .. "_log") eq(deadline(), now + 540) eq(use("hearth:" .. species .. "_log"):get_count(), 1) eq(deadline(), now + 540) end) end test("coal adds 240 seconds and rejects overflow without consuming an item", function() eq(use("hearth:coal 3"):get_count(), 2) eq(deadline(), now + 240) eq(api.get_meta(hearth_pos):get_int("lit"), 0) use("hearth:coal") eq(deadline(), now + 480) eq(use("hearth:coal"):get_count(), 1) eq(deadline(), now + 480) advance(120) eq(use("hearth:coal"):get_count(), 0) eq(processes.fuel_left(hearth_pos, now), 600) eq(use("hearth:sticks"):get_count(), 1) end) test("coal fuels copper smelting for 45 seconds with 30 reserved and one ingot", function() use("hearth:coal") starter(stack("hearth:firestarter")) eq(use("hearth:copper_charge 2"):get_count(), 1) local meta = api.get_meta(hearth_pos) eq(meta:get_string("job_kind"), "smelt_copper") eq(meta:get_int("job_until"), now + 45) eq(meta:get_int("job_cost"), 30) eq(deadline(), now + 210) eq(api.get_node_timer(hearth_pos).timeout, 45) assert(meta:get_string("infotext"):find("smelting copper", 1, true)) advance(44) eq(meta:get_string("job_kind"), "smelt_copper") eq(use(""):get_count(), 0) eq(#inventory.added, 0) advance(1) eq(meta:get_string("job_kind"), "") eq(meta:get_string("job_result"), "hearth:copper_ingot") eq(processes.fuel_left(hearth_pos, now), 165) use(""); use(""); processes.fire_tick(hearth_pos) eq(#inventory.added, 1) eq(inventory.added[1], "hearth:copper_ingot") eq(meta:get_string("job_result"), "") end) test("copper needs ignition and exactly duration plus cost before consuming its charge", function() use("hearth:coal") eq(use("hearth:copper_charge"):get_count(), 1) starter(stack("hearth:firestarter")) local meta = api.get_meta(hearth_pos) meta:set_string("fuel_until", tostring(now + 74)) eq(use("hearth:copper_charge"):get_count(), 1) eq(meta:get_string("job_kind"), "") eq(deadline(), now + 74) meta:set_string("fuel_until", tostring(now + 75)) eq(use("hearth:copper_charge"):get_count(), 0) eq(deadline(), now + 45) advance(45) eq(meta:get_string("job_result"), "hearth:copper_ingot") eq(processes.fuel_left(hearth_pos, now), 0) end) test("copper reload resumes then completes long-offline output idempotently", function() use("hearth:pine_log") starter(stack("hearth:firestarter")) use("hearth:copper_charge") local charged = deadline() now = now + 20 -- No timer callback while unloaded. local reloaded = dofile(arg[1])(hearth, api, construction) reloaded.fire_tick(hearth_pos) eq(api.get_node_timer(hearth_pos).timeout, 25) eq(deadline(), charged) now = now + 86400 * 30 for _ = 1, 3 do reloaded.fire_tick(hearth_pos) end local meta = api.get_meta(hearth_pos) eq(meta:get_string("job_result"), "hearth:copper_ingot") eq(meta:get_int("job_cost"), 0) eq(meta:get_string("job_kind"), "") eq(api.get_node(hearth_pos).name, "hearth:fire") eq(use("hearth:copper_charge"):get_count(), 1) -- Pending output cannot be overwritten. eq(use("hearth:smelt_charge"):get_count(), 1) use("") reloaded.fire_tick(hearth_pos); use("") eq(#inventory.added, 1) eq(inventory.added[1], "hearth:copper_ingot") end) test("full inventory and insertion rejection retain copper until exactly one collection", function() use("hearth:coal"); starter(stack("hearth:firestarter")); use("hearth:copper_charge") eq(use("hearth:copper_charge"):get_count(), 1) eq(use("hearth:smelt_charge"):get_count(), 1) advance(45) inventory.room = false use("") eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:copper_ingot") inventory.room, inventory.reject_next = true, true use("") eq(#inventory.added, 0) eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:copper_ingot") use(""); use("") eq(#inventory.added, 1) eq(inventory.added[1], "hearth:copper_ingot") end) test("authorization blocks new fuels copper queue and copper output before mutation", function() local function deny(mode) protected, claimed, distant = mode == "protected", mode ~= "claim", mode == "distance" end for _, mode in ipairs({"protected", "claim", "distance"}) do deny(mode) for _, fuel in ipairs({"coal", "oak_log", "birch_log", "pine_log", "willow_log", "alder_log", "beech_log", "aspen_log", "rowan_log", "hazel_log", "spruce_log"}) do eq(use("hearth:" .. fuel):get_count(), 1) end eq(deadline(), 0) eq(starter(stack("hearth:firestarter")).wear, 0) end protected, claimed, distant = false, true, false use("hearth:coal"); starter(stack("hearth:firestarter")) local fuel_deadline = deadline() for _, mode in ipairs({"protected", "claim", "distance"}) do deny(mode) eq(use("hearth:copper_charge"):get_count(), 1) eq(api.get_meta(hearth_pos):get_string("job_kind"), "") eq(deadline(), fuel_deadline) end protected, claimed, distant = false, true, false use("hearth:copper_charge"); advance(45) for _, mode in ipairs({"protected", "claim", "distance"}) do deny(mode); use("") eq(#inventory.added, 0) eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:copper_ingot") end protected, claimed, distant = false, true, false use("") eq(#inventory.added, 1) end) test("raw minerals planks and unknown species cannot act as fuel or charges", function() use("hearth:coal"); starter(stack("hearth:firestarter")) local fuel_deadline = deadline() for _, item in ipairs({"copper_ore", "quartz", "copper_ingot", "oak_planks", "beech_planks", "aspen_planks", "rowan_planks", "hazel_planks", "spruce_planks", "wall", "frame", "roof", "door", "storage", "deadwood", "spruce_mature", "beech_leaves", "granite_bricks", "unknown_log", "group:tree"}) do eq(use("hearth:" .. item):get_count(), 1) eq(api.get_meta(hearth_pos):get_string("job_kind"), "") eq(deadline(), fuel_deadline) end end) test("copper result cannot validate a forged iron job or invalid cost", function() local meta = api.get_meta(hearth_pos) for _, kind in ipairs({"smelt", "smelt_copper"}) do meta:set_string("job_kind", kind) meta:set_string("job_until", tostring(now - 1)) meta:set_int("job_cost", kind == "smelt" and 30 or 29) meta:set_string("job_result", "hearth:copper_ingot") processes.fire_tick(hearth_pos); use("") eq(meta:get_string("job_result"), "") eq(#inventory.added, 0) end end) test("drying charges preserve food with bounded offline completion", function() for _ = 1, 4 do use("hearth:charcoal") end starter(stack("hearth:firestarter")) local charge = use("hearth:dried_food_charge") eq(charge:get_count(), 0) eq(api.get_meta(hearth_pos):get_string("job_kind"), "dry") game_now, now = game_now + 300, now + 300 -- Simulate absence; LBM/timer reconciliation catches up once. processes.fire_tick(hearth_pos) eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:dried_food") processes.fire_tick(hearth_pos) eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:dried_food") use("") eq(inventory.added[1], "hearth:dried_food") end) test("pottery fires once and preserves its output across repeated reconciliation", function() use("hearth:charcoal") use("hearth:charcoal") starter(stack("hearth:firestarter")) local charge = use("hearth:clay_jar_charge") eq(charge:get_count(), 0) eq(api.get_meta(hearth_pos):get_string("job_kind"), "fire") eq(deadline(), now + 150) game_now, now = game_now + 120, now + 120 processes.fire_tick(hearth_pos) eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:clay_jar") processes.fire_tick(hearth_pos) eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:clay_jar") use("") eq(inventory.added[1], "hearth:clay_jar") end) test("one crafted charcoal preserves exactly one log of hearth fuel", function() use("hearth:log") starter(stack("hearth:firestarter")) local charge = use("hearth:charcoal_charge") eq(charge:get_count(), 0) eq(api.get_meta(hearth_pos):get_string("job_kind"), "char") advance(60) eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:charcoal") use("") eq(inventory.added[1], "hearth:charcoal") eq(deadline(), now + 90) use("hearth:charcoal") eq(deadline(), now + 180) end) test("raw river fish roasts once and its output is collected safely", function() use("hearth:charcoal") starter(stack("hearth:firestarter")) local raw = use("hearth:raw_fish") eq(raw:get_count(), 0) eq(api.get_meta(hearth_pos):get_string("job_kind"), "roast") advance(8) eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:cooked_fish") use("") eq(inventory.added[1], "hearth:cooked_fish") processes.fire_tick(hearth_pos) eq(inventory.added[1], "hearth:cooked_fish") end) test("dried river fish completes offline and stays shelf-stable output", function() use("hearth:log") use("hearth:log") starter(stack("hearth:firestarter")) local charge = use("hearth:fish_drying_charge") eq(charge:get_count(), 0) eq(api.get_meta(hearth_pos):get_string("job_kind"), "dry_fish") game_now, now = game_now + 300, now + 300 processes.fire_tick(hearth_pos) eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:dried_fish") processes.fire_tick(hearth_pos) eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:dried_fish") use("") eq(inventory.added[1], "hearth:dried_fish") end) test("invalid job metadata fails closed without producing or charging output", function() local meta = api.get_meta(hearth_pos) meta:set_int("lit", 1) meta:set_string("fuel_until", tostring(now + 90)) meta:set_string("job_kind", "cook") meta:set_int("job_until", game_now + 4) meta:set_int("job_cost", 999) meta:set_string("job_result", "hearth:cooked_food") processes.fire_tick(hearth_pos) eq(meta:get_string("job_kind"), "") eq(meta:get_string("job_result"), "") eq(deadline(), now + 90) processes.fire_tick(hearth_pos) eq(meta:get_string("job_result"), "") meta:set_string("job_result", "hearth:cooked_food") meta:set_int("job_cost", 20) processes.use_fire(hearth_pos, api.get_node(hearth_pos), player, stack("")) eq(#inventory.added, 0) processes.fire_tick(hearth_pos) eq(meta:get_string("job_result"), "") end) test("protection, claim and six-block checks cover fuel, jobs, ignition and output", function() protected = true eq(use("hearth:sticks"):get_count(), 1) eq(starter(stack("hearth:firestarter")).wear, 0) eq(violations, 2) protected, claimed = false, false eq(use("hearth:sticks"):get_count(), 1) eq(use("hearth:raw_food"):get_count(), 1) claimed, distant = true, true eq(starter(stack("hearth:firestarter")).wear, 0) eq(violations, 5) distant = false use("hearth:sticks") starter(stack("hearth:firestarter")) use("hearth:raw_food") advance(6) protected = true processes.use_fire(hearth_pos, api.get_node(hearth_pos), player, stack("")) eq(#inventory.added, 0) eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:cooked_food") end) test("destroyed hearth callbacks cannot recreate nodes or claim vanished output", function() use("hearth:sticks") starter(stack("hearth:firestarter")) use("hearth:raw_food") advance(6) world[key(hearth_pos)] = {name = "air"} metadata[key(hearth_pos)], timers[key(hearth_pos)] = nil, nil local before = #inventory.added processes.use_fire(hearth_pos, api.get_node(hearth_pos), player, stack("")) eq(processes.fire_tick(hearth_pos), false) eq(api.get_node(hearth_pos).name, "air") eq(#inventory.added, before) end) os.time = real_time print("1.." .. passed)