692 lines
37 KiB
Lua
Executable File
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)
|