local S = minetest.get_translator("lzr_levels")
local NS = function(s) return s end

lzr_levels = {}

-- Set to true to print a list of translatable
-- level strings to console. Helpful to update
-- the level name and NPC text locale files
local PRINT_TRANSLATABLE_LEVEL_STRINGS = minetest.settings:get_bool("lzr_print_special_translatable_strings", false)

local beginner_hud = minetest.settings:get_bool("lzr_beginner_hud_texts", true)

-- Time the level title/complete message is shown (seconds)
local LEVEL_CAPTION_TIME = 3.0
local FINAL_LEVEL_CAPTION_TIME = 5.0

-- Delay in seconds before the next level starts
local NEXT_LEVEL_DELAY = 3.0

-- Time in seconds for which to reduce the ambience sound
-- volume when one of the level event sounds plays
local SOUND_TIME_LEVEL_ENTER = 0.43
local SOUND_TIME_LEVEL_LEAVE = 0.50
local SOUND_TIME_LEVEL_COMPLETE = 2.34
local SOUND_TIME_LEVEL_SET_COMPLETE = 20.0

-- Informational list of recognized level flags
lzr_levels.flags = {
	"parrot_hint",
	"mark_parrot_talk",
	"show_controls_basic",
	"show_controls_punch",
	"show_controls_select_item",
	"hide_items",
	"remove_plants_above",
}
-- Human-readable names of the level flags
lzr_levels.flags_names = {
	["parrot_hint"] = S("Goldie speech is a hint"),
	["mark_parrot_talk"] = S("Show that you can talk to Goldie"),
	["show_controls_basic"] = S("Show controls: Basic"),
	["show_controls_punch"] = S("Show controls: Punching"),
	["show_controls_select_item"] = S("Show controls: Item selection"),
	["hide_items"] = S("Hide items"),
	["remove_plants_above"] = S("Remove plants above ceiling"),
}
-- Descriptions for the level flags
lzr_levels.flags_descriptions = {
	["parrot_hint"] = S("If player talks to Goldie the Parrot, it will first ask if the player wants a hint"),
	["mark_parrot_talk"] = S("Show “Talk to me!” above Goldie the Parrot"),
	["show_controls_basic"] = S("Show basic controls on screen. Can’t be combined with the other controls settings"),
	["show_controls_punch"] = S("Show punching controls on screen. Can’t be combined with the other controls settings"),
	["show_controls_select_item"] = S("Show item selection controls on screen. Can’t be combined with the other controls settings"),
	["hide_items"] = S("Hide the hotbar and inventory slots. Must not be used if the player spawns with an item"),
	["remove_plants_above"] = S("Remove tree trunks, leaves and plants above the ceiling (up to 24 node lengths)"),
}

local current_level = nil
local current_level_data = nil

local registered_level_packs = {}

local level_packs_completed = {}

local registered_on_level_start_loadings = {}
local registered_on_level_starts = {}

-- Store jobs for minetest.after to avoid
-- running them twice
local job_next_level, job_insta_win

-- Allow to register a callback function that is called
-- when a new level has started loading but is not ready to play yet.
-- Function syntax: callback(level_pack_id, level_number)
-- * level_pack_id: ID of the level pack
-- * level_number: Level number within that level pack
lzr_levels.register_on_level_start_loading = function(callback)
	table.insert(registered_on_level_start_loadings, callback)
end

-- Allow to register a callback function that is called
-- when a new level has started and is ready to play.
-- Function syntax: callback(level_pack_id, level_number)
-- * level_pack_id: ID of the level pack
-- * level_number: Level number within that level pack
lzr_levels.register_on_level_start = function(callback)
	table.insert(registered_on_level_starts, callback)
end

local legacy_levels

local get_max_treasures = function()
	if current_level and current_level_data then
		return current_level_data[current_level].treasures
	end
end

-- Count number of all gold blocks the player has found in
-- the a level pack. Only completed levels add to the
-- total.
function lzr_levels.count_total_collected_treasures(level_data)
	local total = 0
	local completed = lzr_levels.get_completed_levels(level_data)

	local level_nums = {}
	for level_num=1, #level_data do
		local levelname = level_data[level_num].filename
		levelname = string.sub(levelname, 1, -5) -- remove .mts suffix
		level_nums[levelname] = level_num
	end
	for levelname, _ in pairs(completed) do
		local level_num = level_nums[levelname]
		local level = level_data[level_num]
		if level then
			total = total + level.treasures
		end
	end
	return total
end

-- Count number of all available gold blocks in a level pack.
function lzr_levels.count_total_treasures(level_data)
	local total = 0
	for level_num=1, #level_data do
		local level = level_data[level_num]
		if level then
			total = total + level.treasures
		end
	end
	return total
end

-- Returns true if the player has proven they
-- have finished the "intro". The goal is to make
-- sure the player can use the basic controls correctly
-- before starting with the "real" levels.
function lzr_levels.is_intro_completed()
	-- We check if the player has collected any
	-- gold block in the core levels. This is
	-- only possible if they understood the controls.
	local core_level_data = lzr_levels.get_level_pack("__core")
	if not core_level_data then
		return nil
	end
	local treasures = lzr_levels.count_total_collected_treasures(core_level_data)
	if treasures >= 1 then
		return true
	else
		return false
	end
end

-- Mod storage for game progress
local mod_storage = minetest.get_mod_storage()

local analyze_level_schematic = function(filename, levels_path, level_data_entry, check_unknown_nodes)
	if not lzr_util.file_exists(levels_path, filename) then
		return false, "file_nonexistant"
	end
	local filepath = levels_path .. "/" ..filename
	local schem = minetest.read_schematic(filepath, {write_yslice_prob="none"})
	if not schem then
		return false, "schematic_load_error"
	end
	level_data_entry.contains_rotatable_block = false
	level_data_entry.treasures = 0
	level_data_entry.size = schem.size
	local size = level_data_entry.size
	local teleporters = 0
	local parrot_spawners = 0
	local hidden_parrot_spawners = 0
	local bad_hidden_parrot_spawners = 0
	local barriers = 0
	local gold_blocks = 0
	local ignited_bombs = 0
	local burning_barricades = 0
	local plants_on_ground = 0
	local treasures_to_collect = 0
	local pre_collected = 0
	local treasures_to_collect = 0
	local unknown_nodes = 0
	local varea = VoxelArea(vector.new(0,0,0), vector.offset(size, -1, -1, -1))
	-- Find rotatable blocks, treasures, parrot spawners and find the start position
	for d=1, #schem.data do
		local nodename = schem.data[d].name
		local is_rotatable = minetest.get_item_group(nodename, "rotatable") == 1 or minetest.get_item_group(nodename, "takable") == 1
		local treasure = minetest.get_item_group(nodename, "chest_closed") > 0 or minetest.get_item_group(nodename, "chest_open_treasure") > 0
		if is_rotatable then
			level_data_entry.contains_rotatable_block = true
		end
		if treasure then
			level_data_entry.treasures = level_data_entry.treasures + 1
		end
		local g_chest = minetest.get_item_group(nodename, "chest")
		if nodename == "lzr_teleporter:teleporter_off" then
			-- Player spawn pos on start block
			teleporters = teleporters + 1
			local start = lzr_util.schematic_index_to_pos(d, size)
			start = vector.add(start, vector.new(0, 0.5, 0))
			level_data_entry.start_pos = start
			local p2 = schem.data[d].param2 % 4
			if p2 == 0 then
				level_data_entry.start_yaw = math.pi
			elseif p2 == 1 then
				level_data_entry.start_yaw = math.pi/2
			elseif p2 == 2 then
				level_data_entry.start_yaw = 0
			else
				level_data_entry.start_yaw = 3*(math.pi/2)
			end
		elseif nodename == "lzr_parrot_npc:parrot_spawner" then
			-- Parrot spawn pos
			parrot_spawners = parrot_spawners + 1
			local ppos = lzr_util.schematic_index_to_pos(d, size)
			level_data_entry.parrot_pos = ppos
		elseif nodename == "lzr_parrot_npc:hidden_parrot_spawner" then
			-- Hidden parrot spawn pos
			hidden_parrot_spawners = hidden_parrot_spawners + 1
			local ppos = lzr_util.schematic_index_to_pos(d, size)
			level_data_entry.hidden_parrot_pos = ppos
			level_data_entry.hidden_parrot_param2 = schem.data[d].param2
		elseif nodename == "lzr_treasure:gold_block" then
			gold_blocks = gold_blocks + 1
		elseif minetest.get_item_group(nodename, "barrier") ~= 0 then
			barriers = barriers + 1
		elseif minetest.get_item_group(nodename, "plant_on_ground") ~= 0 then
			-- Rooted plant below non-water node

			-- Get schematic data index above the node we look at
			local idx_above = d + varea.ystride
			if schem.data[idx_above] then
				local nodename_above = schem.data[idx_above].name
				if minetest.get_item_group(nodename_above, "water") == 0 then
					plants_on_ground = plants_on_ground + 1
				end
			else
				-- Rooted plant at top of the level is also not allowed
				plants_on_ground = plants_on_ground + 1
			end
		elseif minetest.get_item_group(nodename, "bomb") == 2 then
			ignited_bombs = ignited_bombs + 1
		elseif minetest.get_item_group(nodename, "barricade") == 2 then
			burning_barricades = burning_barricades + 1
		elseif g_chest == 1 or g_chest == 2 then
			-- Closed chest with gold block
			treasures_to_collect = treasures_to_collect + 1
		elseif g_chest == 4 then
			-- Open chest with gold block
			pre_collected = pre_collected + 1
		elseif check_unknown_nodes and not minetest.registered_nodes[nodename] then
			unknown_nodes = unknown_nodes + 1
		end
	end

	-- Check level flags
	local show_controls_flags = 0
	local flags_table = string.split(level_data_entry.flags, "|")
	for f=1, #flags_table do
		local flagname = flags_table[f]
		-- Only one show_controls_* flag allowed
		if string.sub(flagname, 1, 14) == "show_controls_" then
			show_controls_flags = show_controls_flags + 1
			if show_controls_flags > 1 then
				break
			end
		end
	end

	-- return false when level problems were detected
	if teleporters == 0 then
		return false, "no_teleporter"
	elseif teleporters > 1 then
		return false, "too_many_teleporters"
	elseif parrot_spawners > 1 then
		return false, "too_many_parrot_spawners"
	elseif hidden_parrot_spawners > 1 then
		return false, "too_many_hidden_parrot_spawners"
	elseif bad_hidden_parrot_spawners > 0 then
		return false, "bad_hidden_parrot_spawner"
	elseif barriers > 0 then
		return false, "barriers"
	elseif burning_barricades > 0 then
		return false, "burning_barricade"
	elseif ignited_bombs > 0 then
		return false, "ignited_bomb"
	elseif gold_blocks > 0 then
		return false, "gold_block"
	elseif plants_on_ground > 0 then
		return false, "plant_on_ground"
	elseif show_controls_flags > 1 then
		return false, "too_many_show_controls_flags"
	elseif pre_collected > 0 then
		return false, "pre_collected"
	elseif treasures_to_collect == 0 then
		return false, "no_treasures"
	elseif unknown_nodes >= 1 then
		return false, "unknown_node"
	end
	return true
end

