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