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

692 lines
37 KiB
Lua
Executable File

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