371 lines
14 KiB
Lua
Executable File
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)
|