-- Original MIT-licensed integration checks. Staged as a disposable world mod; -- never copy this into the persistent play world. No engine globals are mocked. local mode = core.settings:get("hearth_launcher_mode") local token = core.settings:get("hearth_smoke_token") assert(mode == "check" or mode == "smoke" or mode == "seed", "Invalid launcher test mode") assert(token and token:match("^[%x%-]+$"), "Missing launcher test token") local modpath = core.get_modpath(core.get_current_modname()) local finished = false local function finish(ok, message) if finished then return end finished = true if ok then core.log("action", "LAST_HEARTH_" .. mode:upper() .. "_PASS " .. token) else core.log("error", "LAST_HEARTH_TEST_FAIL: " .. tostring(message)) end core.request_shutdown("Last Hearth " .. mode .. " complete", false, 0) end local function guarded(fn) local ok, message = pcall(fn) if not ok then finish(false, message) end end local function syntax_check() local manifest = dofile(modpath .. "/syntax_manifest.lua") assert(#manifest > 0, "Empty syntax manifest") for _, entry in ipairs(manifest) do local chunk, message = loadfile(modpath .. "/syntax/" .. entry.file) assert(chunk, entry.label .. ": " .. tostring(message)) end core.log("action", "Last Hearth: compiled " .. #manifest .. " Lua files (standalone specs not executed)") end local function registry_check() assert(type(hearth) == "table", "Real hearth global was not loaded") assert(type(hearth.model) == "table", "hearth.model missing") assert(type(hearth.players) == "table", "hearth.players missing") assert(type(hearth.movement.step) == "function", "Movement controller not loaded") assert(type(hearth.combat.valid_attack) == "function", "Combat rules not loaded") assert(type(hearth.presentation_step) == "function", "Held-tool presentation not loaded") assert(type(hearth.feedback_step) == "function", "Movement/target feedback not loaded") assert(type(hearth.course.report) == "function", "Disposable course not loaded") assert(type(hearth.home_for) == "function" and type(hearth.store_home) == "function", "Home service not loaded") assert(type(hearth.start_rest) == "function" and type(hearth.recipes) == "function", "M2 first-night services not loaded") for module, methods in pairs({movement = {"step", "delta"}, combat = {"valid_attack", "valid_guard", "sample"}, construction = {"preview"}, course = {"report"}}) do assert(type(hearth[module]) == "table", "Missing M1 module: " .. module) for _, method in ipairs(methods) do assert(type(hearth[module][method]) == "function", "Missing M1 API: " .. module .. "." .. method) end end for _, method in ipairs({"feedback", "feedback_step", "feedback_sound"}) do assert(type(hearth[method]) == "function", "Missing feedback API: " .. method) end assert(next(hearth.players) == nil and next(hearth.sessions) == nil, "Headless smoke must not invent players") assert(#core.get_connected_players() == 0, "Headless smoke unexpectedly has live players") assert(hearth.course.report("") == "No course run in this session. Use /course.") assert(core.settings:get_bool("hearth_m1_course") ~= true, "Ordinary smoke must not enable course mode") assert(hearth.storage:get_string("m1_course_world_nonce") == "", "Ordinary smoke enrolled a course world") assert(not core.registered_nodes["hearth:m1_course_half_step"], "Course-only node leaked into ordinary smoke") for _, name in ipairs({"course", "practice", "spar", "comfort", "home", "sethome", "recipes"}) do local command = assert(core.registered_chatcommands[name], "Missing gameplay command: " .. name) assert(type(command.func) == "function") assert(command.func("", "") == false, "Command accepted a nonlive console caller: " .. name) end local stationary = assert(core.registered_entities["hearth:practice"], "Stationary target missing") local moving = assert(core.registered_entities["hearth:sparring"], "Moving sparring target missing") assert(stationary.initial_properties.visual == "cube" and stationary.initial_properties.static_save == true) assert(moving.initial_properties.visual == "mesh" and moving.initial_properties.static_save == false) assert(moving.initial_properties.mesh == "hearth_person.b3d") assert(moving.initial_properties.textures[1] == "hearth_person.png") for _, def in ipairs({stationary, moving}) do assert(def.initial_properties.physical == true and def.initial_properties.collide_with_objects == false) for _, method in ipairs({"on_activate", "on_step", "on_punch", "on_deactivate"}) do assert(type(def[method]) == "function", "Missing target lifecycle: " .. method) end end assert(core.get_modpath("hearth"), "hearth mod missing") for _, name in ipairs({"default", "doors", "farming", "fire", "stairs", "wool", "sfinv"}) do assert(not core.get_modpath(name), "Unexpected MTG dependency: " .. name) end for name in pairs(core.registered_items) do assert(not name:match("^default:"), "MTG item leaked into standalone game: " .. name) end for _, name in ipairs({"stone", "soil", "grass", "sand", "water_source", "water_flowing", "log", "leaves", "loose_stone", "sticks", "berry_bush", "berry_bush_bare", "sapling", "grain_patch", "crop_1", "crop_2", "crop_3", "crop_4", "clay_bank", "iron_ore_vein", "fish_trap", "weir_stone", "reeds", "planks", "wall", "roof", "foundation", "frame", "stairs", "door", "door_open", "window", "vent", "storage", "pantry", "drying_rack", "bedroll", "torch", "fire", "fire_lit"}) do assert(core.registered_nodes["hearth:" .. name], "Missing node: " .. name) end for _, name in ipairs({"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", "charcoal_charge", "smelt_charge", "clay", "clay_jar", "clay_jar_charge", "dried_food", "dried_food_charge", "fish_bait", "raw_fish", "cooked_fish", "fish_drying_charge", "dried_fish", "firestarter", "cloak", "knife", "axe", "iron_axe", "pick", "spear"}) do assert(core.registered_items["hearth:" .. name], "Missing item: " .. name) end for name, expected in pairs({knife = {3, 0.7}, axe = {4, 1.1}, iron_axe = {6, 0.9}, pick = {3, 1.2}, spear = {5, 1.2}}) do local def = assert(core.registered_tools["hearth:" .. name]) local caps = ItemStack("hearth:" .. name):get_tool_capabilities() assert(caps.damage_groups.fleshy == expected[1] and math.abs(caps.full_punch_interval - expected[2]) < 0.0001, "Weapon capabilities mismatch: " .. name) if name ~= "iron_axe" then assert(def.inventory_image == "hearth_icon_" .. name .. ".png", "Weapon icon missing: " .. name) else assert(def.inventory_image:find("hearth_", 1, true), "Iron axe image missing") end end for name, expected in pairs({soil = {"crumbly", "soil"}, stone = {"cracky", "stone"}, log = {"choppy", "wood"}, planks = {"choppy", "wood"}, wall = {"choppy", "wood"}, roof = {"snappy", "soil"}}) do local def = core.registered_nodes["hearth:" .. name] assert((def.groups[expected[1]] or 0) > 0, "Missing material group: " .. name) for _, event in ipairs({"dug", "place"}) do local spec = assert(def.sounds[event], "Missing node sound: " .. name .. "." .. event) assert(spec.name == "hearth_dig_" .. expected[2] and type(spec.gain) == "number" and spec.gain > 0 and spec.gain <= 1, "Invalid node sound: " .. name .. "." .. event) end end assert(core.get_item_group("hearth:water_source", "water") > 0) assert(core.get_item_group("hearth:water_flowing", "liquid") > 0) assert(core.get_item_group("hearth:loose_stone", "flint") > 0) assert(core.get_dig_params(core.registered_nodes["hearth:log"].groups, ItemStack("hearth:axe"):get_tool_capabilities()).diggable, "Axe cannot harvest course logs") local hand = ItemStack(""):get_tool_capabilities() local deadwood = assert(core.registered_nodes["hearth:deadwood"]) assert(not core.get_dig_params(deadwood.groups, hand).diggable, "Bare hands unexpectedly harvest fallen logs") assert(core.get_dig_params(deadwood.groups, ItemStack("hearth:axe"):get_tool_capabilities()).diggable, "Axe cannot harvest fallen logs") local drops = core.get_node_drops("hearth:deadwood", "hearth:axe") assert(#drops == 1 and ItemStack(drops[1]):get_name() == "hearth:log" and ItemStack(drops[1]):get_count() == 1, "Fallen logs must drop exactly one timber with an axe") assert(deadwood.can_dig == hearth.construction.can_dig, "Fallen logs bypass construction permissions") local timber = {"log", "stone"} assert(#hearth.diversity.species_order == 13, "Expected thirteen timber species") for _, species in ipairs(hearth.diversity.species_order) do local name = species .. "_log" timber[#timber + 1] = name assert(core.get_dig_params(assert(core.registered_nodes["hearth:" .. name]).groups, ItemStack("hearth:axe"):get_tool_capabilities()).diggable, "Axe cannot harvest " .. name) end for _, name in ipairs(timber) do assert(not core.get_dig_params(core.registered_nodes["hearth:" .. name].groups, hand).diggable, "Bare hands unexpectedly harvest " .. name) end local catalogue = hearth.botany.list() assert(#catalogue == 100, "Expected all 100 botanical species") for _, record in ipairs(catalogue) do assert(core.registered_nodes[record.node], "Missing native botanical species: " .. record.node) end local trees, cubes = 0, 0 for name, def in pairs(core.registered_nodes) do local kind = hearth.seasons.kind(name) if name:match("^hearth:") and (hearth.botany.group_of(name) == "trees" or ({log=true, leaves=true, sapling=true, tree_seedling=true, young_tree=true, mature_tree=true, deadwood=true})[kind]) then assert(def.drawtype ~= "mesh", "Tree still uses mesh drawtype: " .. name) trees = trees + 1 if (def.groups.tree or 0) > 0 then assert(def.drawtype == "normal" and def.walkable == true and def.paramtype2 == "facedir" and (def.visual_scale or 1) == 1, "Timber must be a full facedir cube: " .. name) for _, box in ipairs({"node_box", "selection_box", "collision_box"}) do assert(not def[box] or def[box].type == "regular", "Non-full timber box: " .. name .. "." .. box) end cubes = cubes + 1 end end end assert(deadwood.drawtype == "normal" and deadwood.paramtype2 == "facedir", "Fallen log must be a full facedir cube") core.log("action", "Last Hearth native botany/wood: species=100, timber_species=13, nonmesh_tree_nodes=" .. trees .. ", fullcube_timber_nodes=" .. cubes .. "; fallen hand=false, axe=true, drop=hearth:log 1") for _, name in ipairs({"knife", "axe", "iron_axe", "pick", "spear", "firestarter", "vessel", "fire", "wall", "roof", "foundation", "frame", "stairs", "door", "window", "vent", "storage", "pantry", "drying_rack", "bedroll", "fish_trap", "fish_bait", "fish_drying_charge", "torch", "cloak", "charcoal_charge", "clay_jar_charge", "planks", "sticks", "raw_food"}) do local output = "hearth:" .. name local recipe = core.get_craft_recipe(output) assert(recipe.items and next(recipe.items), "Missing craft: " .. output) for _, ingredient in pairs(recipe.items) do assert(ingredient == "" or core.registered_items[ingredient], "Unknown ingredient: " .. ingredient) end local inputs = {} for index = 1, (recipe.width == 0 and #recipe.items or recipe.width * 3) do inputs[index] = ItemStack(recipe.items[index] or "") end local width = recipe.width > 0 and recipe.width or #inputs local result = core.get_craft_result({method = "normal", width = width, items = inputs}) assert(result.item:get_name() == output, "Engine craft result mismatch: " .. output .. " (method=" .. tostring(recipe.method) .. ", width=" .. tostring(recipe.width) .. ", got=" .. result.item:get_name() .. " x" .. result.item:get_count() .. ")") assert(result.item:get_count() == ItemStack(recipe.output):get_count(), "Craft quantity mismatch: " .. output) end assert(core.registered_aliases.mapgen_stone == "hearth:stone", "Missing mapgen stone alias") assert(core.registered_aliases.mapgen_water_source == "hearth:water_source", "Missing water alias") assert(core.registered_biomes.hearth_temperate, "Missing temperate biome") assert(core.registered_biomes.hearth_riverbank, "Missing riverbank biome") assert(core.registered_entities["hearth:held_visual"], "Missing held-tool visual entity") assert(core.registered_entities["hearth:sparring"], "Missing moving sparring entity") assert(core.registered_entities["hearth:hare"], "Missing passive bounded wildlife") assert(core.settings:get_bool("enable_pvp") == false, "PvP must be disabled") assert(core.settings:get_bool("secure.enable_security") == true, "Mod security must be enabled") assert(core.settings:get("bind_address") == "127.0.0.1", "Server must bind loopback only") end -- Check the actual packaged media, not source-art names or a mocked registry. local function media_check() local files, missing, references = {}, {}, 0 local path = assert(core.get_modpath("hearth")) for _, directory in ipairs({"textures", "models", "sounds"}) do for _, name in ipairs(core.get_dir_list(path .. "/" .. directory, false)) do files[name] = directory end end local function reference(name, source) references = references + 1 if not files[name] then missing[#missing + 1] = source .. " -> " .. name end end local function images(value, source) if type(value) == "string" then -- Modifiers (combine, inventorycube, sheet, opacity, etc.) retain -- their component filenames. Never interpret them as disk paths. for name in value:gmatch("[%w_%-]+%.png") do reference(name, source) end elseif type(value) == "table" then for key, entry in pairs(value) do images(entry, source .. "." .. tostring(key)) end end end local items, entities = 0, 0 local function definition(def, source) for _, key in ipairs({"tiles", "overlay_tiles", "special_tiles", "inventory_image", "wield_image", "inventory_overlay", "wield_overlay", "textures"}) do images(def[key], source .. "." .. key) end if def.mesh and def.mesh ~= "" then reference(def.mesh, source .. ".mesh") end for event, spec in pairs(def.sounds or {}) do local name = type(spec) == "table" and spec.name or spec if type(name) == "string" and name ~= "" then reference(name .. ".ogg", source .. ".sounds." .. event) end end end for name, def in pairs(core.registered_items) do if name:match("^hearth:") then items = items + 1; definition(def, name) end end for name, def in pairs(core.registered_entities) do if name:match("^hearth:") then entities = entities + 1; definition(def.initial_properties or {}, name) end end -- Original generated B3D/OBJ files can also carry atlas references. for name, directory in pairs(files) do if directory == "models" then local file = assert(io.open(path .. "/models/" .. name, "rb")) local bytes = file:read(2 * 1024 * 1024 + 1); file:close() assert(#bytes <= 2 * 1024 * 1024, "Unexpectedly large packaged mesh: " .. name) images(bytes, "models/" .. name) end end table.sort(missing) assert(#missing == 0, "Packaged media closure failed (" .. #missing .. "):\n" .. table.concat(missing, "\n")) assert(items > 0 and entities > 0 and references > 0, "Empty media closure check") core.log("action", "Last Hearth media closure: " .. items .. " items, " .. entities .. " entities, " .. references .. " references; missing=0") end local function craft_closure_check() local names, count, outputs = {}, 0, 0 for name in pairs(core.registered_items) do names[#names + 1] = name end table.sort(names) local function ingredient(value) if value:sub(1, 6) ~= "group:" then assert(value == "" or core.registered_items[ItemStack(value):get_name()], "Unknown craft ingredient: " .. value) return value end for _, name in ipairs(names) do local matches = true for group in value:sub(7):gmatch("[^,]+") do if core.get_item_group(name, group) == 0 then matches = false end end if matches then return name end end error("No registered item satisfies craft ingredient: " .. value) end for _, name in ipairs(names) do if name:match("^hearth:") then local recipes = core.get_all_craft_recipes(name) or {} if #recipes > 0 then outputs = outputs + 1 end for _, recipe in ipairs(recipes) do local inputs = {} for i = 1, (recipe.width == 0 and #recipe.items or recipe.width * 3) do inputs[i] = ItemStack(ingredient(recipe.items[i] or "")) end local result = core.get_craft_result({method = recipe.method, width = recipe.width > 0 and recipe.width or #inputs, items = inputs}) local expected = ItemStack(recipe.output) assert(result.item:get_name() == expected:get_name() and result.item:get_count() == expected:get_count(), "Craft closure mismatch: " .. recipe.output .. " got " .. result.item:to_string()) count = count + 1 end end end assert(count > 0 and outputs > 0, "Empty craft closure check") core.log("action", "Last Hearth craft closure: " .. count .. " recipes, " .. outputs .. " outputs") end local function worldgen_registry_check() local profiles = assert(hearth.biomes and hearth.biomes.biome_profiles, "Biome profiles not wired") assert(#profiles == 10, "Expected ten biome profiles") for _, p in ipairs(profiles) do local def = assert(core.registered_biomes[p.name], "Missing native biome: " .. p.name) assert(def.node_top == "hearth:" .. p.top and def.node_filler == "hearth:" .. p.filler and def.node_stone == "hearth:" .. p.rock and def.y_min == p.min and def.y_max == p.max, "Native biome/profile mismatch: " .. p.name) end local ores, count = {}, 0 for _, def in pairs(core.registered_ores) do if def.name and def.name:match("^hearth:") then assert(core.registered_nodes[def.ore], "Unknown ore: " .. tostring(def.ore)) for _, host in ipairs(type(def.wherein) == "table" and def.wherein or {def.wherein}) do assert(core.registered_nodes[host] and core.get_item_group(host, "stone") > 0, "Invalid native ore host: " .. tostring(host)) end ores[def.ore], count = true, count + 1 end end assert(count == 9, "Expected nine native geological/ore registrations; got " .. count) for _, ore in ipairs({"quartz_vein", "copper_ore_vein", "coal_ore_vein", "iron_ore_vein"}) do assert(ores["hearth:" .. ore], "Missing ore registration: " .. ore) end for setting, expected in pairs({mg_name="v7", mapgen_limit="4096", water_level="1", mgv7_mount_zero_level="0"}) do assert(core.get_mapgen_setting(setting) == expected, "Saved mapgen setting mismatch: " .. setting) end for setting, expected in pairs({mgv7_np_terrain_base={12,45,900,384,900}, mgv7_np_terrain_alt={-8,24,900,384,900}, mgv7_np_height_select={-4,14,1400,320,1400}, mgv7_np_mount_height={250,100,700,700,700}, mgv7_np_mountain={-0.6,1,320,280,320}}) do local actual = assert(core.get_mapgen_setting_noiseparams(setting), "Missing saved mapgen noise: " .. setting) assert(math.abs(actual.offset - expected[1]) < 0.0001 and math.abs(actual.scale - expected[2]) < 0.0001 and actual.spread.x == expected[3] and actual.spread.y == expected[4] and actual.spread.z == expected[5], "Saved terrain scale mismatch: " .. setting) end core.log("action", "Last Hearth worldgen registry: biomes=10, ores=9; saved Epic-inspired v7 terrain base=12/45/900x384x900, alt=-8/24, height_select=-4/14, mount=250/100, mapgen_limit=4096") end local function unit_check() -- Each standalone harness receives its own globals and os table. Mocked core, -- hearth and os.time never replace globals of the running game. local manifest = dofile(modpath .. "/syntax_manifest.lua") local lookup = {} for _, entry in ipairs(manifest) do lookup[entry.label] = modpath .. "/syntax/" .. entry.file end local count, labels = 0, {} for label in pairs(lookup) do labels[#labels + 1] = label end table.sort(labels) for _, module in ipairs({"model", "content", "schema", "persistence", "clock", "players", "runtime", "access", "environment", "init", "ecology", "seasons", "succession", "wildlife", "movement", "combat", "construction", "course", "encounters", "presentation", "biomes", "diversity", "m2_processes", "m2_rest", "recipe", "botany", "species_effects", "species_reader", "character", "lifecycle", "fauna"}) do assert(lookup["tests/" .. module .. "_spec.lua"], "Missing regression suite: " .. module) end local test_modules = {m2_processes = "processes", m2_rest = "runtime", recipe = "interface"} local real_globals = {}; for k, v in pairs(_G) do real_globals[k] = v end for _, label in ipairs(labels) do local filename = lookup[label] if label:match("_spec%.lua$") then local module = label:match("tests/(.+)_spec%.lua$") local target = assert(lookup["mods/hearth/" .. (test_modules[module] or module) .. ".lua"], "Missing test module") local env = setmetatable({arg = {target}, hearth = {}, core = {}, minetest = {}}, {__index = _G}) -- Do not inherit the live hearth.combat/schema via __index. Sibling -- modules execute in this suite's environment, never against live APIs. for _, name in ipairs({"os", "io", "math", "table", "string", "vector"}) do env[name] = {}; for k, v in pairs(_G[name]) do env[name][k] = v end end env._G = env env.dofile = function(path) local permitted = false for _, mapped in pairs(lookup) do if mapped == path then permitted = true; break end end assert(permitted, "Unit harness attempted unexpected file access") local chunk = assert(loadfile(path)); setfenv(chunk, env); return chunk() end -- Content factories load by engine modpath; remap only known source files -- into their isolated staged copies, never the live game or arbitrary paths. local isolated_dofile = env.dofile env.dofile = function(path) local live = core.get_modpath("hearth") .. "/" if path:sub(1, #live) == live then path = assert(lookup["mods/hearth/" .. path:sub(#live + 1)], "Unmapped module") end -- Harness derives sibling paths from its flattened target filename. if not path:match("/%d+%.lua$") then local sibling = path:match("([^/\\]+%.lua)$") path = lookup["mods/hearth/" .. (sibling or "")] or path end return isolated_dofile(path) end local chunk = assert(loadfile(filename)); setfenv(chunk, env); chunk() for k, v in pairs(real_globals) do assert(_G[k] == v, "Unit suite replaced live global: " .. tostring(k)) end for k in pairs(_G) do assert(real_globals[k] ~= nil, "Unit suite leaked live global: " .. tostring(k)) end assert(next(hearth.players) == nil and next(hearth.sessions) == nil, "Unit suite leaked live player state") count = count + 1 end end assert(count == 31, "Expected all 31 regression suites; got " .. count) core.log("action", "Last Hearth: " .. count .. " isolated unit suites executed") end -- Four separated, narrow columns, not a huge continuous exploration volume. -- Their deep slices exercise actual ore hosts; surface samples need not expose -- every biome (and no individual biome is required at the arrival point). local function worldgen_check(done) if mode == "seed" then done(); return end local positions = {{-1200,-800}, {1200,800}, {-800,1200}, {800,-1200}} local totals, biomes, index = {}, {}, 0 local wanted = {"hearth:granite", "hearth:slate", "hearth:limestone", "hearth:sandstone", "hearth:quartz_vein", "hearth:copper_ore_vein", "hearth:coal_ore_vein", "hearth:iron_ore_vein"} local function next_probe() index = index + 1 if index > #positions then local report, biome_count = {}, 0 for _, name in ipairs(wanted) do assert((totals[name] or 0) > 0, "Broad worldgen probes missing material: " .. name) report[#report + 1] = name .. "=" .. totals[name] end for _ in pairs(biomes) do biome_count = biome_count + 1 end assert(biome_count >= 2, "Broad worldgen probes found fewer than two surface biomes") core.log("action", "Last Hearth broad worldgen: probes=4, surface_biomes=" .. biome_count .. "; " .. table.concat(report, ", ")) done(); return end local p = positions[index] local minimum, maximum = {x=p[1]-8,y=-112,z=p[2]-8}, {x=p[1]+8,y=320,z=p[2]+8} core.emerge_area(minimum, maximum, function(_, action, remaining) if finished then return end guarded(function() assert(action ~= core.EMERGE_ERRORED and action ~= core.EMERGE_CANCELLED, "Broad worldgen emergence failed at " .. p[1] .. "," .. p[2]) if remaining ~= 0 then return end core.after(0, function() guarded(function() local _, counts = core.find_nodes_in_area(minimum, maximum, wanted) for name, count in pairs(counts) do totals[name] = (totals[name] or 0) + count end for x = minimum.x, maximum.x, 8 do for z = minimum.z, maximum.z, 8 do for y = 318, 0, -1 do local pos = {x=x,y=y,z=z} local name = core.get_node(pos).name if hearth.biomes.surface(name) then local data = assert(core.get_biome_data(pos), "Missing native biome data") biomes[assert(core.get_biome_name(data.biome))] = true break end end end end core.log("action", "Last Hearth worldgen probe: x=" .. p[1] .. ", z=" .. p[2] .. ", y=-112..320") next_probe() end) end) end) end) end next_probe() end local function actor_check() -- Native objects, not PlayerRef doubles. This checks gravity/collision and -- animation API state only: no consenting player means no chase or combat. local block = {x = 40, y = 85, z = 40} assert(core.forceload_block(block, true), "Cannot hold native actor test block active") local objects = {} local function cleanup() for _, object in ipairs(objects) do if object:get_pos() then object:remove() end end core.forceload_free_block(block, true) end core.register_on_shutdown(cleanup) for x = 37, 43 do for z = 37, 43 do core.set_node({x = x, y = 84, z = z}, {name = "hearth:stone"}) for y = 85, 90 do core.set_node({x = x, y = y, z = z}, {name = "air"}) end end end for i, name in ipairs({"hearth:practice", "hearth:sparring"}) do local object = assert(core.add_entity({x = 34 + i * 4, y = 87, z = 40}, name), "Native actor spawn failed") objects[#objects + 1] = object local entity = assert(object:get_luaentity()) assert(not object:is_player() and entity.name == name and entity.health == 30 and entity.phase == "idle") assert(entity.target == nil and entity.owner == nil and entity.mobile == (i == 2)) assert(object:get_acceleration().y < -9 and object:get_properties().physical == true) assert(object:get_armor_groups().immortal == 1) end local animated = objects[2] local idle, speed, blend, loop = animated:get_animation() assert(idle.x == 0 and idle.y == 19 and speed == 12 and loop == true, "Native idle animation mismatch") for _, frame in ipairs({{0,19,12,true}, {20,39,24,true}, {40,59,32,true}, {60,69,28,false}, {70,79,12,true}, {80,99,20,true}}) do animated:set_animation({x = frame[1], y = frame[2]}, frame[3], 0.12, frame[4]) local range, rate, transition, looping = animated:get_animation() assert(range.x == frame[1] and range.y == frame[2] and range.x >= 0 and range.y <= 99) assert(rate == frame[3] and math.abs(transition - 0.12) < 0.0001 and looping == frame[4]) end animated:set_animation({x = 0, y = 19}, 12, 0.12, true) core.after(2, function() guarded(function() for i, object in ipairs(objects) do local pos, velocity = assert(object:get_pos()), object:get_velocity() assert(math.abs(pos.x - (34 + i * 4)) < 0.02 and math.abs(pos.z - 40) < 0.02, "Unowned actor moved horizontally without player consent") assert(pos.y >= 84.49 and pos.y <= 84.7 and math.abs(velocity.y) < 0.2, "Native gravity/collision did not settle actor on the test floor") local entity = object:get_luaentity() assert(entity.phase == "idle" and entity.target == nil and entity.health == 30) end cleanup() core.log("action", "Last Hearth: native stationary/sparring gravity, collision and animation API checks passed; no player chase, rendered animation or audible playback proven") finish(true) end) end) end local function emerge_check() local callbacks = 0 core.emerge_area({x = -64, y = -32, z = -64}, {x = 64, y = 320, z = 64}, function(_, action, remaining) if finished then return end guarded(function() assert(action ~= core.EMERGE_ERRORED and action ~= core.EMERGE_CANCELLED, "Map emergence failed or was cancelled") callbacks = callbacks + 1 if remaining ~= 0 then return end -- Inspect after mapgen callbacks have completed, on a server step. core.after(0, function() guarded(function() assert(callbacks > 0, "No emerge callbacks received") local material = false for z = -16, 16, 4 do for y = -32, 16, 4 do for x = -16, 16, 4 do local node = core.get_node_or_nil({x = x, y = y, z = z}) assert(node and node.name ~= "ignore", "Emerged map contains unloaded nodes") assert(core.registered_nodes[node.name], "Unregistered generated node: " .. node.name) assert(node.name == "air" or node.name:match("^hearth:"), "Foreign generated node: " .. node.name) if node.name:match("^hearth:") then material = true end end end end assert(material, "Mapgen produced no hearth terrain in the test volume") local resources, missing = {}, {} local minimum, maximum = {x = -64, y = 0, z = -64}, {x = 64, y = 318, z = 64} local function side_air(p) for _, offset in ipairs({{1, 0}, {-1, 0}, {0, 1}, {0, -1}}) do local node = core.get_node_or_nil({x = p.x + offset[1], y = p.y, z = p.z + offset[2]}) local def = node and core.registered_nodes[node.name] if node and node.name == "air" and def and def.walkable == false then return true end end return false end for _, entry in ipairs({{"ground", "group:soil", "hearth:sand", "hearth:gravel", "hearth:scree", "hearth:snow", "group:stone"}, {"flint", "group:flint"}, {"sticks", "hearth:sticks"}, {"berries", "hearth:berry_bush"}, {"reeds", "hearth:reeds"}, {"log", "hearth:log", "hearth:oak_log", "hearth:birch_log", "hearth:pine_log", "hearth:willow_log", "hearth:alder_log"}, {"water", "group:water"}}) do local name, selectors = entry[1], {} for i = 2, #entry do selectors[#selectors + 1] = entry[i] end -- Canopies cover trunk tops, but an axe can reach their sides. -- Require loaded air at the same height: buried logs do not count. local nodes, raw_count if name == "log" then local logs = core.find_nodes_in_area(minimum, maximum, selectors) nodes, raw_count = {}, #logs for _, pos in ipairs(logs) do if side_air(pos) then nodes[#nodes + 1] = pos end end else -- Buried water/ground still cannot prove surface access. nodes = core.find_nodes_in_area_under_air(minimum, maximum, selectors) end resources[#resources + 1] = name .. "=" .. #nodes .. (raw_count and " (total=" .. raw_count .. ")" or "") if #nodes == 0 then missing[#missing + 1] = name end end core.log("action", "Last Hearth starting resources: seed=" .. tostring(core.get_mapgen_setting("seed")) .. "; " .. table.concat(resources, ", ")) if #missing > 0 then local biomes, report = {}, {} for x = -64, 64, 4 do for z = -64, 64, 4 do for y = 318, 0, -1 do local pos = {x = x, y = y, z = z} if hearth.biomes.surface(core.get_node(pos).name) then local data = core.get_biome_data(pos) local name = data and core.get_biome_name(data.biome) or "unknown" biomes[name] = (biomes[name] or 0) + 1 break end end end end for name, count in pairs(biomes) do report[#report + 1] = name .. "=" .. count end table.sort(report) core.log("error", "Last Hearth starting biome surface samples (4-node grid): " .. table.concat(report, ", ")) error("Starting region missing required resource: " .. table.concat(missing, ", ")) end if mode == "seed" then local dry_spawn = 0 local function dry_clear(p) local node = core.get_node_or_nil({x = p.x, y = p.y, z = p.z}) if not node or node.name == "ignore" then return false end local def = core.registered_nodes[node.name] local groups = def and def.groups or {} return def and def.walkable == false and (not def.liquidtype or def.liquidtype == "none") and (groups.water or 0) == 0 and (groups.liquid or 0) == 0 and (groups.lava or 0) == 0 and (def.damage_per_second or 0) == 0 end local function natural_support(p) local node = core.get_node_or_nil(p) local support = node and node.name ~= "ignore" and core.registered_nodes[node.name] local groups = support and support.groups or {} if not (support and support.walkable == true and (not support.liquidtype or support.liquidtype == "none") and (groups.water or 0) == 0 and (groups.liquid or 0) == 0 and (groups.lava or 0) == 0 and (support.damage_per_second or 0) == 0) then return false end for _, group in ipairs({"tree", "leaves", "wood", "hearth_canopy", "hearth_building", "hearth_non_support", "flora", "hearth_ecosystem"}) do if (groups[group] or 0) > 0 then return false end end return (groups.soil or 0) > 0 or (groups.stone or 0) > 0 or (groups.sand or 0) > 0 or (groups.crumbly or 0) > 0 end local function landing_patch(x, y, z) for dx = -1, 1 do for dz = -1, 1 do local p = {x = x + dx, y = y, z = z + dz} if not natural_support(p) and not (dry_clear(p) and natural_support({x = p.x, y = y - 1, z = p.z})) then return false end if not dry_clear({x = p.x, y = y + 1, z = p.z}) or not dry_clear({x = p.x, y = y + 2, z = p.z}) then return false end end end return true end -- Match the player's first natural surface and 3x3 dry patch; -- never descend through terrain/water to count a cave floor. for x = -64, 64, 4 do for z = -64, 64, 4 do for ground_y = 318, 0, -1 do local p = {x = x, y = ground_y, z = z} local node = core.get_node_or_nil(p) local def = node and node.name ~= "ignore" and core.registered_nodes[node.name] if not def then break end local groups = def.groups or {} if natural_support(p) then if math.abs(x) < 64 and math.abs(z) < 64 and landing_patch(x, ground_y, z) then dry_spawn = dry_spawn + 1 end break elseif (def.liquidtype and def.liquidtype ~= "none") or (groups.water or 0) > 0 or (groups.liquid or 0) > 0 or (groups.lava or 0) > 0 or (def.damage_per_second or 0) > 0 or (def.walkable ~= false and (groups.tree or 0) <= 0 and (groups.leaves or 0) <= 0 and (groups.hearth_canopy or 0) <= 0) then break end end end end assert(dry_spawn > 0, "Starting seed has no dry, supported spawn surface") core.log("action", "Last Hearth seed arrival surfaces: " .. dry_spawn) end worldgen_check(function() local p = {x = 0, y = 85, z = 0} -- Clear this artificial fixture: dramatic terrain can now -- occupy its old fixed altitude. This is not spawn evidence. for x = -2, 2 do for z = -2, 2 do for y = 86, 88 do core.set_node({x=x,y=y,z=z}, {name="air"}) end end end for x = -3, 3 do for z = -3, 3 do core.set_node({x = x, y = 85, z = z}, {name = "hearth:stone"}) core.set_node({x = x, y = 89, z = z}, {name = "hearth:roof"}) end end for y = 86, 88 do for side = -3, 3 do core.set_node({x = -3, y = y, z = side}, {name = "hearth:wall"}) core.set_node({x = 3, y = y, z = side}, {name = "hearth:wall"}) core.set_node({x = side, y = y, z = -3}, {name = "hearth:wall"}) core.set_node({x = side, y = y, z = 3}, {name = "hearth:wall"}) end end local env = hearth.environment(p) assert(env.enclosed and env.roof and env.shelter == 1 and not env.ventilated, "A closed room must be sheltered but unventilated") core.remove_node({x = 0, y = 89, z = 0}) hearth.invalidate_environment() assert(hearth.environment(p).shelter == 0, "Walls without a roof are exposed") actor_check() end) end) end) end) end) end core.after(1, function() guarded(function() syntax_check() if mode == "check" then finish(true); return end registry_check() media_check() craft_closure_check() worldgen_registry_check() if mode ~= "seed" then unit_check() end emerge_check() end) end) core.after(80, function() finish(false, "Engine test deadline exceeded (80s; launcher limit 90s)") end)