Files
last-hearth/tests/content_spec.lua
T
2026-11-01 19:35:46 -05:00

371 lines
14 KiB
Lua
Executable File

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