-- Standalone Lua 5.1+ regression harness; no engine or MTG required. -- Pass the content module's path as the first command-line argument. local nodes, items, crafts, biomes, decorations, lbms = {}, {}, {}, {}, {}, {} local world, metas, timers, messages, state = {}, {}, {}, {}, {} local now, protected, claim, violations = 100000, false, true, 0 local player local function key(p) return p.x .. ":" .. p.y .. ":" .. p.z end local function pos(x, y, z) return {x = x or 0, y = y or 0, z = z or 0} end local function put(p, name) world[key(p)] = {name = name, param2 = 2} end local function point(under, above) return {type = "node", under = under, above = above} end local Stack = {} Stack.__index = Stack 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}, Stack) end function Stack:get_name() return self.name end function Stack:get_count() return self.count end function Stack:is_empty() return self.count == 0 end function Stack:take_item(n) self.count = math.max(0, self.count - (n or 1)) if self.count == 0 then self.name = "" end end function Stack:replace(text) local new = stack(text) self.name, self.count = new.name, new.count end core = {registered_nodes = nodes} function core.register_node(name, def) nodes[name], items[name] = def, def end function core.register_item(name, def) items[name] = def end function core.register_tool(name, def) items[name] = def end function core.register_craftitem(name, def) items[name] = def end function core.register_craft(def) crafts[#crafts + 1] = def end function core.register_biome(def) biomes[#biomes + 1] = def end function core.register_decoration(def) decorations[#decorations + 1] = def end function core.register_lbm(def) lbms[#lbms + 1] = def end local aliases = {} function core.register_alias(name, target) aliases[name] = target end function core.get_node(p) return world[key(p)] or {name = "air"} end function core.get_node_or_nil(p) if world[key(p)] == false then return nil end return core.get_node(p) end function core.swap_node(p, node) world[key(p)] = node end function core.is_protected() return protected end function core.record_protection_violation() violations = violations + 1 end function core.get_meta(p) local k = key(p) if not metas[k] then local fields = {} metas[k] = { set_string = function(_, name, value) fields[name] = value end, get_string = function(_, name) return fields[name] or "" end, } end return metas[k] end function core.get_node_timer(p) local k = key(p) if not timers[k] then timers[k] = { start = function(self, seconds) self.timeout = seconds end, stop = function(self) self.timeout = nil end, } end return timers[k] end function core.item_place(s, who, pointed) local under = core.get_node(pointed.under) local def = nodes[under.name] if def.on_rightclick and not who:get_player_control().sneak then return def.on_rightclick(pointed.under, under, who, s) end local p = def.buildable_to and pointed.under or pointed.above if core.is_protected(p, who:get_player_name()) then return s end local name = s:get_name() put(p, name) if nodes[name].on_construct then nodes[name].on_construct(p) end if nodes[name].after_place_node then nodes[name].after_place_node(p, who) end s:take_item() return s, p end nodes.air = {buildable_to = true, walkable = false} hearth = { model = {}, players = {survivor = state}, texture = function(color) return "[fill:16x16:" .. color end, claim_allowed = function() return claim end, notify = function(_, message) messages[#messages + 1] = message end, } local inventory = {room = true, contents = {}} function inventory:room_for_item() return self.room end function inventory:add_item(_, name) self.contents[#self.contents + 1] = name return stack() end player = {position = pos(), sneak = false} function player:is_player() return true end function player:get_pos() return self.position end function player:get_player_name() return "survivor" end function player:get_inventory() return inventory end function player:get_player_control() return {sneak = self.sneak} end local real_time = os.time os.time = function() return now end assert(arg and arg[1], "Pass the path to mods/hearth/content.lua") dofile(arg[1]) local passed = 0 local function eq(actual, expected) assert(actual == expected, "expected " .. tostring(expected) .. ", got " .. tostring(actual)) end local function reset() world, metas, timers, messages = {}, {}, {}, {} now, protected, claim, violations = 100000, false, true, 0 player.position, player.sneak = pos(), false inventory.room, inventory.contents = true, {} state.food, state.water, state.stamina = 100, 100, 100 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 firepos = pos() local function fire() put(firepos, "hearth:fire") nodes["hearth:fire"].on_construct(firepos) end local function use(text) local s = stack(text) local node = core.get_node(firepos) nodes[node.name].on_rightclick(firepos, node, player, s) return s end local function deadline() return tonumber(core.get_meta(firepos):get_string("fuel_until")) end test("standalone registrations, recipes and mapgen resolve", function() for _, name in ipairs({"stone", "soil", "grass", "sand", "water_source", "water_flowing", "log", "leaves", "loose_stone", "sticks", "berry_bush", "reeds", "planks", "wall", "roof", "fire", "fire_lit", "berries", "raw_food", "cooked_food", "vessel", "water_raw", "water_clean", "knife", "axe", "pick", "spear"}) do assert(items["hearth:" .. name], name) end for _, recipe in ipairs(crafts) do assert(items[stack(recipe.output):get_name()]) for _, row in ipairs(recipe.recipe) do for _, ingredient in ipairs(type(row) == "table" and row or {row}) do assert(ingredient == "" or items[ingredient], ingredient) end end end eq(#biomes, 2) eq(#decorations, 6) eq(aliases.mapgen_river_water_source, "hearth:water_source") for _, deco in ipairs(decorations) do if deco.schematic then eq(#deco.schematic.data, 150) for _, entry in ipairs(deco.schematic.data) do assert(nodes[entry.name]) end else assert(nodes[deco.decoration]) end end end) test("hand gathers soil and shrubs but cannot harvest trees or stone", function() local caps = items[":"].tool_capabilities.groupcaps assert(caps.crumbly and caps.snappy) eq(caps.choppy, nil) eq(caps.cracky, nil) assert(items["hearth:axe"].tool_capabilities.groupcaps.choppy) assert(items["hearth:pick"].tool_capabilities.groupcaps.cracky) end) test("fire consumes exactly one fuel and retains metadata through swaps", function() fire() core.get_meta(firepos):set_string("owner", "survivor") eq(use("hearth:sticks 3"):get_count(), 2) eq(deadline(), now + 45) eq(core.get_node(firepos).name, "hearth:fire_lit") eq(core.get_node(firepos).param2, 2) eq(core.get_meta(firepos):get_string("owner"), "survivor") eq(use("hearth:log"):get_count(), 0) eq(deadline(), now + 225) end) test("full hearth rejects fuel rather than granting or wasting it", function() fire() for _ = 1, 3 do use("hearth:log") end eq(use("hearth:log"):get_count(), 1) eq(deadline(), now + 540) eq(use("hearth:sticks"):get_count(), 0) eq(use("hearth:sticks"):get_count(), 1) eq(deadline(), now + 585) end) test("timer and reload expire offline fuel without resetting the clock", function() fire() use("hearth:sticks") now = now + 10 nodes["hearth:fire_lit"].on_timer(firepos, 999) eq(deadline(), 100045) now = now + 1000 lbms[1].action(firepos) eq(core.get_node(firepos).name, "hearth:fire") eq(deadline(), 0) eq(core.get_node_timer(firepos).timeout, nil) eq(use("hearth:raw_food"):get_name(), "hearth:raw_food") end) test("single food cooks in hand and debits fuel", function() fire() use("hearth:sticks") eq(use("hearth:raw_food"):get_name(), "hearth:cooked_food") eq(deadline(), now + 25) eq(use("hearth:raw_food"):get_name(), "hearth:cooked_food") eq(deadline(), now + 5) eq(use("hearth:raw_food"):get_name(), "hearth:raw_food") end) test("full inventory does not destroy cooking input or fuel", function() fire() use("hearth:sticks") inventory.room = false eq(use("hearth:raw_food 4"):get_count(), 4) eq(deadline(), now + 45) inventory.room = true eq(use("hearth:raw_food 4"):get_count(), 3) eq(inventory.contents[1], "hearth:cooked_food") eq(deadline(), now + 25) end) test("protected, claim-denied and distant fire interactions change nothing", function() fire() protected = true eq(use("hearth:log"):get_count(), 1) protected, claim = false, false eq(use("hearth:sticks"):get_count(), 1) claim, player.position = true, pos(7) eq(use("hearth:log"):get_count(), 1) eq(deadline(), 0) eq(violations, 2) for _, action in ipairs({"put", "take", "move"}) do eq(nodes["hearth:fire"]["allow_metadata_inventory_" .. action](firepos, "main", 1, stack("hearth:log"), player), 0) end end) test("vessel filling validates water, protection and six-block reach", function() local fill = items["hearth:vessel"].on_place local water = pos(6) put(water, "hearth:water_source") local pointed = point(water, pos(6, 1)) eq(fill(stack("hearth:vessel"), player, pointed):get_name(), "hearth:water_raw") protected = true eq(fill(stack("hearth:vessel"), player, pointed):get_name(), "hearth:vessel") protected, claim = false, false eq(fill(stack("hearth:vessel"), player, pointed):get_name(), "hearth:vessel") claim, player.position = true, pos(-0.1) eq(fill(stack("hearth:vessel"), player, pointed):get_name(), "hearth:vessel") eq(core.get_node(water).name, "hearth:water_source") end) test("boiling requires fuel and drinking returns the carved cup only when thirsty", function() fire() eq(use("hearth:water_raw"):get_name(), "hearth:water_raw") use("hearth:sticks") local water = use("hearth:water_raw") eq(water:get_name(), "hearth:water_clean") eq(deadline(), now + 35) local drink = items["hearth:water_clean"].on_use drink(water, player) eq(water:get_name(), "hearth:water_clean") state.water = 80 drink(water, player) eq(state.water, 100) eq(water:get_name(), "hearth:vessel") end) test("food consumption is conditional and clamps restored needs", function() local food = stack("hearth:berries 2") items["hearth:berries"].on_use(food, player) eq(food:get_count(), 2) state.food = 95 items["hearth:berries"].on_use(food, player) eq(food:get_count(), 1) eq(state.food, 100) eq(state.water, 100) end) test("wall placement is supported, protected and records ownership", function() local ground, target = pos(0, -1), pos() put(ground, "hearth:stone") local s = stack("hearth:wall 2") nodes["hearth:wall"].on_place(s, player, point(ground, target)) eq(s:get_count(), 1) eq(core.get_node(target).name, "hearth:wall") eq(core.get_meta(target):get_string("owner"), "survivor") protected = true eq(nodes["hearth:wall"].can_dig(target, player), false) protected, claim = false, false eq(nodes["hearth:wall"].can_dig(target, player), false) end) test("sequential roofs cannot extend a horizontal span beyond two blocks", function() put(pos(0, -1), "hearth:stone") put(pos(), "hearth:wall") for x = 1, 3 do local s = stack("hearth:roof") nodes["hearth:roof"].on_place(s, player, point(pos(x - 1), pos(x))) eq(s:get_count(), x <= 2 and 0 or 1) end end) test("unloaded, unsupported, protected and distant placements fail closed", function() local s = stack("hearth:wall 4") local p = point(pos(0, -1), pos()) nodes["hearth:wall"].on_place(s, player, p) eq(s:get_count(), 4) put(pos(0, -1), "hearth:stone") claim = false nodes["hearth:wall"].on_place(s, player, p) claim, protected = true, true nodes["hearth:wall"].on_place(s, player, p) protected, player.position = false, pos(7) nodes["hearth:wall"].on_place(s, player, p) player.position = pos() world[key(pos(0, -1))] = false nodes["hearth:wall"].on_place(s, player, p) eq(s:get_count(), 4) end) test("node-item rightclick feeds fire even when the space above is occupied", function() fire() put(pos(0, 1), "hearth:stone") local s = stack("hearth:sticks 2") nodes["hearth:sticks"].on_place(s, player, point(firepos, pos(0, 1))) eq(s:get_count(), 1) eq(deadline(), now + 45) end) test("construction height is limited to sixteen blocks above an anchor", function() put(pos(0, -1), "hearth:stone") for y = 0, 16 do player.position = pos(0, y) local s = stack("hearth:wall") nodes["hearth:wall"].on_place(s, player, point(pos(0, y - 1), pos(0, y))) eq(s:get_count(), y < 16 and 0 or 1) end end) test("water primary-use boiling obeys claim and distance checks", function() fire() use("hearth:sticks") local s = stack("hearth:water_raw") local pointed = point(firepos, pos(0, 1)) claim = false items["hearth:water_raw"].on_use(s, player, pointed) eq(s:get_name(), "hearth:water_raw") claim, player.position = true, pos(7) items["hearth:water_raw"].on_use(s, player, pointed) eq(s:get_name(), "hearth:water_raw") player.position = pos() items["hearth:water_raw"].on_use(s, player, pointed) eq(s:get_name(), "hearth:water_clean") eq(deadline(), now + 35) end) test("all content nodes enforce claim-aware digging and placement", function() put(pos(0, -1), "hearth:stone") claim = false for name, def in pairs(nodes) do if name ~= "air" then eq(def.can_dig(pos(), player), false) local s = stack(name) def.on_place(s, player, point(pos(0, -1), pos())) eq(s:get_count(), 1) end end end) test("nested procedural fill texture arguments escape colons", function() assert(nodes["hearth:grass"].tiles[3]:find("[fill\\:16x16\\:", 1, true)) assert(nodes["hearth:berry_bush"].tiles[1]:find("[fill\\:16x16\\:", 1, true)) end) os.time = real_time print("1.." .. passed)