Files
last-hearth/tests/m2_processes_spec.lua
2026-11-03 12:41:09 -05:00

582 lines
22 KiB
Lua
Executable File

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