Files
last-hearth/tests/content_spec.lua
T
2026-11-05 09:57:11 -05:00

1135 lines
52 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.
assert(arg and arg[1], "Pass the path to mods/hearth/content.lua")
local modpath = assert(arg[1]:match("^(.*)[/\\][^/\\]+$"), "Content path needs a directory")
local nodes, items, crafts, biomes, decorations, lbms, entities, globalsteps = {}, {}, {}, {}, {}, {}, {}, {}
local craftitems, tools, texture_calls = {}, {}, {}
local ores, registrations = {}, {}
local world, metas, timers, messages, sounds, state, formspecs = {}, {}, {}, {}, {}, {}, {}
local now, game_now, protected, claim, violations = 100000, 500, false, true, 0
local claim_owner, member_role = "survivor", "owner"
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
function Stack:add_wear(amount)
self.wear = (self.wear or 0) + amount
if self.wear >= 65535 then self.name, self.count = "", 0 end
end
_G.ItemStack = stack
core = {registered_nodes = nodes}
function core.get_modpath(name) return name == "hearth" and modpath or nil end
function core.register_node(name, def)
assert(not nodes[name], "Duplicate node: " .. name)
nodes[name], items[name] = def, def
registrations[#registrations + 1] = name
end
function core.register_item(name, def) items[name] = def end
function core.register_tool(name, def) items[name], tools[name] = def, def end
function core.register_craftitem(name, def)
assert(not items[name], "Duplicate item: " .. name)
items[name], craftitems[name] = def, 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_ore(def) ores[#ores + 1] = def end
function core.register_lbm(def) lbms[#lbms + 1] = def end
function core.register_entity(name, def) entities[name] = def end
function core.register_globalstep(fn) globalsteps[#globalsteps + 1] = fn end
function core.get_connected_players() return {} end
function core.get_objects_inside_radius() return {} end
function core.get_gametime() return game_now end
function core.sound_play(name, params) sounds[#sounds + 1] = {name = name, params = params} 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.set_node(p, node)
local k = key(p)
world[k], metas[k], timers[k] = node, nil, nil
local def = nodes[node.name]
if def and def.on_construct then def.on_construct(p) end
end
function core.is_protected() return protected end
function core.record_protection_violation() violations = violations + 1 end
function core.show_formspec(name, form, spec)
formspecs[#formspecs + 1] = {name = name, form = form, spec = spec}
end
function core.get_meta(p)
local k = key(p)
if not metas[k] then
local fields = {}
local node_inventory = {items = {}, size = 0}
function node_inventory:set_size(_, size) self.size = size end
function node_inventory:is_empty() return #self.items == 0 end
metas[k] = {
set_string = function(_, name, value) fields[name] = 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,
get_inventory = function() return node_inventory 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.get_gametime() return game_now 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)
texture_calls[#texture_calls + 1] = color
return "[fill:16x16:" .. color
end,
claim_owner_at = function() return claim and claim_owner or nil end,
claim_role_at = function(_, name) return name == claim_owner and "owner" or member_role end,
external_protected = function() return protected end,
claim_allowed = function(_, name, action)
if not claim then return false end
if name == claim_owner then return true end
if member_role == "resident" then return action ~= "arrival" and action ~= "combat" end
return member_role == "storekeeper" and action == "storage"
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] = type(name) == "table" and name:get_name() or name
return stack()
end
player = {position = pos(), sneak = false}
local player_fields = {}
local player_meta = {
get_int = function(_, name) return player_fields[name] or 0 end,
set_int = function(_, name, value) player_fields[name] = math.floor(value) end,
}
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
function player:get_meta() return player_meta end
local real_time = os.time
os.time = function() return now end
-- Keep the runner's dofile: isolated engine runs map permitted sibling modules
-- into this same environment; standalone runs use Lua's normal file loader.
local core_before = {}
for name, value in pairs(core) do core_before[name] = value end
_G.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, sounds, formspecs = {}, {}, {}, {}, {}, {}
now, game_now, protected, claim, violations = 100000, 500, false, true, 0
claim_owner, member_role, player_fields = "survivor", "owner", {}
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 ignite()
local starter = stack("hearth:firestarter")
items["hearth:firestarter"].on_use(starter, player, point(firepos, pos(0, 1)))
return starter
end
local function advance(seconds)
now, game_now = now + seconds, game_now + seconds
local node = core.get_node(firepos)
if nodes[node.name] and nodes[node.name].on_timer then
nodes[node.name].on_timer(firepos, seconds)
end
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", "foundation", "frame", "stairs", "door", "door_open", "window", "vent", "storage", "pantry",
"drying_rack", "bedroll", "torch", "fish_trap", "weir_stone", "sapling", "berry_bush_bare", "crop_1", "crop_2", "crop_3", "crop_4",
"grain_patch", "clay_bank", "iron_ore_vein", "fire", "fire_lit", "berries", "grain", "grain_seed",
"raw_food", "cooked_food", "fish_bait", "raw_fish", "cooked_fish", "fish_drying_charge", "dried_fish",
"vessel", "water_raw", "water_clean", "iron_ore", "iron_ingot", "charcoal",
"clay", "clay_jar", "clay_jar_charge", "dried_food", "dried_food_charge", "smelt_charge", "charcoal_charge", "knife", "axe", "iron_axe",
"pick", "spear", "firestarter", "cloak"}) do
assert(items["hearth:" .. name], name)
end
eq(nodes["hearth:torch"].light_source, 12)
eq(nodes["hearth:torch"].on_timer, nil)
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
assert(#biomes >= 2, "meadow and highland biomes remain available")
assert(#decorations > 0, "resource distribution remains available")
for _, name in ipairs({"meadow_grass", "wildflowers", "fern", "litter", "fungi", "deadwood",
"tree_seedling", "young_tree", "mature_tree", "dung"}) do
local def = assert(nodes["hearth:" .. name], name)
local wood = name=="young_tree" or name=="mature_tree" or name=="deadwood"
eq(def.tiles[1], wood and "hearth_log_top.png" or "hearth_" .. (name=="tree_seedling" and "sapling" or name) .. ".png")
eq(def.mesh, nil)
assert(def.groups.hearth_ecosystem and def.after_place_node and def.can_dig)
end
assert(hearth.succession and hearth.succession.forage and hearth.succession.deposit and hearth.succession.habitat)
eq(aliases.mapgen_river_water_source, "hearth:water_source")
for _, deco in ipairs(decorations) do
if deco.schematic then
eq(#deco.schematic.data, deco.schematic.size.x * deco.schematic.size.y * deco.schematic.size.z)
for _, entry in ipairs(deco.schematic.data) do assert(nodes[entry.name]) end
else assert(nodes[deco.decoration]) end
end
end)
test("biome and diversity integration is unique, complete and ordered before seasonal variants", function()
assert(hearth.seasons and hearth.botany and hearth.diversity and hearth.biomes and hearth.succession and hearth.wildlife)
eq(#biomes, #hearth.biomes.biome_profiles)
local seen = {}
for _, biome in ipairs(biomes) do
assert(not seen[biome.name], "duplicate biome: " .. biome.name)
seen[biome.name] = true
for _, field in ipairs({"node_top", "node_filler", "node_stone", "node_water", "node_riverbed"}) do
assert(nodes[biome[field]], biome[field])
end
end
assert(seen.hearth_temperate and seen.hearth_riverbank and seen.hearth_rocky_peaks)
local index = {}
for i, name in ipairs(registrations) do index[name] = i end
eq(#hearth.biomes.species_order,13)
eq(table.concat(hearth.biomes.species_order,","),table.concat(hearth.diversity.species_order,","))
for _, species in ipairs(hearth.biomes.species_order) do
for _, part in ipairs({"log", "leaves", "planks", "seedling", "young", "mature"}) do
local name = species .. "_" .. part
assert(nodes["hearth:" .. name], name)
if part == "leaves" or part == "seedling" or part == "young" or part == "mature" then
for _, season in ipairs({"spring", "autumn", "winter"}) do
local variant = "hearth:" .. name .. "_" .. season
if species == "pine" or species == "spruce" then eq(nodes[variant], nil)
else
assert(index[variant] > index["hearth:" .. name], variant)
eq(hearth.seasons.kind(variant), name)
end
end
end
end
end
assert(index["hearth:oak_leaves"] < index["hearth:oak_seedling"])
assert(index["hearth:lichen"] < index["hearth:oak_leaves_winter"])
eq(#hearth.botany.catalogue,100)
local botany_nodes=0
for _,record in ipairs(hearth.botany.catalogue) do
assert(nodes[record.node],record.node)
if record.group~="trees" and not record.existing then
botany_nodes=botany_nodes+1
assert(index[record.node]>index["hearth:lichen"] and index[record.node]<index["hearth:oak_leaves_winter"])
local def=nodes[record.node]
eq(def.tiles[1],record.texture); eq(def.inventory_image,record.texture); eq(def.wield_image,record.texture)
end
end
eq(botany_nodes,79)
for _, name in ipairs(hearth.diversity.plants) do assert(nodes["hearth:" .. name]) end
eq(hearth.diversity.supports_species("pine", "podzol"), true)
eq(hearth.diversity.supports_species("oak", "podzol"), false)
eq(#ores, 9)
for _, ore in ipairs(ores) do
assert(nodes[ore.ore], ore.ore)
for _, host in ipairs(ore.wherein) do assert(nodes[host], host) end
end
local decoration_names = {}
for _, deco in ipairs(decorations) do
assert(not decoration_names[deco.name], "duplicate decoration: " .. deco.name)
decoration_names[deco.name] = true
assert(not (deco.name == "hearth:iron_seams"), "legacy fake floor ores retained")
end
local n, b, d, o, c = #registrations, #biomes, #decorations, #ores, #crafts
eq(hearth.biomes.register(), hearth.biomes)
hearth.diversity.register_nodes(function() error("duplicate diversity registration") end)
hearth.botany.register_nodes(function() error("duplicate botany registration") end)
eq(#registrations, n); eq(#biomes, b); eq(#decorations, d); eq(#ores, o); eq(#crafts, c)
end)
test("new minerals have exact utility recipes and copper upgrades only the flint mining tier", function()
local function recipe(output, ingredients, craft_type)
for _, def in ipairs(crafts) do
local flat = {}
for _, row in ipairs(def.recipe) do
for _, value in ipairs(type(row) == "table" and row or {row}) do
if value ~= "" then flat[#flat + 1] = value end
end
end
if def.output == output and def.type == craft_type and table.concat(flat, ",") == table.concat(ingredients, ",") then return true end
end
return false
end
assert(recipe("hearth:copper_charge", {"hearth:copper_ore", "hearth:charcoal"}, "shapeless"))
assert(recipe("hearth:copper_pick", {"hearth:copper_ingot", "hearth:copper_ingot", "hearth:copper_ingot", "hearth:sticks", "hearth:sticks"}))
assert(recipe("hearth:window", {"hearth:quartz", "hearth:quartz", "hearth:quartz"}, "shapeless"))
assert(recipe("hearth:window", {"hearth:planks", "hearth:reeds", "hearth:planks", "hearth:planks", "hearth:reeds", "hearth:planks"}))
for _, entry in ipairs({{"quartz_vein", "quartz"}, {"copper_ore_vein", "copper_ore"}, {"coal_ore_vein", "coal"}}) do
eq(nodes["hearth:" .. entry[1]].drop, "hearth:" .. entry[2] .. " 2")
end
local flint, copper = tools["hearth:pick"].tool_capabilities, tools["hearth:copper_pick"].tool_capabilities
assert(copper.groupcaps.cracky.times[3] < flint.groupcaps.cracky.times[3])
assert(copper.groupcaps.cracky.uses > flint.groupcaps.cracky.uses)
eq(copper.groupcaps.cracky.maxlevel, flint.groupcaps.cracky.maxlevel)
eq(copper.groupcaps.cracky.times[2], nil); eq(copper.groupcaps.choppy, nil)
eq(copper.damage_groups.fleshy, flint.damage_groups.fleshy)
eq(tools["hearth:copper_pick"].inventory_image, "hearth_item_copper_pick.png")
end)
test("all species and rocks keep single-item products and pair-only mass-preserving conversions", function()
local function exact(output, inputs)
local matches=0
for _, def in ipairs(crafts) do
if def.type=="shapeless" and def.output==output and #def.recipe==#inputs then
local same=true
for i, input in ipairs(inputs) do if def.recipe[i]~=input then same=false end end
if same then matches=matches+1 end
end
end
eq(matches,1)
end
for _, species in ipairs(hearth.biomes.species_order) do
local log, planks="hearth:"..species.."_log", "hearth:"..species.."_planks"
exact(planks.." 4", {log})
exact("hearth:log 2", {log,log})
exact("hearth:planks", {planks})
end
for _, rock in ipairs(hearth.biomes.rocks) do
local raw="hearth:"..rock
exact(raw.."_bricks 4", {raw})
exact("hearth:stone 2", {raw,raw})
end
exact("hearth:planks 4", {"hearth:log"})
exact("hearth:sticks 4", {"hearth:planks"})
end)
test("all 87 registered recipes close uniquely with exact output names and counts", function()
-- Exact-name registrations only. Compare shaped bounding boxes, and compare
-- ingredient multisets whenever either recipe is shapeless. This catches
-- single-input collisions even across craft types without pretending to be
-- the native resolver (the native smoke still calls get_craft_result).
eq(#crafts,87)
local signatures, outputs={},{}
for i, def in ipairs(crafts) do
assert(def.type==nil or def.type=="shapeless", "unsupported craft type")
local flat, rows, width={}, {}, 0
if def.type=="shapeless" then rows={def.recipe}
else rows=def.recipe end
for _, row in ipairs(rows) do width=math.max(width,#row) end
local xmin,ymin,xmax,ymax=width+1,#rows+1,0,0
for y, row in ipairs(rows) do for x, input in ipairs(row) do
if input~="" then
assert(items[input], "unknown or non-exact ingredient: "..input)
flat[#flat+1]=input
xmin,ymin=math.min(xmin,x),math.min(ymin,y)
xmax,ymax=math.max(xmax,x),math.max(ymax,y)
end
end end
assert(#flat>0 and #flat<=9)
table.sort(flat)
local grid={}
for y=ymin,ymax do for x=xmin,xmax do grid[#grid+1]=rows[y][x] or "" end end
local result=stack(def.output)
assert(items[result:get_name()] and result:get_count()>0)
outputs[result:get_name()]=true
signatures[i]={bag=table.concat(flat,","), shape=(xmax-xmin+1)..":"..table.concat(grid,","),
shapeless=def.type=="shapeless", name=result:get_name(), count=result:get_count()}
end
local output_count=0; for _ in pairs(outputs) do output_count=output_count+1 end
eq(output_count,55)
for i, expected in ipairs(signatures) do
local matches=0
for j, candidate in ipairs(signatures) do
local same=expected.bag==candidate.bag and
(expected.shapeless or candidate.shapeless or expected.shape==candidate.shape)
if same then
eq(candidate.name,expected.name); eq(candidate.count,expected.count)
assert(i==j, "duplicate recipe for "..expected.name)
matches=matches+1
end
end
eq(matches,1)
end
end)
test("legacy seasonal variants retain callbacks and cube geometry without mesh atlases", function()
local seasons = hearth.seasons
for _, kind in ipairs({"tree_seedling", "young_tree", "mature_tree", "leaves"}) do
local base = nodes["hearth:"..kind]
for _, season in ipairs({"spring", "autumn", "winter"}) do
local variant = assert(nodes["hearth:"..kind.."_"..season])
eq(seasons.kind("hearth:"..kind.."_"..season), kind)
eq(variant.can_dig, base.can_dig)
eq(variant.after_place_node, base.after_place_node)
eq(variant.drop, base.drop)
eq(variant.collision_box, base.collision_box)
eq(variant.mesh,nil)
if kind=="young_tree" or kind=="mature_tree" then
eq(variant.drawtype,"normal"); eq(variant.tiles[1],"hearth_log_top.png")
eq(variant.tiles[2],"hearth_log_top.png"); eq(variant.tiles[3],"hearth_log_side.png")
elseif kind=="tree_seedling" then eq(variant.tiles[1],"hearth_sapling.png")
else eq(variant.tiles[1],"hearth_leaves_"..season..".png")
end
end
end
for _, kind in ipairs({"spring_greens", "autumn_mushrooms"}) do
local base = assert(nodes["hearth:"..kind.."_patch"])
assert(base.on_rightclick and base.on_dig and not base.buildable_to)
eq(base.drop, "")
eq(base.tiles[1], "hearth_"..kind.."_dormant.png")
eq(items["hearth:"..kind].inventory_image, "hearth_item_"..kind..".png")
end
assert(items["hearth:spring_greens"].on_use)
eq(items["hearth:autumn_mushrooms"].on_use,nil)
end)
test("player placement marks even natural-looking terrain and ecological plants", function()
for _, name in ipairs({"grass", "soil", "meadow_grass", "mature_tree"}) do
local p = pos(2, 1)
nodes["hearth:" .. name].after_place_node(p, player)
eq(core.get_meta(p):get_string("player_placed"), "1")
end
end)
test("harvested crop soil remains marked farmland instead of native recruitment ground", function()
local p = pos(2, 1)
put(p, "hearth:crop_4")
hearth.ecology.harvest_crop(p, player)
eq(core.get_node(p).name, "hearth:soil")
eq(core.get_meta(p):get_string("player_placed"), "1")
end)
test("seasonal forage uses normal reach and protection authorization", function()
local original_climate=hearth.climate_at
hearth.climate_at=function() return {season="Spring",temperature=15} end
local p=pos(2,1)
put(p,"hearth:spring_greens_patch_spring")
local pick=nodes[core.get_node(p).name].on_rightclick
player.position=pos(20,1); pick(p,nil,player)
eq(#inventory.contents,0)
player.position=pos(); protected=true; pick(p,nil,player)
eq(#inventory.contents,0)
protected=false; pick(p,nil,player); eq(#inventory.contents,1)
eq(inventory.contents[1],"hearth:spring_greens")
pick(p,nil,player); eq(#inventory.contents,1)
state.food=50
items["hearth:spring_greens"].on_use(stack("hearth:spring_greens"),player)
eq(state.food,62)
hearth.climate_at=original_climate
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("fallen logs are axe-only ordinary timber cubes; loose sticks remain hand-gatherable", function()
local def = nodes["hearth:deadwood"]
eq(def.description, "Fallen log (axe; yields timber)")
eq(def.groups.snappy, nil); eq(def.groups.choppy, 2); eq(def.groups.tree,1)
eq(def.drop, "hearth:log")
eq(def.can_dig, hearth.construction.can_dig)
eq(def.on_dig, nil); eq(def.on_rightclick, nil)
eq(def.mesh, nil); eq(def.walkable, true); eq(def.drawtype,"normal"); eq(def.paramtype2,"facedir")
eq(def.selection_box,nil); eq(def.collision_box,nil); eq(def.node_box,nil)
eq(def.tiles[1],"hearth_log_top.png"); eq(def.tiles[2],def.tiles[1]); eq(def.tiles[3],"hearth_log_side.png")
eq(nodes["hearth:sticks"].groups.dig_immediate,3)
for _, kind in ipairs({"log", "young_tree", "mature_tree", "oak_log", "birch_log", "pine_log", "willow_log", "alder_log", "stone"}) do
eq(nodes["hearth:" .. kind].groups.snappy, nil)
end
end)
test("fallen log digging retains reach, claims, external protection and construction support checks", function()
local p = pos()
put(p, "hearth:deadwood")
local def = nodes["hearth:deadwood"]
eq(def.can_dig(p, player), true)
protected = true; eq(def.can_dig(p, player), false)
protected, claim = false, false; eq(def.can_dig(p, player), false)
claim, player.position = true, pos(7); eq(def.can_dig(p, player), false)
player.position = pos()
put(pos(0, 1), "hearth:wall")
eq(def.can_dig(p, player), false)
put(pos(0, 1), "air"); world[key(pos(2, 1))] = false
eq(def.can_dig(p, player), false)
eq(core.get_node(p).name, "hearth:deadwood")
eq(#inventory.contents, 0)
end)
test("deadwood migration rotates only legacy logs once and preserves provenance and timers", function()
local loader
for _,lbm in ipairs(lbms) do if lbm.name=="hearth:block_deadwood" then assert(not loader); loader=lbm end end
assert(loader and loader.run_at_every_load and #loader.nodenames==1)
eq(loader.nodenames[1],"hearth:deadwood")
local p=pos(2,1); put(p,"hearth:deadwood")
world[key(p)].param1=11
local meta=core.get_meta(p)
meta:set_string("owner","survivor"); meta:set_string("player_placed","1")
meta:set_string("succ_natural","1"); meta:set_string("succ_age","345")
core.get_node_timer(p):start(77)
loader.action(p)
eq(core.get_node(p).name,"hearth:deadwood"); eq(core.get_node(p).param2,12); eq(core.get_node(p).param1,11)
eq(meta:get_string("owner"),"survivor"); eq(meta:get_string("player_placed"),"1")
eq(meta:get_string("succ_natural"),"1"); eq(meta:get_string("succ_age"),"345")
eq(meta:get_string("succ_block_deadwood"),"1"); eq(core.get_node_timer(p).timeout,77)
world[key(p)].param2=0; loader.action(p); eq(core.get_node(p).param2,0) -- once per node
for _,facedir in ipairs({4,8,12,16}) do
metas[key(p)]=nil; put(p,"hearth:deadwood"); world[key(p)].param2=facedir
loader.action(p); eq(core.get_node(p).param2,facedir)
end
for _,name in ipairs({"air","hearth:log","hearth:oak_log","hearth:water_source","ignore"}) do
metas[key(p)]=nil; put(p,name); loader.action(p,{name="hearth:deadwood"})
eq(core.get_node(p).name,name); eq(core.get_node(p).param2,2); eq(metas[key(p)],nil)
end
put(p,"hearth:deadwood"); metas[key(p)]=nil; protected=true; loader.action(p)
eq(core.get_node(p).param2,2); eq(metas[key(p)],nil)
world[key(p)]=false; protected=false; loader.action(p); eq(world[key(p)],false)
end)
test("fuel stays cold until a reusable firestarter ignites it", 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")
local starter = ignite()
eq(starter.wear, math.ceil(65535 / 20))
eq(sounds[1].name, "hearth_fire")
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")
local second = pos(2)
put(second, "hearth:fire")
nodes["hearth:fire"].on_construct(second)
nodes["hearth:fire"].on_rightclick(second, core.get_node(second), player, stack("hearth:sticks"))
items["hearth:firestarter"].on_use(starter, player, point(second, pos(2, 1)))
eq(starter.wear, math.ceil(65535 / 20) * 2)
eq(core.get_node(second).name, "hearth:fire_lit")
eq(#sounds, 2)
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)
eq(core.get_node(firepos).name, "hearth:fire")
end)
test("timer and reload expire offline fuel without resetting the clock", function()
fire()
use("hearth:sticks")
ignite()
now = now + 10
nodes["hearth:fire_lit"].on_timer(firepos, 999)
eq(deadline(), now + 35)
now = now + 1000
local fire_lbm
for _, lbm in ipairs(lbms) do if lbm.name == "hearth:expire_fire" then fire_lbm = lbm end end
assert(fire_lbm, "Missing hearth restart reconciler")
fire_lbm.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("food cooks for six real seconds after reserving the heat cost", function()
fire()
use("hearth:sticks")
ignite()
eq(use("hearth:raw_food"):get_count(), 0)
eq(core.get_meta(firepos):get_string("job_kind"), "cook")
eq(nodes["hearth:fire_lit"].can_dig(firepos, player), false)
eq(deadline(), now + 25)
advance(5)
eq(core.get_meta(firepos):get_string("job_kind"), "cook")
eq(deadline(), now + 20)
advance(1)
eq(core.get_meta(firepos):get_string("job_result"), "hearth:cooked_food")
eq(deadline(), now + 19)
use("")
eq(inventory.contents[1], "hearth:cooked_food")
eq(core.get_meta(firepos):get_string("job_result"), "")
eq(nodes["hearth:fire_lit"].can_dig(firepos, player), true)
end)
test("full inventory preserves completed output for a later safe claim", function()
fire()
use("hearth:sticks")
ignite()
use("hearth:raw_food 4")
advance(6)
inventory.room = false
use("")
eq(core.get_meta(firepos):get_string("job_result"), "hearth:cooked_food")
eq(deadline(), now + 19)
inventory.room = true
use("")
eq(inventory.contents[1], "hearth:cooked_food")
eq(core.get_meta(firepos):get_string("job_result"), "")
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")
ignite()
use("hearth:water_raw")
eq(core.get_meta(firepos):get_string("job_kind"), "boil")
advance(4)
eq(core.get_meta(firepos):get_string("job_result"), "hearth:water_clean")
eq(deadline(), now + 31)
local water = stack("hearth:water_clean")
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)
eq(core.get_node(firepos).name, "hearth:fire")
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()
ignite()
items["hearth:water_raw"].on_use(s, player, pointed)
eq(s:get_name(), "")
eq(core.get_meta(firepos):get_string("job_kind"), "boil")
advance(4)
eq(core.get_meta(firepos):get_string("job_result"), "hearth:water_clean")
eq(deadline(), now + 31)
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("every registration uses the named art manifest with no generic swatches", function()
local world_names = {
"stone", "soil", "grass_top", "grass_side", "sand", "log_top", "log_side", "leaves",
"planks", "wall", "roof", "thatch", "foundation", "frame", "stairs", "door", "window",
"vent", "storage", "pantry", "reeds", "berry_bush", "berry_bush_bare", "sapling",
"grain_patch", "crop_1", "crop_2", "crop_3", "crop_4", "clay_bank", "iron_ore_vein",
"water", "fire", "fire_lit", "torch", "sticks", "loose_stone", "bedroll", "drying_rack",
"fish_trap", "weir_stone", "meadow_grass", "wildflowers", "fern", "litter", "fungi",
"deadwood", "tree_seedling", "young_tree", "mature_tree", "dung",
"tree_atlas", "tree_atlas_spring", "tree_atlas_autumn", "tree_atlas_winter",
"leaves_spring", "leaves_autumn", "leaves_winter",
"meadow_grass_dormant", "fern_dormant", "wildflowers_dormant",
"spring_greens", "spring_greens_dormant", "autumn_mushrooms", "autumn_mushrooms_dormant",
}
local item_names = {
"berries", "grain", "grain_seed", "raw_food", "cooked_food", "fish_bait", "raw_fish",
"cooked_fish", "fish_drying_charge", "dried_fish", "iron_ore", "iron_ingot", "charcoal",
"charcoal_charge", "smelt_charge", "clay", "clay_jar", "clay_jar_charge", "dried_food",
"dried_food_charge", "cloak", "vessel", "water_raw", "water_clean", "firestarter", "iron_axe",
"spring_greens", "autumn_mushrooms",
"quartz", "copper_ore", "coal", "copper_ingot", "copper_charge", "copper_pick",
}
local manifest = {}
for _, name in ipairs(world_names) do manifest["hearth_" .. name .. ".png"] = true end
for _, name in ipairs({"granite", "basalt", "limestone", "slate", "sandstone"}) do
manifest["hearth_" .. name .. ".png"] = true
manifest["hearth_" .. name .. "_bricks.png"] = true
end
for _, name in ipairs({"forest_floor", "podzol", "alluvium", "peat", "alpine_turf", "gravel", "scree", "snow",
"quartz_vein", "copper_ore_vein", "coal_ore_vein"}) do manifest["hearth_" .. name .. ".png"] = true end
for _, species in ipairs(hearth.biomes.species_order) do
for _, part in ipairs({"log_top", "log_side", "planks", "leaves", "atlas"}) do
manifest["hearth_" .. species .. "_" .. part .. ".png"] = true
end
if species ~= "pine" and species ~= "spruce" then
for _, season in ipairs({"spring", "autumn", "winter"}) do
for _, part in ipairs({"leaves", "atlas"}) do
manifest["hearth_" .. species .. "_" .. part .. "_" .. season .. ".png"] = true
end
end
end
end
for _,record in ipairs(hearth.botany.catalogue) do
manifest[record.texture]=true
manifest["hearth_botany_" .. record.id .. "_dormant.png"]=true
end
for _, plant in ipairs({"clover", "nettle", "heather", "bluebell", "sedge", "bulrush", "moss", "lichen"}) do
manifest["hearth_" .. plant .. ".png"] = true
if plant ~= "moss" and plant ~= "lichen" then manifest["hearth_" .. plant .. "_dormant.png"] = true end
end
for _, name in ipairs(item_names) do manifest["hearth_item_" .. name .. ".png"] = true end
for _, name in ipairs({"knife", "axe", "pick", "spear"}) do
manifest["hearth_icon_" .. name .. ".png"] = true
end
local function check(image)
assert(type(image) == "string" and image ~= "", "missing registration art")
local source = image:match("^([^%^]+)")
assert(manifest[source], "unmanifested image: " .. image)
assert(not image:find("[fill", 1, true) and not image:find("^[multiply", 1, true)
and not image:find("^[lowpart", 1, true) and not image:find("^[contrast", 1, true), image)
end
for name, def in pairs(nodes) do
if name:match("^hearth:") then
assert(def.tiles and #def.tiles > 0, name)
for _, tile in ipairs(def.tiles) do check(type(tile) == "table" and tile.name or tile) end
for _, tile in ipairs(def.special_tiles or {}) do check(tile.name) end
end
end
for name, def in pairs(items) do
if name:match("^hearth:") then
if not nodes[name] then check(def.inventory_image) end
if def.inventory_image then check(def.inventory_image) end
if def.wield_image then check(def.wield_image) end
end
end
local count = 0
for name, def in pairs(craftitems) do
count = count + 1
eq(def.inventory_image, "hearth_item_" .. name:sub(8) .. ".png")
end
eq(count, #item_names - 3) -- firestarter, iron_axe and copper_pick are tools
for _, name in ipairs({"knife", "axe", "pick", "spear"}) do
eq(tools["hearth:" .. name].inventory_image, "hearth_icon_" .. name .. ".png")
end
for _, name in ipairs({"iron_axe", "firestarter", "copper_pick"}) do
eq(tools["hearth:" .. name].inventory_image, "hearth_item_" .. name .. ".png")
end
eq(#texture_calls, 1)
eq(texture_calls[1], "#c7aa83") -- compatibility swatch is exclusively the skin hand
end)
test("terrain faces, water and physical furniture use semantic materials", function()
for i, image in ipairs({"grass_top", "soil", "grass_side"}) do
eq(nodes["hearth:grass"].tiles[i], "hearth_" .. image .. ".png")
end
for i, image in ipairs({"log_top", "log_top", "log_side"}) do
eq(nodes["hearth:log"].tiles[i], "hearth_" .. image .. ".png")
end
for _, name in ipairs({"water_source", "water_flowing"}) do
local def = nodes["hearth:" .. name]
eq(def.tiles[1], "hearth_water.png^[opacity:185")
eq(#def.special_tiles, 2)
for i, tile in ipairs(def.special_tiles) do
eq(tile.name, def.tiles[1]); eq(tile.backface_culling, i == 2)
end
eq(def.use_texture_alpha, "blend")
end
local overrides = {door_open = "door", loose_stone = "stone", sticks = "log_side",
fire = "stone", fire_lit = "stone", drying_rack = "planks", fish_trap = "planks",
tree_seedling="sapling", young_tree="log_top", mature_tree="log_top", deadwood="log_top",
meadow_grass_winter="meadow_grass_dormant", fern_winter="fern_dormant",
wildflowers_autumn="wildflowers_dormant", wildflowers_winter="wildflowers_dormant",
spring_greens_patch="spring_greens_dormant", spring_greens_patch_spring="spring_greens",
autumn_mushrooms_patch="autumn_mushrooms_dormant", autumn_mushrooms_patch_autumn="autumn_mushrooms"}
for _, kind in ipairs({"tree_seedling", "young_tree", "mature_tree"}) do
for _, season in ipairs({"spring","autumn","winter"}) do overrides[kind.."_"..season]=kind=="tree_seedling" and "sapling" or "log_top" end
end
for _, species in ipairs(hearth.biomes.species_order) do
overrides[species .. "_log"] = species .. "_log_top"
for _, stage in ipairs({"seedling", "young", "mature"}) do
overrides[species .. "_" .. stage] = stage=="seedling" and "botany_"..species or species .. "_log_top"
if species ~= "pine" and species ~= "spruce" then
for _, season in ipairs({"spring", "autumn", "winter"}) do
overrides[species .. "_" .. stage .. "_" .. season] = stage=="seedling" and "botany_"..species or species.."_log_top"
end
end
end
end
for _,record in ipairs(hearth.botany.catalogue) do
if record.group~="trees" and not record.existing then
overrides[record.id]="botany_"..record.id
for _, season in ipairs({"Spring", "Summer", "Autumn", "Winter"}) do
if not hearth.botany.active(record.id,season) then
overrides[record.id .. "_" .. season:lower()]="botany_"..record.id.."_dormant"
end
end
end
end
for _, plant in ipairs({"clover", "nettle", "heather", "bluebell", "sedge", "bulrush"}) do
overrides[plant .. "_winter"] = plant .. "_dormant"
end
overrides.bluebell_summer, overrides.bluebell_autumn = "bluebell_dormant", "bluebell_dormant"
for name, def in pairs(nodes) do
local short = name:match("^hearth:(.+)$")
if short and short ~= "grass" and short ~= "log" and short ~= "bedroll"
and short ~= "water_source" and short ~= "water_flowing" then
eq(def.tiles[1], "hearth_" .. (overrides[short] or short) .. ".png")
end
if short and def.drawtype == "nodebox" then
local record=hearth.botany.get(name)
local image = record and not record.existing and record.group~="trees"
and ("hearth_" .. (overrides[short] or ("botany_"..record.id)) .. ".png")
or "hearth_" .. (short == "door_open" and "door" or short) .. ".png"
eq(def.inventory_image, image); eq(def.wield_image, image)
end
end
eq(nodes["hearth:bedroll"].tiles[1], "hearth_bedroll.png^[sheet:4x4:1,2")
eq(nodes["hearth:window"].tiles[1], "hearth_window.png")
eq(nodes["hearth:window"].use_texture_alpha, "blend")
eq(nodes["hearth:window"].paramtype, "light")
end)
test("botanical silhouettes have crisp alpha and waving without losing ecosystem art", function()
for _, name in ipairs({"leaves", "berry_bush", "berry_bush_bare", "sapling", "grain_patch",
"reeds", "crop_1", "crop_2", "crop_3", "crop_4", "meadow_grass", "wildflowers", "fern",
"litter", "fungi", "deadwood", "tree_seedling", "young_tree", "mature_tree", "dung"}) do
local def = nodes["hearth:" .. name]
local wood=name=="deadwood" or name=="young_tree" or name=="mature_tree"
eq(def.mesh,nil)
if wood then
eq(def.drawtype,"normal"); eq(def.tiles[1],"hearth_log_top.png")
eq(def.tiles[3],"hearth_log_side.png"); eq(def.use_texture_alpha,nil)
eq(def.paramtype,nil); eq(def.sunlight_propagates,false); eq(def.waving,nil)
else
eq(def.tiles[1], "hearth_" .. (name=="tree_seedling" and "sapling" or name) .. ".png")
eq(def.drawtype, name == "leaves" and "allfaces_optional" or "plantlike")
eq(def.use_texture_alpha, "clip"); eq(def.paramtype, "light")
eq(def.waving, 1)
eq(def.sunlight_propagates, true)
if name ~= "leaves" then
eq(def.inventory_image, def.tiles[1]); eq(def.wield_image, def.tiles[1])
end
end
end
eq(nodes["hearth:torch"].use_texture_alpha, "clip")
end)
test("all 23 fauna register the actual animated catalogue assets at node scale", function()
for _, r in ipairs(hearth.fauna.list()) do
local def = assert(entities[r.entity], r.id)
local props = def.initial_properties
eq(props.visual, "mesh"); eq(props.mesh, r.mesh)
eq(#props.textures, 1); eq(props.textures[1], r.texture)
eq(props.visual_size.x, 1); eq(props.visual_size.y, 1)
eq(props.collisionbox[2], 0); eq(props.static_save, true)
eq(r.frames,100); eq(r.fps,24)
assert(def.get_staticdata and def.on_step)
end
end)
test("local art registrars do not wrap global APIs or require optional mock registries", function()
for name, value in pairs(core_before) do eq(core[name], value) end
for name, value in pairs(core) do eq(value, core_before[name]) end
eq(core.registered_items, nil); eq(core.registered_tools, nil); eq(core.registered_craftitems, nil)
end)
test("storage and pantry share Mocha styling and retain gated inventory access", function()
for _, entry in ipairs({{"storage", 3, 24, "HOMESTEAD STORAGE"}, {"pantry", 6, 48, "FIRED-CLAY PANTRY"}}) do
local p, def = pos(4, 2, 1), nodes["hearth:" .. entry[1]]
def.on_construct(p)
eq(core.get_meta(p):get_inventory().size, entry[3])
def.on_rightclick(p, {name = "hearth:" .. entry[1]}, player)
local form = formspecs[#formspecs]
eq(form.form, "hearth:storage")
for _, fragment in ipairs({"bgcolor[#1e1e2e;true]", "box[0.25,0.2;10.5,0.55;#313244]",
"style_type[button;bgcolor=#313244;textcolor=#cdd6f4;border=false]",
"style[close;bgcolor=#cba6f7;textcolor=#1e1e2e]",
"listcolors[#313244;#45475a;#cba6f7;#181825;#cdd6f4]", entry[4],
"list[nodemeta;4,2,1;main;0.5,1;8," .. entry[2] .. ";]", "button_exit[",
"listring[nodemeta;4,2,1;main]listring[current_player;main]"}) do
assert(form.spec:find(fragment, 1, true), fragment)
end
claim_owner, member_role = "homesteader", "storekeeper"
eq(def.allow_metadata_inventory_put(p, "main", 1, stack("hearth:grain 3"), player), 3)
eq(def.allow_metadata_inventory_take(p, "main", 1, stack("hearth:grain 2"), player), 2)
eq(def.allow_metadata_inventory_move(p, "main", 1, "main", 2, 3, player), 3)
eq(def.can_dig(p, player), false)
member_role = nil
local count = #formspecs
def.on_rightclick(p, {name = "hearth:" .. entry[1]}, player)
eq(#formspecs, count)
eq(def.allow_metadata_inventory_put(p, "main", 1, stack("hearth:grain"), player), 0)
eq(def.allow_metadata_inventory_take(p, "main", 1, stack("hearth:grain"), player), 0)
eq(def.allow_metadata_inventory_move(p, "main", 1, "main", 2, 1, player), 0)
claim_owner, member_role = "survivor", "owner"
end
end)
test("doors toggle as passable openings only for household-use permission", function()
local doorpos = pos()
put(doorpos, "hearth:door")
nodes["hearth:door"].on_rightclick(doorpos, core.get_node(doorpos), player)
eq(core.get_node(doorpos).name, "hearth:door_open")
nodes["hearth:door_open"].on_rightclick(doorpos, core.get_node(doorpos), player)
eq(core.get_node(doorpos).name, "hearth:door")
claim_owner, member_role = "homesteader", "storekeeper"
nodes["hearth:door"].on_rightclick(doorpos, core.get_node(doorpos), player)
eq(core.get_node(doorpos).name, "hearth:door")
end)
test("storage is claim-bound, form-gated, role-checked, and empty before removal", function()
local ground, chest = pos(0, -1, 0), pos()
put(ground, "hearth:stone")
local stack_item = stack("hearth:storage")
items["hearth:storage"].on_place(stack_item, player, point(ground, chest))
eq(stack_item:get_count(), 0)
local def = nodes["hearth:storage"]
local inv = core.get_meta(chest):get_inventory()
eq(inv.size, 24)
claim_owner, member_role = "homesteader", "storekeeper"
def.on_rightclick(chest, core.get_node(chest), player)
eq(formspecs[#formspecs].form, "hearth:storage")
eq(def.allow_metadata_inventory_put(chest, "main", 1, stack("hearth:planks 3"), player), 3)
eq(def.can_dig(chest, player), false)
member_role = nil
local form_count = #formspecs
def.on_rightclick(chest, core.get_node(chest), player)
eq(#formspecs, form_count)
claim_owner = "new-homesteader"
eq(def.allow_metadata_inventory_take(chest, "main", 1, stack("hearth:planks"), player), 0)
eq(def.can_dig(chest, player), false)
claim_owner = "homesteader"
claim_owner, member_role = "homesteader", "resident"
inv.items[1] = "hearth:planks"
eq(def.can_dig(chest, player), false)
inv.items = {}
eq(def.can_dig(chest, player), true)
end)
test("drying racks require a shared claim and cloaks toggle persistent player protection", function()
local ground, rack = pos(0, -1, 0), pos()
put(ground, "hearth:stone")
claim_owner, member_role = "homesteader", "storekeeper"
local stack_item = stack("hearth:drying_rack")
items["hearth:drying_rack"].on_place(stack_item, player, point(ground, rack))
eq(stack_item:get_count(), 1)
member_role = "resident"
items["hearth:drying_rack"].on_place(stack_item, player, point(ground, rack))
eq(stack_item:get_count(), 0)
local cloak = stack("hearth:cloak")
items["hearth:cloak"].on_use(cloak, player)
eq(player_meta:get_int("hearth:cloak"), 1)
eq(cloak:get_count(), 1)
items["hearth:cloak"].on_use(cloak, player)
eq(player_meta:get_int("hearth:cloak"), 0)
end)
test("real factory wiring publishes calendar policy and the exact wildlife fauna catalogue",function()
local L=assert(hearth.lifecycle)
eq(L.SECONDS_PER_DAY,86400); eq(L.DAYS_PER_YEAR,365.2425)
eq(#L.ids("plant"),100); eq(#L.ids("animal"),23)
eq(hearth.fauna,hearth.wildlife.fauna)
eq(#hearth.fauna.list(),23)
local status=hearth.wildlife.status(); eq(status.species_count,23)
local breeding={bank_vole=true,wood_mouse=true,blackbird=true,mallard=true,hare=true,fox=true}
for _,r in ipairs(hearth.fauna.list()) do
assert(entities[r.entity] and status.counts[r.id]==0,r.id)
assert(r.profile and r.profile.reproduction_enabled==(breeding[r.id]==true),r.id)
assert(not hearth.fauna.can_breed(r.id,1782043200,1782043200,"female"),"Newborn must not breed: "..r.id)
end
-- UTC dates: 2026-06-21 and 2027-06-21, NOT dates built in local time.
local phase=L.phase(L.plant("oak"),1782043200,1813579200)
eq(phase.age_days,365); eq(phase.death_due,false)
local groups={trees=13,shrubs=14,grasses=10,herbs=22,ferns_mosses=11,fungi=10,aquatic=10,lichens=10}
for _,r in ipairs(hearth.botany.list()) do
local profile=assert(L.plant(r.id,r.group)); eq(profile.id,r.id)
eq(profile.kind,"plant"); eq(profile.group,r.group)
assert(type(profile.confidence)=="string" and type(profile.evidence)=="table")
for _,field in ipairs({"germination_days","juvenile_days","maturity_days","lifespan_days"}) do
local range=profile[field]
assert(range==false or (type(range)=="table" and range.min>=0 and range.max>=range.min
and type(range.confidence)=="string" and range.source_url~=nil),r.id..":"..field)
end
local start=assert(L.phase(profile,1782043200,1782043200)); eq(start.age_days,0)
local after=assert(L.phase(profile,1782043200,1813579200)); eq(after.age_days,365)
eq(after.death_due,false); assert(L.resolve(profile,731104),r.id)
eq(#hearth.botany.list(r.group),groups[r.group])
assert(nodes[r.node],r.node)
if r.group=="trees" then
for _,part in ipairs({"seedling","young","mature","log","leaves","planks"}) do
eq(hearth.botany.kind("hearth:"..r.id.."_"..part),r.id)
end
end
end
end)
test("actual pollination and frugivore transactions share the human annual fruit budget",function()
local old_climate,old_allowed=hearth.climate_at,hearth.claim_allowed
hearth.climate_at=function() return {season="Summer"} end
hearth.claim_allowed=function() return true end
claim=false; now=1782043200
local p=pos(0,1,0); put(pos(),"hearth:grass"); put(p,"hearth:bilberry")
local m=core.get_meta(p); m:set_string("succ_natural","1")
assert(hearth.succession.pollinate(p)) -- requires lifecycle captured at construction
eq(tonumber(m:get_string("succ_pollinated_until")),now+86400)
local ok,n=hearth.botany.harvest(p,player); assert(ok); eq(n,3)
eq(m:get_string("succ_pollinated_until"),"0")
local deadline=now+365.2425*86400
eq(tonumber(m:get_string("botany_ready_at")),deadline)
assert(not hearth.succession.frugivore(p,"bilberry"))
now=deadline; assert(hearth.succession.frugivore(p,"bilberry"))
eq(tonumber(m:get_string("botany_ready_at")),now+365.2425*86400)
assert(not hearth.botany.harvest(p,player))
hearth.climate_at,hearth.claim_allowed=old_climate,old_allowed
end)
os.time = real_time
assert(passed==40,"Unexpected focused content integration count")
print("1.." .. passed)