-- Create a level_data table for a single level
-- with default settings. Used for levels where
-- no metadata is available.
lzr_levels.create_fallback_level_data = function(level, levels_path)
	local local_level_data = {}
	if not level then
		return nil
	end
	local filename = level .. ".mts"
	local_level_data.levels_path = levels_path
	local local_level = {
		filename = filename,
		name = "",
		node_wall = lzr_globals.DEFAULT_WALL_NODE,
		node_floor = lzr_globals.DEFAULT_FLOOR_NODE,
		node_ceiling = lzr_globals.DEFAULT_CEILING_NODE,
		ambience = lzr_ambience.DEFAULT_AMBIENCE,
		sky = lzr_globals.DEFAULT_SKY,
		npc_texts = lzr_globals.DEFAULT_NPC_TEXTS,
		weather = lzr_globals.DEFAULT_WEATHER,
		backdrop = lzr_globals.DEFAULT_BACKDROP,
		backdrop_pos = lzr_globals.DEFAULT_BACKDROP_POS,
		triggers = "",
		flags = "",
	}
	local ok = analyze_level_schematic(filename, levels_path, local_level)
	if not ok then
		return nil
	end
	local_level_data[1] = local_level
	return local_level_data
end

--[[ Read the level schematics to find out some metadata about them.
Returns a level_data table.

A CSV file (given by `level_list_path` is used for metadata.
Syntax of a single record in the CSV file:

   <File name>,<Title>,<Border nodes>,<Ambience>,<Sky>,<NPC texts>,<Weather>,<Backdrop>,<Node meta>,<Flags>

   Border nodes is a list of nodenames for the level border, separated by the pipe symbol (“|”), in this order:
     wall, ignored, floor, ceiling
     wall is mandatory, the rest is optional (will default to the wall node)
     ignored can be any string and will just be ignored (this was the window in old versions)
   Ambience is an ambience ID for the background noise (see lzr_ambience).
   Sky and weather are IDs for sky and weather (see lzr_sky, lzr_weather).
   NPC texts is a *single* text used by NPC like the information block.
   (multiple NPC texts are not supported yet).
   Backdrop is the environment around the playable field. One of "ocean", "islands", "underground", "sky".
   Node meta is a serialized string containing node metadata
   Flags is a list of flags to turn on some special properties that don't fit anywhere else. Can be empty. Flags are separated with "|". Available flags:
     show_controls_<TYPE>: Shows default controls on the screen. <TYPE> is one of:
       basic: basic controls: look, move, interact, punch, menu
       punch: punch controls
       select_item: item selection controls
     mark_parrot_talk: Show a message/icon above parrot NPC (non-hidden) to indicate that you should talk to it
     parrot_hint: Make Goldie the Parrot ask player if they want a hint before giving the parrot speech
     hide_items: Hide the inventory. Meant for early tutorial. Only use this if player cannot rotate or pickup anything in the level

All entries up to ambience are mandatory, the remaining ones are optional and can be omitted.

Parameters:
* level_list_path: Path to CSV file containing the level list
* levels_path: Path in which the levels are stored (.mts files)
* solutions_path: (optional) Path in which the level solutions are stored (.sol.csv files)
* level_order_path: (optional) Path to CSV file containing explicity level order and difficulty ratings
* check_unknown_nodes: (optional) If true, fail if the level contains an unknown node (default: false)

Returns `nil, "load_error" if CSV file for level list could not be read
Returns `nil, "csv_error", <csv_error_message>` if CSV file for the level list could not be parsed correctly.
    `<csv_error_message>` contains an error message that describes the parse error.
Returns `nil, "csv_error_order", <csv_error_message>` if CSV file for level order could not be parsed correctly.
Returns `nil, "bad_schematic", <schematic_file_name>, <problem ID> if level schematic contains error (e.g. missing teleporter).
]]
lzr_levels.analyze_levels = function(level_list_path, levels_path, solutions_path, level_order_path, check_unknown_nodes)
	local level_list_file = io.open(level_list_path, "rb")
	if not level_list_file then
		return nil, "load_error"
	end

	local level_list_string = level_list_file:read("*a")
	local level_list, csv_error = rcsv.parse_csv(level_list_string)
	level_list_file:close()
	if not level_list then
		return nil, "csv_error", csv_error
	end

	local level_order_file
	if level_order_path then
		level_order_file = io.open(level_order_path, "rb")
	end
	local level_order
	if level_order_file then
		local level_order_string = level_order_file:read("*a")
		level_order, csv_error = rcsv.parse_csv(level_order_string)
		level_order_file:close()
		if not level_order then
			return nil, "csv_error_order", csv_error
		end
	end

	local local_level_data = {}
	local_level_data.levels_path = levels_path
	local_level_data.solutions_path = solutions_path
	local ordered_levels_all = {}
	if level_order then

		-- Check if level_order file has listed all the levels
		for ll=1, #level_list do
			local found = false
			for lo=1, #level_order do
				if level_order[lo][1] == "0" and level_order[lo][2] == level_list[ll][1] then
					found = true
					break
				end
			end
			if not found then
				return nil, "csv_error_order", "Level order CSV file doesn't contain level: "..tostring(level_list[ll][1])
			end
		end

		local level_number = 1
		local_level_data.difficulties = {}
		local current_difficulty = -1
		for lo=1, #level_order do
			local level_order_row = level_order[lo]
			local row_type = level_order_row[1]
			local arg = level_order_row[2]
			if not row_type or not arg then
				return nil, "csv_error_order", "Missing column at record #"..lo.." of level order CSV file: "..tostring(row_type)
			end
			-- append level
			if row_type == "0" then
				if not local_level_data.difficulties[current_difficulty] then
					local_level_data.difficulties[current_difficulty] = {}
				end
				local filename = tostring(arg)
				local level_list_number
				for ll=1, #level_list do
					local global_filename = level_list[ll][1]
					if filename == global_filename then
						level_list_number = ll
						break
					end
				end
				if not level_list_number then
					return nil, "csv_error_order", "Level order CSV file contains level "..filename.." that is missing in level list (CSV record="..lo..")"
				end
				table.insert(local_level_data.difficulties[current_difficulty], level_number)
				level_number = level_number + 1
				table.insert(ordered_levels_all, level_list_number)
			-- set difficulty
			elseif row_type == "1" then
				current_difficulty = tonumber(arg) or -1
			else
				return nil, "csv_error_order", "Invalid row type at CSV record #"..lo.." of level order CSV file: "..tostring(row_type)
			end
		end
	else
		for ll=1, #level_list do
			table.insert(ordered_levels_all, ll)
		end
	end

	local tr_level_names, tr_npc_texts = {}, {}

	-- Replace CR LF newlines with Lua newlines.
	-- Needed for NPC text field because a multi-line text value
	-- in the CSV file uses CR LF newlines.
	local replace_newlines = function(str)
		str = string.gsub(str, "\13\10", "\n")
		return str
	end

	for ll=1, #ordered_levels_all do
		local level_list_number = ordered_levels_all[ll]
		local level_list_row = level_list[level_list_number]
		local filename = level_list_row[1]
		local lname = level_list_row[2]
		local nodes = level_list_row[3]
		local ambience = level_list_row[4]
		local sky = level_list_row[5] or lzr_globals.DEFAULT_SKY
		local npc_texts_raw = level_list_row[6]
		local npc_texts
		if npc_texts_raw then
			npc_texts = { goldie = replace_newlines(npc_texts_raw) }
		else
			npc_texts = lzr_globals.DEFAULT_NPC_TEXTS
		end
		local weather = level_list_row[7] or lzr_globals.DEFAULT_WEATHER
		local backdrop = level_list_row[8] or lzr_globals.DEFAULT_BACKDROP
		local backdrop_pos
		local parsed_backdrop_pos = level_list_row[9]
		if parsed_backdrop_pos then
			backdrop_pos = minetest.string_to_pos(parsed_backdrop_pos)
		end
		if not backdrop_pos then
			backdrop_pos = lzr_globals.DEFAULT_BACKDROP_POS
		end
		local triggers = level_list_row[10] or ""
		local flags = level_list_row[11] or ""

		local node_matches = string.split(nodes, "|", true)
		local node_wall = node_matches[1]
		local node_legacy_window = node_matches[2]
		local node_floor = node_matches[3] or node_wall
		local node_ceiling = node_matches[4] or node_wall
		-- If the level uses a legacy window, force the ceiling to
		-- be a barrier so that light can enter the level
		if node_legacy_window == "lzr_decor:woodframed_glass" then
			node_ceiling = "lzr_core:barrier"
		end

		local filename_solution
		if solutions_path then
			filename_solution = string.sub(filename, 1, -5) .. ".sol.csv"
		end

		table.insert(local_level_data, {filename=filename, filename_solution=filename_solution, name=lname, node_wall=node_wall, node_floor=node_floor, node_ceiling=node_ceiling, ambience=ambience, sky=sky, npc_texts=npc_texts, weather=weather, backdrop=backdrop, backdrop_pos=backdrop_pos, triggers=triggers, flags=flags})
	end

	-- Scan the level schematics for intersting blocks, treasures, etc.
	-- and write it into local_level_data
	for l=1, #local_level_data do
		local filename = local_level_data[l].filename
		local ok, problem = analyze_level_schematic(filename, levels_path, local_level_data[l], check_unknown_nodes)
		if not ok then
			return nil, "bad_schematic", filename, problem
		end
	end

	return local_level_data
end

-- Returns a list of all levels in which hidden parrots can be found for the given level pack.
-- Each list entry contains a table of this form:
-- {
--   level: level ID,
--   param2: param2 value of hidden parrot spawner,
-- }
-- The table is returned in the order of levels
lzr_levels.get_hidden_parrot_levels = function(level_data)
	local parrot_levels = {}
	for l=1, #level_data do
		if level_data[l].hidden_parrot_param2 then
			table.insert(parrot_levels, { level = l, param2 = level_data[l].hidden_parrot_param2 })
		end
	end
	return parrot_levels
end

-- Remove plant nodes (including trees + leaves) in a cuboid from pos to pos+size.
local remove_plants = function(pos, size)
	local max_pos = vector.add(pos, size)
	local nodes = minetest.find_nodes_in_area(pos, max_pos, { "group:tree", "group:leaves", "group:plant" })
	minetest.bulk_set_node(nodes, {name="air", param2=0})
end

-- Set the boundary nodes of the room
local set_room_nodes = function(pos, size, nodes)
	-- This will set nodes at the room floor, ceiling and wall.
	-- Air, water and plant nodes will be replaced by the boundary node.
	-- All other nodes will be left alone.
	-- Exception: Water will not be replaced if the boundary node is air or barrier.
	local psize = size
	local posses_border = {}
	local posses_floor = {}
	local posses_ceiling = {}
	local size = vector.add(psize, {x=1,y=1,z=1})
	lzr_world.set_level_size(psize)
	for x=0,size.x do
	for z=0,size.z do
	for y=0,size.y do
		local offset = {x=x-1, y=y-1, z=z-1}
		if not ((x >= 1 and x < size.x) and (y >= 1 and y < size.y) and (z >= 1 and z < size.z)) then
			local lpos = vector.add(pos, offset)
			local node = minetest.get_node(lpos)
			local is_water = minetest.get_item_group(node.name, "water") ~= 0
			if node.name == "air" or is_water or minetest.get_item_group(node.name, "plant") ~= 0 then
				local node_boundary, posses
				local at_wall = (x == 0 or x == size.x) or (z == 0 or z == size.z)
				if y == 0 and (not at_wall) then
					node_boundary = nodes.node_floor
					posses = posses_floor
				elseif y == size.y and (not at_wall) then
					node_boundary = nodes.node_ceiling
					posses = posses_ceiling
				else
					node_boundary = nodes.node_wall
					posses = posses_border
				end
				if (not is_water) or (is_water and node_boundary ~= "air" and node_boundary ~= "lzr_core:barrier" and node_boundary ~= "lzr_core:rain_membrane") then
					table.insert(posses, lpos)
				end
			end
		end
	end
	end
	end
	minetest.bulk_set_node(posses_floor, {name=nodes.node_floor})
	minetest.bulk_set_node(posses_border, {name=nodes.node_wall})
	minetest.bulk_set_node(posses_ceiling, {name=nodes.node_ceiling})
end

local get_singleplayer = function()
	return minetest.get_player_by_name("singleplayer")
end

local emerge_callback = function(blockpos, action, calls_remaining, param)
	local emerge_log = param.emerge_log
	minetest.log("verbose", "[lzr_levels] emerge_callback() called ...")
	if action == minetest.EMERGE_ERRORED then
		minetest.log("error", "[lzr_levels] Error while emerging room block (blockpos="..minetest.pos_to_string(blockpos)..", calls_remaining="..tostring(calls_remaining)..").")
		emerge_log.errors = emerge_log.errros + 1
	elseif action == minetest.EMERGE_CANCELLED then
		minetest.log("warning", "[lzr_levels] Room block emerging was cancelled (blockpos="..minetest.pos_to_string(blockpos)..", calls_remaining="..tostring(calls_remaining)..").")
		emerge_log.cancels = emerge_log.cancels + 1
	elseif (action == minetest.EMERGE_GENERATED or action == minetest.EMERGE_FROM_DISK or action == minetest.EMERGE_FROM_MEMORY) then
		emerge_log.successes = emerge_log.successes + 1
	end
	if (calls_remaining == 0) then
		minetest.log("action", "[lzr_levels] Level area emerging complete (final blockpos="..minetest.pos_to_string(blockpos)..")")
		minetest.log("action", "[lzr_levels] Emerge stats: successes="..emerge_log.successes..", cancels="..emerge_log.cancels..", errors="..emerge_log.errors)
		if emerge_log.errors > 0 then
			minetest.log("error", "[lzr_levels] Level area emerge was finished with errors! The level will likely not work")
			return
		end
		if emerge_log.cancels > 0 then
			minetest.log("warning", "[lzr_levels] Building level into area in which "..emerge_log.cancels.." block(s) were cancelled ...")
		end
		local maxpos = vector.add(param.pos, param.size)
		if param.mode == "resize" then
			local inner = {
				x = math.min(param.old_size.x, param.size.x),
				y = math.min(param.old_size.y, param.size.y),
				z = math.min(param.old_size.z, param.size.z),
			}
			local outer = {
				x = math.max(param.old_size.x, param.size.x),
				y = math.max(param.old_size.y, param.size.y),
				z = math.max(param.old_size.z, param.size.z),
			}
			lzr_laser.clear_out_of_bounds_lasers()
			lzr_levels.reset_level_area(false, param.pos, outer, inner)
			lzr_levels.reset_level_area(true, param.pos, outer, inner)
			lzr_levels.reset_level_area(false, param.pos, param.old_size, param.size)
			set_room_nodes(param.pos, param.size, param.nodes)
			lzr_laser.full_laser_update(param.pos, maxpos)
			minetest.log("action", "[lzr_levels] Room emerge resize callback done")
			if param.callback_done then
				param.callback_done()
			end
			return
		else
			local parsed_flags = {}
			if param.flags then
				parsed_flags = lzr_levels.parse_flags(param.flags)
			end

			-- Reset old level area
			if param.old_pos then
				lzr_levels.reset_level_area(false, param.old_pos, param.old_size)
			end
			-- Reset new level area
			lzr_laser.clear_out_of_bounds_lasers()
			if param.clear then
				if param.clear_border then
					lzr_levels.reset_level_area(true, param.pos, param.size)
				else
					lzr_levels.reset_area(true, param.pos, vector.subtract(param.size, vector.new(1,1,1)))
				end
			else
				lzr_levels.reset_level_area(false, param.pos, param.size)
			end

			-- Remove plants above the ceiling
			if parsed_flags["remove_plants_above"] then
				local fpos = table.copy(param.pos)
				-- add 1 because we skip over the ceiling
				fpos.y = param.pos.y + param.size.y + 1
				local fsize = table.copy(param.size)
				fsize.y = 24
				remove_plants(fpos, fsize)
			end

			set_room_nodes(param.pos, param.size, param.nodes)
			local level_ok = false

			if param.level then
				level_ok = lzr_levels.build_level(param.level, param.level_data)
			elseif param.schematic then
				local maxpos = vector.add(param.pos, param.size)
				level_ok = lzr_levels.build_level_raw(param.schematic, param.pos, maxpos)
				if level_ok and param.triggers then
					lzr_levels.init_triggers()
					local triggers_ok = lzr_levels.deserialize_triggers(param.triggers)
					if not triggers_ok then
						return
					end
				end
				lzr_laser.full_laser_update(param.pos, maxpos)
			else
				local player = get_singleplayer()
				if player then
					if param.spawn_pos then
						if param.spawn_pos == "start" then
							lzr_silent_set_pos.silent_set_pos(player, param.pos)
						else
							lzr_silent_set_pos.silent_set_pos(player, param.spawn_pos)
						end
					end
					if param.yaw then
						player:set_look_horizontal(param.yaw)
						player:set_look_vertical(0)
					end
				end
				minetest.log("action", "[lzr_levels] Empty room emerge callback done")
				if param.callback_done then
					param.callback_done()
				end
				return
			end


			if not level_ok then
				minetest.log("error", "[lzr_levels] Room emerge callback done with error")
				if param.callback_error then
					param.callback_error()
				end
			else
				local player = get_singleplayer()
				if player then
					if param.spawn_pos then
						local spawn_pos
						if param.spawn_pos == "start" then
							-- special "start" spawn pos puts player on teleporter
							-- (meant for editor use)
							local teleporters = minetest.find_nodes_in_area(param.pos, maxpos, "lzr_teleporter:teleporter_off")
							local spos
							if #teleporters > 0 then
								spos = teleporters[1]
							else
								-- Alternatively, put player in the lowest air block
								-- in the Y column of the level position
								spos = table.copy(param.pos)
								repeat
									local snode = minetest.get_node(spos)
									if snode.name == "air" then
										if spos.y > param.pos.y then
											spos.y = spos.y - 1
										end
										break
									else
										spos.y = spos.y + 1
									end
								until spos.y >= maxpos.y - 3
							end
							lzr_silent_set_pos.silent_set_pos(player, vector.offset(spos, 0, 0.5, 0))
						else
							-- normal spawn pos provides spawn position directly
							lzr_silent_set_pos.silent_set_pos(player, param.spawn_pos)
						end
					end
					if param.yaw then
						player:set_look_horizontal(param.yaw)
						player:set_look_vertical(0)
					end
					local gs = lzr_gamestate.get_state()
					if gs == lzr_gamestate.LEVEL or gs == lzr_gamestate.LEVEL_TEST then
						local found = lzr_laser.count_found_treasures(param.pos, maxpos)
						lzr_gui.update_treasure_status(player, found, get_max_treasures())

						-- Spawn parrot
						if param.parrot_pos then
							local parrot_node_pos = vector.add(param.parrot_pos, param.pos)
							local parrot_node = minetest.get_node(parrot_node_pos)
							minetest.set_node(parrot_node_pos, {name="air"})
							local parrot_entity_pos = vector.add(parrot_node_pos, lzr_globals.PARROT_SPAWN_OFFSET)
							local obj = minetest.add_entity(parrot_entity_pos, "lzr_parrot_npc:parrot")
							if obj then
								local ent = obj:get_luaentity()
								if ent then
									-- set initial parrot yaw
									local degrotate = parrot_node.param2
									degrotate = (degrotate + 120) % 240
									local yaw = (degrotate / 240) * (math.pi*2)
									obj:set_yaw(yaw)
									-- Mark the parrot. This should cause a GUI element to
									-- appear when the player is close to indicate the player
									-- can talk to it.
									if beginner_hud and parsed_flags["mark_parrot_talk"] then
										ent._talk_mark = true
									end
								end
							end
						end
						-- Spawn hidden parrot, if not already found
						if param.hidden_parrot_pos then
							local hidden_parrot_node_pos = vector.add(param.hidden_parrot_pos, param.pos)
							local pnode = minetest.get_node(hidden_parrot_node_pos)
							local p2 = pnode.param2
							minetest.set_node(hidden_parrot_node_pos, {name="air"})
							local hidden_parrot_num = lzr_parrot_npc.get_hidden_parrot_num_by_param2(pnode.param2)
							local hidden_parrot_name = lzr_parrot_npc.get_hidden_parrot_name(hidden_parrot_num)
							if not hidden_parrot_name then
								minetest.log("error", "[lzr_levels] Could not get parrot name for hidden parrot with param2="..p2.."!")
							elseif not lzr_parrot_npc.was_hidden_parrot_found(hidden_parrot_name) then
								local hidden_parrot_entity_pos = vector.add(hidden_parrot_node_pos, lzr_globals.PARROT_SPAWN_OFFSET)
								local obj = minetest.add_entity(hidden_parrot_entity_pos, "lzr_parrot_npc:hidden_parrot")
								local parrot_ok = false
								if obj then
									local ent = obj:get_luaentity()
									if ent then
										local dir = minetest.fourdir_to_dir(p2 % 4)
										local yaw = minetest.dir_to_yaw(dir)
										obj:set_yaw(yaw + math.pi)
										if minetest.global_exists("lzr_parrot_npc") then
											ent:_init(hidden_parrot_name)
											minetest.log("action", "[lzr_levels] Spawned and initialized hidden parrot '"..hidden_parrot_name.."'")
											parrot_ok = true
										else
											minetest.log("error", "[lzr_levels] Tried to initialize hidden parrot but lzr_parrot_npc was not loaded yet!")
										end
									end
								end
								if not parrot_ok then
									minetest.log("error", "[lzr_levels] Failed to spawn hidden parrot '"..hidden_parrot_name.."' into level")
								end
							else
								minetest.log("info", "[lzr_levels] Didn't spawn hidden parrot '"..hidden_parrot_name.."' into level; was already found")
							end
						end
					end
					if param.level then
						local lname = lzr_levels.get_level_name(param.level, param.level_data, true)
						if gs ~= lzr_gamestate.LEVEL_TEST then
							-- Show level title
							if lname ~= "" then
								lzr_messages.show_message(player, lname, LEVEL_CAPTION_TIME)
							end
							-- Briefly eeduce ambience gain for the level enter soudn
							lzr_ambience.reduce_ambience(SOUND_TIME_LEVEL_ENTER)
						end

						-- Show game controls, if level flag has it enabled
						if beginner_hud and lzr_gamestate.get_state() ~= lzr_gamestate.LEVEL_TEST then
							if parsed_flags["show_controls_basic"] then
								lzr_gui.set_controls_gui(player, "basic")
							elseif parsed_flags["show_controls_punch"] then
								lzr_gui.set_controls_gui(player, "punch")
							elseif parsed_flags["show_controls_select_item"] then
								lzr_gui.set_controls_gui(player, "select_item")
							end
						else
							lzr_gui.set_controls_gui(player, "none")
						end

						lzr_player.set_play_inventory(player, lname, gs == lzr_gamestate.LEVEL_TEST, parsed_flags["hide_items"])

						if gs ~= lzr_gamestate.LEVEL_TEST then
							-- Play level enter sound
							minetest.sound_play({name = "lzr_levels_level_enter", gain = 0.4}, {to_player=player:get_player_name()}, true)
						end

						-- Report level start to callback functions
						for i=1, #registered_on_level_starts do
							local callback = registered_on_level_starts[i]
							callback(param.level_data.name, param.level)
						end
					end
				end
				if param.callback_done then
					param.callback_done()
				end
				minetest.log("action", "[lzr_levels] Room emerge callback done")
			end
		end
	end
end

local prepare_room = function(room_data, after_emerge)
	local real_pos, real_size
	-- When resizing, make sure we emerge the maximum size of either
	-- old size or new size because we later might want to operate inside
	-- the old size area as well.
	if room_data.mode == "resize" and room_data.old_size then
		real_size = {
			x = math.max(room_data.old_size.x, room_data.size.x),
			y = math.max(room_data.old_size.y, room_data.size.y),
			z = math.max(room_data.old_size.z, room_data.size.z),
		}
	else
		real_size = room_data.size
	end
	-- We have to emerge a few nodes extra for the level boundary nodes
	-- which the area reset functions depend on
	real_size = vector.offset(real_size, 2, 2, 2)
	real_pos = vector.offset(room_data.pos, -1, -1, -1)
	local real_max_pos = vector.add(real_pos, real_size)

	local my_emerge_callback
	if not after_emerge then
		my_emerge_callback = emerge_callback
	else
		my_emerge_callback = function(blockpos, action, calls_remaining, param)
			emerge_callback(blockpos, action, calls_remaining, param)
			if calls_remaining == 0 then
				if (param.emerge_log.errors == 0) then
					after_emerge(true)
				else
					after_emerge(false)
				end
			end
		end
	end

	local emerge_param = table.copy(room_data)
	emerge_param.emerge_log = {
		successes = 0,
		cancels = 0,
		errors = 0,
	}
	minetest.log("info", "[lzr_mapgen] Starting to emerge from "..minetest.pos_to_string(real_pos).." to "..minetest.pos_to_string(real_max_pos).." ...")
	minetest.emerge_area(room_data.pos, vector.add(room_data.pos, room_data.size), my_emerge_callback, emerge_param)
end

--[[ Resets blocks within an area by
either putting it back to the original mapgen state
or deleting it all.
Also clears all objects.

* clear: If true, will set all nodes in area to
  air instead of regenerating the map
* start_pos: Minimum position of area to clear
* size: Size of area to clean
* start_pos_protected: If set, this is the minimum
  position of the protected area, i.e. an area in
  which nodes will NOT be reset. If nil, this has no effect
* size_protected: Size of protected area. Use in
  combination with `start_pos_protected`.
]]
function lzr_levels.reset_area(clear, start_pos, size, start_pos_protected, size_protected)
	local aprot_min, aprot_max
	if start_pos_protected then
		aprot_min = start_pos_protected
		aprot_max = vector.add(start_pos_protected, size_protected)
	end
	if clear then
		lzr_levels.clear_area(start_pos, vector.add(start_pos, size),
			aprot_min, aprot_max)
	else
		lzr_mapgen.generate_piece(start_pos, vector.add(start_pos, size),
			nil, aprot_min, aprot_max)
	end

	-- Also clear objects
	local objects = minetest.get_objects_in_area(
		start_pos,
		vector.add(start_pos, size))
	for o=1, #objects do
		local obj = objects[o]
		if not obj:is_player() then
			obj:remove()
		end
	end
	local msg = "Reset area at "..minetest.pos_to_string(start_pos)..", size "..minetest.pos_to_string(size)
	if start_pos_protected then
		msg = msg .." (start_pos_protected="..minetest.pos_to_string(start_pos_protected)..", size_protected="..minetest.pos_to_string(size_protected)..")"
	end
	minetest.log("action", "[lzr_levels] "..msg)
end

--[[ Resets the specified level area including the border
around it by putting it back to the original mapgen state,
or by deleting all blocks.
Also clears all objects.

* clear: If true, will set all nodes in area to
  air instead of regenerating the map
* level_pos: Minimum position of level contents (NOT the level border!)
* level_size: Size of level contents
* protected_size: If specified, no nodes from level_pos to
  level_pos + size_protected will be touched.
]]

function lzr_levels.reset_level_area(clear, level_pos, level_size, protected_size)
	local real_start_pos = vector.subtract(level_pos, vector.new(1,1,1))
	local real_size = vector.add(level_size, vector.new(1,1,1))
	if protected_size then
		protected_size = vector.subtract(protected_size, vector.new(1,1,1))
		lzr_levels.reset_area(clear, real_start_pos, real_size, level_pos, protected_size)
	else
		lzr_levels.reset_area(clear, real_start_pos, real_size)
	end
end

-- Deletes all nodes with in an area from the position start_pos
-- to start_pos+size.
function lzr_levels.clear_area(start_pos, end_pos, protected_min, protected_max)
	local vmanip = minetest.get_voxel_manip(start_pos, end_pos)
	local emin, emax = vmanip:get_emerged_area()
	local varea = VoxelArea:new({MinEdge = emin, MaxEdge = emax})
	local vdata = vmanip:get_data()
	local vp2data = vmanip:get_param2_data()

	local posses = {}

	for z=start_pos.z, end_pos.z do
	for y=start_pos.y, end_pos.y do
	for x=start_pos.x, end_pos.x do
		local pos = vector.new(x,y,z)
		if not protected_min or not vector.in_area(pos, protected_min, protected_max) then
			local idx = varea:index(x,y,z)
			vdata[idx] = minetest.CONTENT_AIR
			vp2data[idx] = 0
			table.insert(posses, vector.new(x,y,z))
		end
	end
	end
	end
	vmanip:set_data(vdata)
	vmanip:set_param2_data(vp2data)
	vmanip:write_to_map()

	for p=1, #posses do
		local meta = minetest.get_meta(posses[p])
		meta:from_table({fields={}, inventory={}})
	end

	minetest.log("action", "[lzr_levels] Cleared area from "..minetest.pos_to_string(start_pos).." to "..minetest.pos_to_string(end_pos))
end

-- room_data:
-- 	- pos: Room pos
-- 	- size: Room size vector
-- 	- spawn_pos: Relative player spawn position (optional)
-- 	- yaw: Initial player yaw (optional)
-- 	Either one of these (or none of them for empty room):
--	 	- level: level ID (for builtin level)
-- 		- schematic: Path to schematic
-- 	- nodes (optional): Table containing node names of level border nodes:
-- 		- node_floor, node_ceiling, node_wall
function lzr_levels.build_room(room_data)
	lzr_laser.clear_out_of_bounds_lasers()
	if not room_data.nodes then
		room_data.nodes = {
			node_floor = lzr_globals.DEFAULT_FLOOR_NODE,
			node_wall = lzr_globals.DEFAULT_WALL_NODE,
			node_ceiling = lzr_globals.DEFAULT_CEILING_NODE,
		}
	end
	prepare_room(room_data)
end

-- Resize room while preserving the inner contents of the old room
-- as much as the new size permits. This function is asynchronous,
-- the change will not happen instantly.
-- * old_size: Old room size (vector)
-- * new_size: New room size (vector)
-- * nodes: Table containing node names of level border nodes:
-- 	* node_floor, node_ceiling, node_wall
-- * after_emerge(success):
--	* called after the room was built or the action was aborted. success is true on succes or false on failure or error
function lzr_levels.resize_room(old_size, new_size, nodes, after_emerge)
	prepare_room({mode="resize", pos=lzr_world.get_level_pos(), old_size=old_size, size=new_size, nodes=nodes}, after_emerge)
end

function lzr_levels.prepare_and_build_level(level, level_data, spawn_pos, yaw, old_pos, old_size, callback_done, callback_error)
	local bounding_nodes = {
		node_floor = level_data[level].node_floor,
		node_wall = level_data[level].node_wall,
		node_ceiling = level_data[level].node_ceiling,
	}
	local lpos
	if level_data[level].backdrop == "ocean" then
		lpos = lzr_globals.BACKDROP_POS_OCEAN
	elseif level_data[level].backdrop == "islands" then
		lpos = level_data[level].backdrop_pos
	elseif level_data[level].backdrop == "underground" then
		lpos = lzr_globals.BACKDROP_POS_UNDERGROUND
	elseif level_data[level].backdrop == "sky" then
		lpos = lzr_globals.BACKDROP_POS_SKY
	end

	lzr_levels.build_room({
		mode="build",
		pos=lpos,
		size=level_data[level].size,
		old_pos=old_pos,
		old_size=old_size,
		level=level,
		level_data=level_data,
		spawn_pos=spawn_pos,
		yaw=yaw,
		parrot_pos=level_data[level].parrot_pos,
		hidden_parrot_pos=level_data[level].hidden_parrot_pos,
		nodes=bounding_nodes,
		backdrop=level_data[level].backdrop,
		backdrop_pos=level_data[level].backdrop_pos,
		triggers=level_data[level].triggers,
		flags=level_data[level].flags,
		callback_done=callback_done,
		callback_error=callback_error
	})
end

function lzr_levels.prepare_and_build_custom_level(level_pos, schematic, spawn_pos, yaw, bounding_nodes, triggers, flags, callback_done, callback_error)
	lzr_levels.build_room({
		mode="build",
		pos=level_pos,
		size=schematic.size,
		schematic=schematic,
		spawn_pos=spawn_pos,
		yaw=yaw,
		nodes=bounding_nodes,
		triggers=triggers,
		flags=flags,
		callback_done=callback_done,
		callback_error=callback_error,
	})
end

function lzr_levels.build_level_raw(schematic_specifier, lminpos, lmaxpos)
	local schem = minetest.place_schematic(lminpos, schematic_specifier, "0", {}, true, "")
	if not schem then
		minetest.log("error", "[lzr_levels] lzr_levels.build_level_raw failed to build level")
	end
	return schem
end

function lzr_levels.build_level(level, level_data)
	local filepath = level_data.levels_path .. "/" .. level_data[level].filename
	local schematic_specifier
	if level_data.is_builtin then
		-- Will provide file name to place_schematic, causing Luanti
		-- to cache it for better performance.
		schematic_specifier = filepath
	else
		-- Custom levels must be read uncached because custom levels
		-- may be edited in the level editor frequently.
		-- Once a schematic was cached by Luanti, it is "locked"
		-- in this state forever.
		-- Reading a schematic uncached is done by first getting a
		-- specifier with read_schematic and then passing it to
		-- place_schematic.
		schematic_specifier = minetest.read_schematic(filepath, {write_yslice_prob="none"})
	end
	local lminpos, lmaxpos
	if level_data[level].backdrop == "islands" then
		lminpos = level_data[level].backdrop_pos
	elseif level_data[level].backdrop == "ocean" then
		lminpos = lzr_globals.BACKDROP_POS_OCEAN
	elseif level_data[level].backdrop == "underground" then
		lminpos = lzr_globals.BACKDROP_POS_UNDERGROUND
	elseif level_data[level].backdrop == "sky" then
		lminpos = lzr_globals.BACKDROP_POS_SKY
	else
		minetest.log("error", "[lzr_levels] Unknown level backdrop type: "..tostring(level_data[level].backdrop))
		return
	end

	-- Reset out-of-bounds lasers
	lzr_laser.clear_out_of_bounds_lasers()

	lmaxpos = vector.add(lminpos, level_data[level].size)
	lzr_world.set_level_pos(table.copy(lminpos))

	local schem = lzr_levels.build_level_raw(schematic_specifier, lminpos, lmaxpos)

	lzr_levels.init_triggers()
	local triggers_ok = lzr_levels.deserialize_triggers(level_data[level].triggers)
	if not triggers_ok then
		return
	end

	lzr_laser.full_laser_update(lminpos, lmaxpos)

	local flags = level_data[level].flags
	if flags and flags ~= "" then
		minetest.log("verbose", "[lzr_levels] Level flags = "..flags)
	end

	-- Check for insta-win (if the level has no treasure, the
	-- player has found "all" treasure of this level)
	local done = lzr_laser.check_level_won()
	if done and lzr_gamestate.get_state() == lzr_gamestate.LEVEL then
		if job_insta_win then
			job_insta_win:cancel()
		end
		job_insta_win = minetest.after(3, function(param)
			if lzr_gamestate.get_state() == lzr_gamestate.LEVEL and param.level == current_level and param.level_data == current_level_data then
				lzr_levels.level_complete()
			end
			job_insta_win = nil
		end, {level=level, level_data=level_data})
	end

	return schem
end

local function clear_inventory(player)
	local inv = player:get_inventory()
	inv:set_list("main", {})
end

local function reset_inventory(player, needs_rotate)
	clear_inventory(player)
	if needs_rotate then
		local inv = player:get_inventory()
		inv:add_item("main", "lzr_hook:hook")
	end
end

local function get_start_pos(level, level_data)
	local start_pos -- player start position, relative to level
	local size = level_data[level].size
	if level_data[level].start_pos then
		start_pos = level_data[level].start_pos
	else
		-- Fallback start pos
		start_pos = vector.new(math.floor(size.x/2), -0.5, math.floor(size.z/2))
	end
	return start_pos
end

local function get_start_yaw(level, level_data, start_pos)
	local start_yaw -- player start yaw
	if level_data[level].start_yaw then
		start_yaw = level_data[level].start_yaw
	else
		-- Fallback start yaw
		start_yaw = 0
		local size = level_data[level].size
		if start_pos.z > size.z/2 then
			start_yaw = start_yaw + math.pi
		end
	end
	return start_yaw
end

function lzr_levels.get_npc_texts()
	if not current_level_data then
		return nil
	end
	local level_data = current_level_data[current_level]
	if not level_data then
		return nil
	end
	local texts = level_data.npc_texts
	if current_level_data.textdomain_npc_texts then
		local translated_texts = {}
		if texts then
			for npc, text in pairs(texts) do
				local tt = minetest.translate(current_level_data.textdomain_npc_texts, text)
				translated_texts[npc] = tt
			end
		end
		return translated_texts
	else
		return level_data.npc_texts
	end
end

function lzr_levels.get_flags()
	if not current_level_data then
		return nil
	end
	local level_data = current_level_data[current_level]
	if not level_data then
		return nil
	end
	local flags_str = level_data.flags
	if not flags_str then
		return nil
	end
	local parsed_flags = lzr_levels.parse_flags(flags_str)
	return parsed_flags
end

function lzr_levels.get_current_spawn_pos()
	if not current_level then
		return nil
	end
	local start_pos = get_start_pos(current_level, current_level_data)
	-- absolute spawn position
	local spawn_pos = vector.add(lzr_world.get_level_pos(), start_pos)
	return spawn_pos
end

function lzr_levels.start_level(level, level_data)
	if job_next_level then
		job_next_level:cancel()
		job_next_level = nil
	end
	if job_insta_win then
		job_insta_win:cancel()
		job_insta_win = nil
	end
	current_level = level
	current_level_data = level_data
	local player = get_singleplayer()

	local old_pos = lzr_world.get_level_pos()
	local old_size = lzr_world.get_level_size()

	local print_error = function(msg, resource)
		minetest.chat_send_player(player:get_player_name(), minetest.colorize("#FF0000", S(msg, tostring(resource))))
	end
	if level_data[level].backdrop == "ocean" then
		lzr_world.set_level_pos(lzr_globals.BACKDROP_POS_OCEAN)
	elseif level_data[level].backdrop == "islands" then
		lzr_world.set_level_pos(table.copy(level_data[level].backdrop_pos))
	elseif level_data[level].backdrop == "underground" then
		lzr_world.set_level_pos(lzr_globals.BACKDROP_POS_UNDERGROUND)
	elseif level_data[level].backdrop == "sky" then
		lzr_world.set_level_pos(lzr_globals.BACKDROP_POS_SKY)
	else
		print_error(NS("Could not start level: unknown backdrop “@1”"), level_data[level].backdrop)
		return
	end
	if not lzr_sky.sky_exists(level_data[level].sky) then
		print_error(NS("Could not start level: unknown sky “@1”"), level_data[level].sky)
		return
	end
	if not lzr_weather.weather_exists(level_data[level].weather) then
		print_error(NS("Could not start level: unknown weather “@1”"), level_data[level].weather)
		return
	end
	if not minetest.registered_nodes[level_data[level].node_wall] then
		print_error(NS("Could not start level: unknown wall boundary node “@1”"), level_data[level].node_wall)
		return
	end
	if not minetest.registered_nodes[level_data[level].node_ceiling] then
		print_error(NS("Could not start level: unknown ceiling boundary node “@1”"), level_data[level].node_ceiling)
		return
	end
	if not minetest.registered_nodes[level_data[level].node_floor] then
		print_error(NS("Could not start level: unknown floor boundary node “@1”"), level_data[level].node_floor)
		return
	end

	lzr_laser.reset_destroy_events()

	lzr_triggers.reset_triggers()

	local size = level_data[level].size
	lzr_world.set_level_size(size)

	local start_pos = get_start_pos(level, level_data)
	local spawn_pos = vector.add(lzr_world.get_level_pos(), start_pos)
	local yaw = get_start_yaw(level, level_data, start_pos)


	-- Report level loading start to callback functions
	for i=1, #registered_on_level_start_loadings do
		local callback = registered_on_level_start_loadings[i]
		callback(level_data.name, level)
	end

	local needs_rotate = level_data[current_level].contains_rotatable_block
	clear_inventory(player)
	lzr_damage.reset_player_damage(player)
	lzr_slowdown.stop_slowdown(player)

	local state = lzr_gamestate.get_state()
	if state ~= lzr_gamestate.LEVEL and state ~= lzr_gamestate.LEVEL_TEST then
		lzr_gamestate.set_state(lzr_gamestate.LEVEL)
	end

	minetest.close_formspec(player:get_player_name(), "lzr_speech:speech")

	lzr_gui.set_controls_gui(player, "none")
	lzr_gui.set_loading_gui(player)
	lzr_player.set_loading_inventory_formspec(player)
	lzr_gamestate.set_loading(true)

	-- Stuff do do when the level was started
	local on_done = function()
		reset_inventory(player, needs_rotate)
		lzr_ambience.set_ambience(level_data[level].ambience)
		lzr_sky.set_sky(level_data[level].sky)
		lzr_weather.set_weather(level_data[level].weather)
		local pflags = lzr_levels.parse_flags(level_data[level].flags)
		lzr_gui.set_play_gui(player, lzr_gamestate.get_state() == lzr_gamestate.LEVEL_TEST, pflags["hide_items"])
		lzr_gamestate.set_loading(false)
		minetest.log("action", "[lzr_levels] Started level "..level)
	end
	local on_error = function()
		-- Catch a loading failure and send the player back to
		-- the ship to ensure the game is still in a valid state when this happens
		minetest.log("error", "[lzr_levels] Failed to start level "..level)
		lzr_gamestate.set_loading(false)
		reset_inventory(player, needs_rotate)
		lzr_levels.go_to_menu()
	end

	-- Start loading and building the level (async)
	lzr_levels.prepare_and_build_level(level, level_data, spawn_pos, yaw, old_pos, old_size, on_done, on_error)

	minetest.log("action", "[lzr_levels] Starting level "..level.." ...")
end

local registered_on_collected_treasures = {}
-- Register a callback function func(treasures), to be
-- called when the total number of collected treasures
-- in the core level pack has been changed.
-- The parameter 'treasures' is the new number of treasures
function lzr_levels.register_on_collected_treasure(func)
	table.insert(registered_on_collected_treasures, func)
end

function lzr_levels.on_collected_treasure(level_data, treasures)
	local treasures = lzr_levels.count_total_collected_treasures(level_data)
	for i=1, #registered_on_collected_treasures do
		registered_on_collected_treasures[i](treasures)
	end
end

function lzr_levels.clear_level_progress(level_data)
	if level_data.is_singleton then
		return
	end
	level_packs_completed[level_data.name] = nil

	local levels = minetest.deserialize(mod_storage:get_string("lzr_levels:levels"), true)
	levels[level_data.name] = {}
	mod_storage:set_string("lzr_levels:levels", minetest.serialize(levels))

	if level_data.name == "__core" then
		lzr_menu.remove_painting("perfect_plunderer")

		-- Also needs to notify mods about changed treasure count
		local treasures = lzr_levels.count_total_collected_treasures(level_data)
		lzr_levels.on_collected_treasure(level_data, treasures)
	end
	minetest.log("action", "[lzr_levels] Level progress for level pack '"..level_data.name.."' was cleared")
end

function lzr_levels.mark_level_as_complete(level, level_data)
	if level_data.is_singleton then
		return
	end
	local levels = minetest.deserialize(mod_storage:get_string("lzr_levels:levels"), true)
	if not levels then
		levels = { [level_data.name] = {} }
	end
	if not level_data[level] then
		return false
	end
	local levelname = level_data[level].filename
	levelname = string.sub(levelname, 1, -5) -- remove .mts suffix

	if not levels[level_data.name] then
		levels[level_data.name] = {}
	end
	levels[level_data.name][levelname] = true
	mod_storage:set_string("lzr_levels:levels", minetest.serialize(levels))

	if level_data.name == "__core" then
		-- Notify mods that the treasure count has changed
		local treasures = lzr_levels.count_total_collected_treasures(level_data)
		lzr_levels.on_collected_treasure(level_data, treasures)
	end

	-- Place 'Perfect Plunderer' painting in ship
	-- as some kind of "mini-achievement"
	if level_data.name == "__core" and lzr_levels.are_all_levels_completed(level_data) then
		lzr_menu.place_painting("perfect_plunderer")
	end

	return true
end

-- Returns true if all levels of a level pack are completed
function lzr_levels.are_all_levels_completed(level_data)
	local completed = lzr_levels.get_completed_levels(level_data)
	for l=1, #level_data do
		local levelname = string.sub(level_data[l].filename, 1, -5) -- remove .mts suffix
		if not completed[levelname] then
			return false
		end
	end
	return true
end

-- Returns true if all levels of a level pack of a given difficulty are completed
function lzr_levels.is_difficulty_completed(level_data, difficulty)
	local completed = lzr_levels.get_completed_levels(level_data)
	if not level_data.difficulties then
		return true
	end
	local levels_in_difficulty = level_data.difficulties[difficulty]
	for l=1, #levels_in_difficulty do
		local lnum = levels_in_difficulty[l]
		local level = level_data[lnum]
		if not level then
			return false
		end
		local filename = level_data[lnum].filename
		local levelname = string.sub(level_data[lnum].filename, 1, -5) -- remove .mts suffix
		if not completed[levelname] then
			return false
		end
	end
	return true
end

-- Returns list of completed levels of a level pack in the form
--    { levelname1 = true, levelname2 = true, ... }
function lzr_levels.get_completed_levels(level_data)
	if level_data.is_singleton then
		return {}
	end
	local levels = minetest.deserialize(mod_storage:get_string("lzr_levels:levels"), true)
	if not levels then
		return {}
	end
	-- Make sure only levels that actually are in the level
	-- pack are returned.
	local available_levels = {}
	for lvl=1, #level_data do
		available_levels[level_data[lvl].filename] = true
	end
	if levels[level_data.name] then
		-- Gather completed levels
		local completed_levels = {}
		for k,_ in pairs(levels[level_data.name]) do
			if available_levels[k] then
				completed_levels[k] = true
			end
		end
		return levels[level_data.name]
	else
		return {}
	end
end

-- Returns number of completed levels in level pack
-- or nil if not applicable.
function lzr_levels.count_completed_levels(level_data)
	if level_data.is_singleton then
		return nil
	end
	local levels = minetest.deserialize(mod_storage:get_string("lzr_levels:levels"), true)
	if not levels then
		return 0
	end
	-- Make sure only levels that actually are in the level
	-- pack are counted.
	local available_levels = {}
	for lvl=1, #level_data do
		available_levels[level_data[lvl].filename] = true
	end
	if levels[level_data.name] then
		-- Count completed levels
		local count = 0
		for k,v in pairs(levels[level_data.name]) do
			if available_levels[k..".mts"] then
				count = count + 1
			end
		end
		return count
	else
		return 0
	end
end

--[[ Checks the data format of the completed levels list in mod storage
and updates it to the new format if neccessary.
To be executed at mod load time.

Old legacy format:
    A table with numbers for keys and booleans as values. The keys ar
    level numbers, the position of how they appeared in the levels list
    up to version 1.4.0.
    The booleans are true.
    Levels in the table with `true` as value count as completed.
    Levels not in the table count as incomplete.
    Example:

       { [1] = true, [3] = true, [5] = true }

    This means: Levels 1, 3 and 5 are completed.

New format:
    A table. The keys are always strings, they represent level pack names.
    This transforms to core levels only, as the legacy completed levels
    list were only for core levels as well.
    The value of each key is an inner table.
    The inner table has level names as keys (always string) and the values
    is set to true to represent level completion.
    Levels in the table with `true` as value count as completed.
    Levels not in the table count as incomplete.
    Example:

       {
           __core = {
               lzr_levels_dripstone_cave = true,
               lzr_levels_barriers = true,
           },
       }

    This means: The two built-in levels `lzr_levels_dripstone_cave`
    and `lzr_levels_barriers` are completed.
]]
local function update_legacy_completed_levels_format()
	local levels = minetest.deserialize(mod_storage:get_string("lzr_levels:levels"), true)
	if not levels or type(levels) ~= "table" then
		levels = {}
	end
	-- Check any single key of the levels table.
	-- If it's a number, the table is in legacy format.
	local is_legacy_format = false
	for k,v in pairs(levels) do
		if type(k) == "number" then
			is_legacy_format = true
		end
		break
	end

	if is_legacy_format then
		-- This CSV file is an 1:1 mapping from legacy level number to new level name
		-- Column 1 is the legacy level number, column 2 the new name.
		local level_list_file = io.open(minetest.get_modpath("lzr_levels").."/data/legacy_level_names.csv", "rb")
		local level_list_string = level_list_file:read("*a")
		local legacy_levels_csv = rcsv.parse_csv(level_list_string)
		level_list_file:close()

		local new_levels = { __core = {} }

		-- Match new level name identifiers with old-style level numbers
		-- and construct a new levels table.
		for l=1, #legacy_levels_csv do
			local level_number = tonumber(legacy_levels_csv[l][1])
			local level_name = legacy_levels_csv[l][2]
			if levels[level_number] == true then
				new_levels.__core[level_name] = true
			end
		end

		mod_storage:set_string("lzr_levels:levels", minetest.serialize(new_levels))

		minetest.log("action", "[lzr_levels] Converted level completion data in mod storage (lzr_levels:levels) from pre-1.4.0 format to new format")
	end
end

function lzr_levels.level_complete()
	local gs = lzr_gamestate.get_state()
	if gs ~= lzr_gamestate.LEVEL then
		return false
	end
	lzr_levels.mark_level_as_complete(current_level, current_level_data)
	local minpos, maxpos = lzr_world.get_level_bounds()

	-- Trigger chest 'flying treasure' animation
	local open_chests = minetest.find_nodes_in_area(minpos, maxpos, {"group:chest_open"})
	-- Sort the chests in a deterministic order
	table.sort(open_chests, function(c1, c2)
		if c1.x > c2.x then
			return true
		elseif c1.x < c2.x then
			return false
		elseif c1.z > c2.z then
			return true
		elseif c1.z < c2.z then
			return false
		else
			return c1.y > c2.y
		end
	end)
	for c=1, #open_chests do
		local pos = open_chests[c]
		local node = minetest.get_node(pos)
		local def = minetest.registered_nodes[node.name]
		local delay
		-- Add an unique delay for all chests besides
		-- the first one to reduce overlapping while
		-- rising
		if c > 1 then
			delay = (c-1) * 0.009
			-- Delay should not get too large tho to make
			-- sure the animation still plays for all chests
			delay = math.min(delay, 1.0)
		end
		if def._lzr_send_treasure then
			def._lzr_send_treasure(pos, node, delay)
		end
	end
	if #open_chests > 0 then
		lzr_laser.full_laser_update(minpos, maxpos)
	end

	local player = get_singleplayer()
	minetest.close_formspec(player:get_player_name(), "lzr_teleporter:level")

	local has_treasure = current_level_data[current_level].treasures > 0

	if has_treasure then
		lzr_messages.show_message(player, S("Level complete!"), LEVEL_CAPTION_TIME)
	else
		-- Level had no treasures and thus was insta-won;
		-- show special message
		lzr_messages.show_message(player, S("There are no treasures here!"), LEVEL_CAPTION_TIME)
	end

	if current_level_data.is_singleton then
		minetest.log("action", "[lzr_levels] Level completed")
	else
		minetest.log("action", "[lzr_levels] Level "..current_level.." of level pack '"..current_level_data.name.."' completed")
	end
	-- Victory fanfare
	if has_treasure then
		lzr_ambience.reduce_ambience(SOUND_TIME_LEVEL_COMPLETE)
		minetest.sound_play({name = "lzr_levels_level_complete", gain = 1}, {to_player=player:get_player_name()}, true)
	end

	lzr_gamestate.set_state(lzr_gamestate.LEVEL_COMPLETE)

	if job_next_level then
		job_next_level:cancel()
	end
	-- Go to next level
	job_next_level = minetest.after(NEXT_LEVEL_DELAY, function(completed_level)
		if lzr_gamestate.get_state() == lzr_gamestate.LEVEL_COMPLETE and current_level == completed_level then
			lzr_levels.next_level()
		end
		job_next_level = nil
	end, current_level)
end

function lzr_levels.level_pack_completed(level_data)
	if not level_packs_completed[level_data.name] then
		local player = minetest.get_player_by_name("singleplayer")
		if not player then
			return false
		end
		lzr_speech.speak(player,
			--~ Said by Goldie the Parrot after completing a level pack
			S("Yarr! You’ve looted all gold blocks in the level pack “@1”.", level_data.title).."\n"..
			--~ Said by Goldie the Parrot after completing a level pack
			S("Well done, matey!"),
			"goldie")
		-- Victory fanfare, but faster than the default victory fanfare
		lzr_ambience.reduce_ambience(SOUND_TIME_LEVEL_SET_COMPLETE * 0.78)
		minetest.sound_play({name = "lzr_levels_level_set_complete", gain = 0.6, pitch = 1.25}, nil, true)
		lzr_levels.leave_level(nil, false)
		minetest.log("action", "[lzr_levels] Player has completed the level pack '"..level_data.name.."'")
		return true
	end
	return false
end

function lzr_levels.game_completed()
	if not level_packs_completed.__core then
		local player = minetest.get_player_by_name("singleplayer")
		if not player then
			return false
		end
		lzr_speech.speak(player, S("Yarr! You did it! Our ship’s full of gold now. We’re rich!").."\n"..
			S("You’ve collected every treasure in the known world!").."\n"..
			S("No puzzle was too hard, no security mechanism could stop you.").."\n\n"..
			S("You have become the Perfect Plunderer!"), "goldie", nil, nil, "credits")
		lzr_ambience.reduce_ambience(SOUND_TIME_LEVEL_SET_COMPLETE)
		minetest.sound_play({name = "lzr_levels_level_set_complete", gain = 1.0}, nil, true)
		lzr_levels.leave_level(nil, false)
		lzr_menu.teleport_player_to_ship(player, "victory")
		minetest.log("action", "[lzr_levels] Game completed!")
		return true
	else
		return false
	end
end

-- Start a "next" level in the current level pack; intended to be called right after
-- a level was completed. Sometimes, this function may return the player to the ship instead.
-- The next level is chosen as follows:
-- * If at least one level is unsolved, pick the next *unsolved* level (counting from the current level)
-- * If all levels after the current level are solved, pick the first unsolved level in the level pack
-- * If all levels are solved, pick the next level (the level after the current level).
-- * If all levels are solved, and the current level is the final level, return to the ship
-- SPECIAL CASE: If the player completed their first level ever and haven’t seen the ship yet,
--               go to the ship and trigger the ship explanation dialog
-- This algorithm was designed so that the "next level" the game chooses for the player is always
-- a level that hasn't been solved yet, to avoid annoying repetitions, while also preserving the
-- level order as much as possible. The only exception is when the player has completed the level pack,
-- in which the player simply progresses through the levels in order.
function lzr_levels.next_level()
	local state = lzr_gamestate.get_state()
	if state ~= lzr_gamestate.LEVEL and state ~= lzr_gamestate.LEVEL_COMPLETE and state ~= lzr_gamestate.LEVEL_TEST then
		return
	end
	if not current_level_data then
		return
	end
	if current_level_data.is_singleton then
		lzr_levels.leave_level()
		return
	end
	local pack_completed = lzr_levels.are_all_levels_completed(current_level_data)
	if pack_completed then
		local packname = current_level_data.name
		local celebrated = false
		if packname == "__core" then
			celebrated = lzr_levels.game_completed()
		else
			celebrated = lzr_levels.level_pack_completed(current_level_data)
		end
		if celebrated then
			level_packs_completed[packname] = true
			return
		end
	end
	local player = get_singleplayer()
	if pack_completed then
		current_level = current_level + 1
	else
		local completed_levels = lzr_levels.get_completed_levels(current_level_data)
		local tlvl = current_level
		tlvl = tlvl + 1
		if tlvl > #current_level_data then
			tlvl = 1
		end
		local incomplete_level_found = false
		while tlvl ~= current_level do
			local level = current_level_data[tlvl]
			local levelname = string.sub(level.filename, 1, -5)
			if not completed_levels[levelname] then
				incomplete_level_found = true
				current_level = tlvl
				break
			end
			tlvl = tlvl + 1
			if tlvl > #current_level_data then
				tlvl = 1
			end
		end
		if not incomplete_level_found then
			current_level = current_level + 1
		end
	end

	local left_level = false
	if current_level > #current_level_data then
		lzr_messages.show_message(player, S("Final level completed!"), FINAL_LEVEL_CAPTION_TIME)
		lzr_levels.leave_level()
		left_level = true
	end

	local go_to_menu = false
	if not pack_completed then
		local menu_seen = lzr_levels.was_menu_seen()
		if not menu_seen then
			minetest.log("action", "[lzr_levels] Going to the menu for the first time")
			lzr_levels.go_to_menu()
			left_level = true
		end
	end
	if not left_level then
		lzr_levels.start_level(current_level, current_level_data)
	end
end

function lzr_levels.go_to_menu(fallout)
	current_level = nil
	current_level_data = nil
	local player = get_singleplayer()
	clear_inventory(player)
	-- Select respawn position
	if fallout then
		-- Respawn pos when fallout was triggered (i.e. fell out of level or crushed)
		lzr_menu.teleport_player_to_ship(player, "skulls")
		lzr_gui.set_menu_gui(player, nil)
	else
		-- Normal player spawn pos in ship
		lzr_menu.teleport_player_to_ship(player, "captain")
		lzr_gui.set_menu_gui(player, "captain")
	end
	lzr_gamestate.set_state(lzr_gamestate.MENU)
end

function lzr_levels.leave_level(fallout, play_sound)
	if lzr_gamestate.is_loading() then
		return false
	end
	local state = lzr_gamestate.get_state()
	if state ~= lzr_gamestate.LEVEL and state ~= lzr_gamestate.LEVEL_COMPLETE and state ~= lzr_gamestate.LEVEL_TEST then
		return false
	end
	current_level = nil
	current_level_data = nil

	lzr_levels.go_to_menu(fallout)

	-- Clean up level area
	local pos = lzr_world.get_level_pos()
	local size = lzr_world.get_level_size()
	lzr_levels.reset_level_area(false, pos, size)

	if play_sound ~= false then
		lzr_ambience.reduce_ambience(SOUND_TIME_LEVEL_LEAVE)
		minetest.sound_play({name = "lzr_levels_level_leave", gain = 0.4}, {to_player="singleplayer"}, true)
	end
	return true
end

function lzr_levels.get_current_level()
	return current_level
end

function lzr_levels.get_current_level_data()
	return current_level_data
end

-- Register a level pack. See `LEVEL_PACKS.md` for documentation
function lzr_levels.register_level_pack(name, info)

	local mod = minetest.get_current_modname()

	local level_data_file = info.level_data_path or minetest.get_modpath(mod).."/data/level_data.csv"
	local level_order_file = info.level_order_path or minetest.get_modpath(mod).."/data/level_order.csv"
	local schematic_path = info.schematic_path or minetest.get_modpath(mod).."/schematics"
	local solutions_path = info.solutions_path or minetest.get_modpath(mod).."/solutions"

	local error_type, error_msg, error_detail
	local level_data
	level_data, error_type, error_msg, error_detail = lzr_levels.analyze_levels(
		level_data_file, schematic_path, solutions_path, level_order_file)

	if not level_data then
		if error_type == "csv_error" then
			error("Error while parsing "..tostring(level_data_file)..": "..tostring(error_msg))
		elseif error_type == "csv_error_order" then
			error("Error while parsing "..tostring(level_order_file)..": "..tostring(error_msg))
		elseif error_type == "load_error" then
			error("Could not load "..tostring(level_data_file))
		elseif error_type == "bad_schematic" then
			error("Invalid level schematic in core level file: "..tostring(error_msg)..", problem type: "..tostring(error_detail))
		else
			error("Error while loading or parsing "..tostring(level_data_file))
		end
	end

	if PRINT_TRANSLATABLE_LEVEL_STRINGS then
		local tr_level_names = {}
		local tr_npc_texts = {}
		for d=1, #level_data do
			local level = level_data[d]
			local lname = level.name
			if lname and lname ~= "" then
				table.insert(tr_level_names, lname)
			end
			local npc_texts = level.npc_texts
			if npc_texts and npc_texts.goldie and npc_texts.goldie ~= "" then
				table.insert(tr_npc_texts, npc_texts.goldie)
			end
		end

		print("-- TRANSLATABLE STRINGS FOR LEVEL PACK '"..name.."' --")
		for l=1, #tr_level_names do
			print(string.format("NS_LEVEL_NAME(%q)", tr_level_names[l]))
		end
		for t=1, #tr_npc_texts do
			print(string.format("NS_NPC_TEXT(%q)", tr_npc_texts[t]))
		end
	end

	level_data.name = name
	level_data.mod_origin = minetest.get_current_modname()
	level_data.textdomain_npc_texts = info.textdomain_npc_texts
	level_data.textdomain_level_names = info.textdomain_level_names
	level_data.title = info.title
	level_data.description = info.description

	level_data.level_data_file = level_data_file
	level_data.level_order_file = level_order_file
	level_data.schematic_path = schematic_path
	level_data.solutions_path = solutions_path

	registered_level_packs[name] = level_data

	if lzr_levels.are_all_levels_completed(level_data) then
		level_packs_completed[name] = true
	end
end

-- Returns level packs by name
function lzr_levels.get_level_pack(name)
	return registered_level_packs[name]
end

-- Returns list of all registered level packs (by name)
function lzr_levels.get_level_pack_names()
	local packs = {}
	for k,v in pairs(registered_level_packs) do
		table.insert(packs, k)
	end
	return packs
end

-- Returns the level number for a level in a level pack within
-- a given difficulty.
-- * nth_level: The nth level with the given difficulty (1 = 1st, 2 = 2nd, etc.)
-- * level_data: level pack data table
-- * difficulty: Difficulty (given as number)
-- Returns a level number on success or nil on failure (e.g. level does not exist
-- or level pack doesn’t specify difficulties)
function lzr_levels.get_level_number_by_difficulty(nth_level, level_data, difficulty)
	local difficulty_list = level_data.difficulties
	if not difficulty_list then
		return nil
	end
	if not difficulty_list[difficulty] then
		return nil
	end
	local difficulty_level = difficulty_list[difficulty][nth_level]
	return difficulty_level
end

function lzr_levels.get_level_position_in_difficulty(level_number, level_data)
	local difficulty_list = level_data.difficulties
	if not difficulty_list then
		return nil
	end
	for d=0, 5 do
		if difficulty_list[d] then
			for l=1, #difficulty_list[d] do
				local difficulty_level = difficulty_list[d][l]
				if difficulty_level == level_number then
					return l
				end
			end
		end
	end
	return nil
end

-- Returns difficulty for a given level or nil if unspecified
function lzr_levels.get_level_difficulty(level_number, level_data)
	local difficulty_list = level_data.difficulties
	if not difficulty_list then
		return nil
	end
	for d=0, 5 do
		for l=1, #difficulty_list[d] do
			if difficulty_list[d][l] == level_number then
				return d
			end
		end
	end
	return nil
end

-- Returns the name of the level with the given level number, translated
-- (if available in level pack).
-- Note that levels may have an empty name.
-- If with_fallback is true and the level's name is empty, it will return
-- "Untitled (<file name>)" (translated)
function lzr_levels.get_level_name(level, level_data, with_fallback)
	local name = level_data[level].name
	if name and name ~= "" then
		if level_data.textdomain_level_names then
			return minetest.translate(level_data.textdomain_level_names, level_data[level].name)
		else
			return name
		end
	else
		if with_fallback then
			local fname = level_data[level].filename
			fname = string.sub(fname, 1, -5)
			--~ Fallback name for untitled levels. @1 = technical level name based on file name
			return S("Untitled (@1)", fname)
		else
			return ""
		end
	end
end

function lzr_levels.restart_level()
	if job_next_level then
		job_next_level:cancel()
		job_next_level = nil
	end
	if job_insta_win then
		job_insta_win:cancel()
		job_insta_win = nil
	end
	local state = lzr_gamestate.get_state()
	if state == lzr_gamestate.LEVEL or state == lzr_gamestate.LEVEL_COMPLETE or state == lzr_gamestate.LEVEL_TEST then
		if not lzr_gamestate.is_loading() then
			lzr_levels.start_level(current_level, current_level_data)
			return true
		else
			return false
		end
	else
		return false
	end
end

-- Called when a treasure has been found
lzr_treasure.register_after_found_treasure(function(pos)
	local minpos, maxpos = lzr_world.get_level_bounds()
	local treasures = lzr_laser.count_found_treasures(minpos, maxpos)
	local player = get_singleplayer()
	lzr_gui.update_treasure_status(player, treasures, get_max_treasures())
end)

--[[ THE SERIALIZED TRIGGER FORMAT
The serialized triggers is the specification of triggers
in a levels as a single string. There are 3 format types:

* (empty string): This means the level uses the default triggers:
  all chest locks will break open when all detectors are active
  at once. Mostly exists to support old levels.
* the string "none": There are no triggers in the level
* CSV format (RFC 4180): Used when there are triggers in the level.
  This is a sequence of CSV records, where each record specifies the
  following values:
     * First value is always the trigger position
     * The following values are pairs of metadata keys and values,
       e.g. "sender_type" followed by "4". This means the sender type
       is set to 4 for this trigger.
  Positions are stored in the minetest.pos_to_string format relative
  to the level origin (!).
  Example:

     "(0,1,2)",send_to,"(1,2,3);(4,5,6)",sender_type,0,receiver_type,0

   This means a trigger at (0,1,2) sends to locations (1,2,3) and (4,5,6)
   and is of sender type 0 and receiver type 0.

   For historic reasons, "signal_type" is an alias to "sender_type".
]]


-- Serialize triggers of the current level
function lzr_levels.serialize_triggers()
	local triggers = lzr_triggers.internal_trigger_export()
	local sorted_triggers = lzr_triggers.sort_triggers(triggers)
	local entries = {}
	for t=1, #sorted_triggers do
		local trigger_id = sorted_triggers[t].trigger_id
		local trigger = sorted_triggers[t].def

		local new_entry = {}
		local pos = minetest.string_to_pos(trigger_id)

		local send_to = trigger.send_to
		local send_to_level = {}
		if send_to then
			for s=1, #send_to do
				local spos = minetest.string_to_pos(send_to[s])
				local lpos = lzr_world.world_pos_to_level_pos(spos)
				table.insert(send_to_level, minetest.pos_to_string(lpos))
			end
		end

		local send_to_str = table.concat(send_to_level, ";")
		-- Note: We always store signal and receiver type,
		-- even for nodes where this doesn't apply.
		local sender_type = trigger.sender_type
		local receiver_type = trigger.receiver_type

		-- Only save trigger if it either sends to anywhere
		-- or has a non-default receiver type.
		if send_to_str ~= "" or receiver_type ~= 0 then

			-- <<< THE CSV RECORDS ARE CONSTRUCTED HERE >>>
			-- new_entry is equivalent to a single CSV record
			local new_entry = {
				-- Entry format is: First value is always pos, then the
				-- following values alternate between metadata keys and values.

				-- First value is pos
				minetest.pos_to_string(lzr_world.world_pos_to_level_pos(pos)),

				-- receivers (key, value)
				"send_to", send_to_str,

				-- sender type (key, value)
				"sender_type", tostring(sender_type),

				-- receiver type (key, value)
				"receiver_type", tostring(receiver_type),
			}
			table.insert(entries, new_entry)
		end
	end

	-- SPECIAL CASE: No triggers in the map, so we pass
	-- the special string "none".
	if #entries == 0 then
		return "none"
	end

	-- Internally, we format the data as CSV (RFC 4180).
	-- We do NOT use minetest.serialize because it's unsafe
	-- to use minetest.deserialize on arbitrary user data
	-- (level files in our case).
	local entries_s = rcsv.write_csv(entries)
	return entries_s
end

function lzr_levels.init_triggers()
	lzr_triggers.reset_triggers()

	local minpos, maxpos = lzr_world.get_level_bounds()

	local trigger_nodes = minetest.find_nodes_in_area(minpos, maxpos, {"group:sender", "group:receiver"})
	for t=1, #trigger_nodes do
		local tpos = trigger_nodes[t]
		local trigger_id = lzr_triggers.add_trigger(tpos)
		local meta = minetest.get_meta(tpos)
		meta:set_string("trigger_id", trigger_id)
	end
end

-- Default triggers: All chests locks break open when all detectors
-- are active.
local set_default_triggers = function()
	minetest.log("info", "[lzr_levels] Setting up the default triggers")
	local triggers = lzr_triggers.get_triggers()
	local locked_chests = {}
	-- Pass 1: Set receiver type for locked chests to SYNC_AND
	for trigger_id, trigger in pairs(triggers) do
		if type(trigger.location) == "table" then
			local pos = trigger.location
			local node = minetest.get_node(pos)
			if minetest.get_item_group(node.name, "chest") == 2 then
				lzr_triggers.set_trigger_receiver_type(trigger_id, lzr_triggers.RECEIVER_TYPE_SYNC_AND)
				table.insert(locked_chests, trigger_id)
			end
		end
	end
	-- Pass 2: Set detector signals to every locked chest
	for trigger_id, trigger in pairs(triggers) do
		if type(trigger.location) == "table" then
			local pos = trigger.location
			local node = minetest.get_node(pos)
			if minetest.get_item_group(node.name, "detector") ~= 0 then
				lzr_triggers.set_trigger_sender_type(trigger_id, lzr_triggers.SENDER_TYPE_SYNC)
				lzr_triggers.set_signals(trigger_id, table.copy(locked_chests))
			end
		end
	end
end

function lzr_levels.deserialize_triggers(serialized_triggers)
	if serialized_triggers == "" then
		-- Special case: Use default triggers
		set_default_triggers()
		return true
	elseif serialized_triggers == "none" then
		-- Special case: No triggers
		return true
	end

	-- Normal deserialization of triggers: Triggers are stored in CSV format (RFC-4180),
	-- defined in lzr_levels.serialize_triggers.
	local entries, csv_error = rcsv.parse_csv(serialized_triggers)
	if not entries then
		minetest.log("error", "[lzr_levels] Could not parse CSV-serialized triggers: "..tostring(csv_error))
		lzr_levels.init_triggers()
		return false
	end
	-- Parse entries.
	-- ANY error leads to a cancellation and the function
	-- will revert to the triggers in init state so the
	-- game doesn’t break.
	for e=1, #entries do
		local entry = entries[e]
		local pos_str = entry[1]
		if not pos_str then
			minetest.log("error", "[lzr_levels] deserialize_triggers: Missing position. Row="..e)
			lzr_levels.init_triggers()
			return false
		end
		local pos = minetest.string_to_pos(pos_str)
		if not pos then
			minetest.log("error", "[lzr_levels] deserialize_triggers: Invalid position. Row="..e)
			lzr_levels.init_triggers()
			return false
		end
		pos = lzr_world.level_pos_to_world_pos(pos)
		local trigger_id = minetest.pos_to_string(pos)
		if not lzr_triggers.trigger_exists(trigger_id) then
			minetest.log("error", "[lzr_levels] deserialize_triggers: Found information to trigger '"..tostring(trigger_id).."' which doesn’t exist")
			lzr_levels.init_triggers()
			return false
		end
		if not pos then
			minetest.log("error", "[lzr_levels] deserialize_triggers: Invalid position. Row="..e)
			lzr_levels.init_triggers()
			return false
		end
		for i=2,#entry,2 do
			local key = entry[i]
			local value = entry[i+1]
			if not key or not value then
				minetest.log("error", "[lzr_levels] deserialize_triggers: Key/value mismatch. Row="..e.."; row length="..#entry)
				lzr_levels.init_triggers()
				return false
			end
			if key == "send_to" then
				local send_to = lzr_laser.pos_string_to_positions(value)
				local send_to_world = {}
				for s=1, #send_to do
					local rpos = send_to[s]
					if type(rpos) == "table" and rpos.x and rpos.y and rpos.z then
						local rlpos = lzr_world.level_pos_to_world_pos(rpos)
						table.insert(send_to_world, minetest.pos_to_string(rlpos))
					else
						minetest.log("error", "[lzr_levels] deserialize_triggers: send_to["..s.."] is not a position vector! Row="..e)
						lzr_levels.init_triggers()
						return false
					end
				end

				lzr_triggers.set_signals(trigger_id, send_to_world)
			-- Sender type supports "signal_type" as an alias for historic reasons
			elseif key == "sender_type" or key == "signal_type" then
				local sender_type = tonumber(value)
				if sender_type then
					lzr_triggers.set_trigger_sender_type(trigger_id, sender_type)
				end
			elseif key == "receiver_type" then
				local receiver_type = tonumber(value)
				if receiver_type then
					lzr_triggers.set_trigger_receiver_type(trigger_id, receiver_type)
				end
			else
				minetest.log("error", "[lzr_levels] deserialize_triggers: Invalid key: "..tostring(key).."; Row="..e)
				lzr_levels.init_triggers()
				return false
			end
		end
	end
	return true
end

function lzr_levels.parse_flags(flags)
	local list = string.split(flags, "|")
	local parsed_flags = {}
	for l=1, #list do
		parsed_flags[list[l]] = true
	end
	return parsed_flags
end

lzr_levels.was_menu_seen = function()
	return mod_storage:get_int("lzr_levels:menu_seen") == 1
end

lzr_levels.set_menu_seen = function(seen)
	if seen then
		mod_storage:set_int("lzr_levels:menu_seen", 1)
	else
		mod_storage:set_int("lzr_levels:menu_seen", 0)
	end
end

minetest.register_chatcommand("level_info", {
	privs = {},
	params = "",
	description = S("Display information about the identity of the current level"),
	func = function(name, param)
		local state = lzr_gamestate.get_state()
		if state == lzr_gamestate.LEVEL or state == lzr_gamestate.LEVEL_COMPLETE or state == lzr_gamestate.LEVEL_TEST then
			local fail = true
			if current_level and current_level_data then
				local level = current_level_data[current_level]
				if level then
					fail = false
					local lname = lzr_levels.get_level_name(current_level, current_level_data, true)
					local fname = level.filename
					local out = ""
					local pinfo = minetest.get_player_information(name)
					local lang_code = pinfo.lang_code
					local VALUE_COLOR = "#ffc0c0"
					if lang_code and lang_code == "en" then
						out = out .. S("• Level name: @1", minetest.colorize(VALUE_COLOR, lname)) .. "\n"
					else
						out = out .. S("• Level name (in your language): @1", minetest.colorize(VALUE_COLOR, lname)).."\n"
						out = out .. S("• Level name (in English): @1", minetest.colorize(VALUE_COLOR, minetest.get_translated_string("en", lname))).."\n"
					end

					out = out .. S("• File name: @1", minetest.colorize(VALUE_COLOR, fname))
					if current_level_data.is_singleton then
						out = out .. "\n" .. S("• Level pack: @1", minetest.colorize(VALUE_COLOR,
							S("Single levels")))
					else
						local title = current_level_data.title or current_level_data.name or "???"
						out = out .. "\n" .. S("• Level pack: @1", minetest.colorize(VALUE_COLOR, title))
						if minetest.settings:get_bool("lzr_debug", false) then
							out = out .. "\n" .. S("• Level number: @1", minetest.colorize(VALUE_COLOR, current_level))
						end
					end
					return true, out
				end
			end
			if fail then
				minetest.log("error", "[lzr_levels] /levelinfo command could not get level information!")
				return false, S("Could not get level information.")
			end
		elseif state == lzr_gamestate.EDITOR then
			return false, S("You’re in the level editor.")
		else
			return false, S("Not playing in a level!")
		end
	end,
})

minetest.register_chatcommand("restart", {
	privs = {},
	params = "",
	description = S("Restart current level"),
	func = function(name, param)
		local state = lzr_gamestate.get_state()
		if state == lzr_gamestate.LEVEL or state == lzr_gamestate.LEVEL_COMPLETE then
			if not lzr_gamestate.is_loading() then
				lzr_levels.restart_level()
				return true
			else
				return false, S("Can’t restart while loading!")
			end
		elseif state == lzr_gamestate.LEVEL_TEST then
			return false, S("Can’t restart during the level solution test!")
		else
			return false, S("Not playing in a level!")
		end
	end,
})


lzr_gamestate.register_on_enter_state(function(state)
	local player = minetest.get_player_by_name("singleplayer")
	if state ~= lzr_gamestate.LEVEL then
		if player and player:is_player() then
			lzr_gui.set_controls_gui(player, "none")
		end
	end
	local hide_items
	if current_level_data and current_level then
		local pflags = lzr_levels.parse_flags(current_level_data[current_level].flags)
		hide_items = pflags["hide_items"]
	end
	if state == lzr_gamestate.LEVEL or state == lzr_gamestate.LEVEL_TEST then
		lzr_gui.set_play_gui(player, state == lzr_gamestate.LEVEL_TEST, hide_items)
		local level_name
		if current_level and current_level_data then
			level_name = lzr_levels.get_level_name(current_level, current_level_data, true)
			local backdrop = current_level_data[current_level].backdrop
			if backdrop == "ocean" then
				lzr_world.set_level_pos(lzr_globals.BACKDROP_POS_OCEAN)
			elseif backdrop == "islands" then
				lzr_world.set_level_pos(table.copy(current_level_data[current_level].backdrop_pos))
			elseif backdrop == "underground" then
				lzr_world.set_level_pos(lzr_globals.BACKDROP_POS_UNDERGROUND)
			elseif backdrop == "sky" then
				lzr_world.set_level_pos(lzr_globals.BACKDROP_POS_SKY)
			end
		end
		lzr_player.set_play_inventory(player, level_name, state == lzr_gamestate.LEVEL_TEST, hide_items)
	elseif state == lzr_gamestate.LEVEL_COMPLETE then
		local level_name
		if current_level and current_level_data then
			level_name = lzr_levels.get_level_name(current_level, current_level_data, true)
		end
		lzr_gui.set_play_gui(player, state == lzr_gamestate.LEVEL_TEST, hide_items)
		lzr_player.set_play_inventory(player, level_name, nil, hide_items)
	end
end)

minetest.register_on_player_receive_fields(function(player, formname, fields)
	local state = lzr_gamestate.get_state()
	if state == lzr_gamestate.LEVEL or state == lzr_gamestate.LEVEL_COMPLETE or state == lzr_gamestate.LEVEL_TEST then
		-- Fields from inventory formspec, set in lzr_player
		if fields.__lzr_levels_leave then
			if lzr_gamestate.is_loading() then
				minetest.chat_send_player(player:get_player_name(), S("Can’t leave while loading!"))
				return
			end
			lzr_levels.leave_level()
		elseif fields.__lzr_levels_restart and state ~= lzr_gamestate.LEVEL_TEST then
			if lzr_gamestate.is_loading() then
				minetest.chat_send_player(player:get_player_name(), S("Can’t restart while loading!"))
				return
			end
			lzr_levels.restart_level()
		end
	end
end)

minetest.register_on_joinplayer(function(player)
	-- Teleport level into the first level of the core levels if it wasn't completed before
	local intro_completed = lzr_levels.is_intro_completed()
	if intro_completed == false then
		local core_level_data = lzr_levels.get_level_pack("__core")
		lzr_levels.start_level(1, core_level_data)
	elseif intro_completed == nil then
		minetest.log("error", "[lzr_levels] Could not get core levels when checking intro completion!")
	end
end)

update_legacy_completed_levels_format()


if minetest.settings:get_bool("lzr_debug", false) then
	-- Debug command to mark one or multiple levels as complete.
	minetest.register_chatcommand("complete_levels", {
		privs = { server = true },
		params = S("[<level pack>] <first level> [<last level>]"),
		description = S("Mark one or multiple levels of a level pack as complete"),
		func = function(name, param)
			-- <level pack> <first level> <last level>
			local level_pack, first_level, last_level = string.match(param, "([a-zA-Z0-9_]+) (%d+) (%d+)")
			if not level_pack then
				-- <first level> <last level>
				first_level, last_level = string.match(param, "(%d+) (%d+)")
				if not first_level then
					-- <level pack> <first level>
					level_pack, first_level = string.match(param, "([a-zA-Z0-9_]+) (%d+)")
					if not level_pack then
						-- <level>
						first_level = string.match(param, "(%d+)")
						last_level = first_level
					end
				end
			end
			first_level = tonumber(first_level)
			last_level = tonumber(last_level)
			if not first_level then
				return false
			end
			if not level_pack then
				level_pack = "__core"
			end
			if not last_level then
				last_level = first_level
			end
			local level_data = lzr_levels.get_level_pack(level_pack)
			if not level_data then
				return false, S("Level pack “@1” doesn’t exist.", level_pack)
			end
			--~ @1, @2, and @3 are level numbers
			local not_exist_msg = NS("Level @1 does not exist in this level pack. Available levels range from @2 to @3.")
			if first_level < 1 or first_level > #level_data then
				return false, S(not_exist_msg, first_level, 1, #level_data)
			end
			if last_level < 1 or last_level > #level_data then
				return false, S(not_exist_msg, last_level, 1, #level_data)
			end
			if last_level < first_level then
				return false, S("The first level number must not be larger than the last level number.")
			end
			local count = 0
			for n=first_level, last_level do
				lzr_levels.mark_level_as_complete(n, level_data)
				count = count + 1
			end
			if first_level == last_level then
				return true, S("Marked level @1 in level pack “@2” as complete.", first_level, level_pack)
			else
				return true, S("Marked levels @1 to @2 in level pack “@3” marked as complete.", first_level, last_level, level_pack)
			end
		end,
	})
end
