-- Laser "physics". This file contains the code that propagates
-- the lasers, updates the laser nodes and the map.

local S = minetest.get_translator("lzr_laser")

-- Max. number of steps in the laser travel algorithm
local MAX_LASER_ITERATIONS = 30000

-- Max. number of nodes a laser can extend out of level bounds
local MAX_LASERS_OUT_OF_BOUNDS_DISTANCE = 100

-- How long a barricade burns, in seconds
local BARRICADE_BURN_TIME = 1.0

-- How long the bomb fuse burns, in seconds,
-- when ignited directly.
local BOMB_BURN_TIME = 2.0
-- How long the bomb takes to explode
-- when it was caught up in an explosion,
-- in seconds
local BOMB_BURN_TIME_QUICK = 0.2

local destroy_events = {}

local out_of_bounds_lasers = {}
local out_of_bounds_destroyeds = {}

local recently_touched_receivers = {}

-- If true, the laser simulation is frozen. While frozen,
-- lasers won't automatically update on map changes.
local lasers_frozen = false

-- Calculate all node positions of an out-of-bounds laser starting at `pos`,
-- direction `dir` and colorcode `colorcode`.
-- An out-of-bounds laser will extend up to MAX_LASERS_OUT_OF_BOUNDS_DISTANCE
-- nodes.
-- Returns <positions>, <barrier_pos>, <destroyed_node_positions>, <laser node>
function lzr_laser.travel_laser_out_of_bounds(pos, dir, colorcode)
	local posses = {}
	local barrier_pos
	local destroyeds = {}
	local laser_node
	local dirs = lzr_laser.vector_and_color_to_dirs(dir, colorcode)
	local dirstring = lzr_laser.dirs_to_dirstring(dirs)
	local laser_node
	if colorcode == 0 then
		laser_node = { name = "air" }
	else
		laser_node = { name = "lzr_laser:laser_"..dirstring }
	end

	local i = 0
	local first_pos
	while i < MAX_LASERS_OUT_OF_BOUNDS_DISTANCE do
		if not first_pos then
			first_pos = pos
		end
		local node = minetest.get_node(pos)
		local ld = minetest.get_item_group(node.name, "laser_destroys")
		local la = minetest.get_item_group(node.name, "laser")

		-- First block in path is a barrier
		if i == 0 and (minetest.get_item_group(node.name, "barrier") > 0 or minetest.get_item_group(node.name, "rain_membrane") > 0) then
			barrier_pos = pos
		-- Laser through air or destroyable block or existing laser
		elseif node.name == "air" or ld == 1 or la ~= 0 then
			table.insert(posses, pos)
			if ld == 1 then
				table.insert(destroyeds, { pos = pos, node = node, start_pos = first_pos })
			end
		else
			break
		end
		pos = vector.add(pos, dir)
		i = i + 1
	end
	return posses, barrier_pos, destroyeds, laser_node
end

-- This propagates a *single laser* by a *single step* and checks what
-- to do with the next node. This will either add the laser, stop the
-- laser propagation (due to collision), or do a special event in case we hit a
-- laser block (mirror, crystal, etc.).
-- This function only works on VoxelManip data!
-- Parameters:
-- * `pos`: start position
-- * `dir`: laser direction
-- * `colorcode`: laser colorcode
-- * `varea`: VoxelArea for the VoxelManip workable area
-- * `vdata`: VoxelManip data table gotten with `get_data`
-- * `vdata_p2`: VoxelManip data table, but for param2
-- * `emit_state`: Table that contains more complex info about the laser state (call-by-reference)
--
-- Returns a list of the *next* laser positions and direction, each entry is in format:
--    { pos, dir, colorcode }
-- This indicates where to spawn the next lasers (pos), where they are headed
-- towards (dir) and what color they are (colorcode).
-- Some laser blocks may spawn multiple lasers (crystal, beam spliter),
-- this is why it must be a list.
-- If the laser terminates (e.g. due to collision), returns false instead.
function lzr_laser.add_laser(pos, dir, colorcode, varea, vdata, vdata_p2, emit_state)
	local lminpos, lmaxpos = lzr_world.get_level_bounds()
	-- Check if laser is going outside the level bounds
	if pos.x < lminpos.x or pos.x > lmaxpos.x or
			pos.y < lminpos.y or pos.y > lmaxpos.y or
			pos.z < lminpos.z or pos.z > lmaxpos.z then
		return { "laser_out_of_bounds", pos, dir, colorcode }
	end
	local vi = varea:indexp(pos)
	local content_id = vdata[vi]
	local param2 = vdata_p2[vi]
	local nodename = minetest.get_name_from_content_id(content_id)
	local ld = minetest.get_item_group(nodename, "laser_destroys")
	-- Laser through air or destroyable block
	if content_id == minetest.CONTENT_AIR or ld == 1 then
		local dirs = lzr_laser.vector_and_color_to_dirs(dir, colorcode)
		local dirstring = lzr_laser.dirs_to_dirstring(dirs)
		vdata[vi] = minetest.get_content_id("lzr_laser:laser_"..dirstring)
		if ld == 1 then
			table.insert(emit_state.destroy_cache, {pos=pos, nodename=nodename})
		end
		-- Just advance straight ahead
		pos = vector.add(pos, dir)
		return { "laser", {pos, dir, colorcode}}
	-- Burning block
	elseif ld == 2 or ld == 3 then
		local def = minetest.registered_nodes[nodename]
		local active = def._lzr_active
		if active then
			local burn = false
			if ld == 2 then
				burn = true
			-- Gets ignited from top only (bomb)
			elseif ld == 3 then
				local top_dir = lzr_laser.get_top_dir(param2)
				local inverted_dir = vector.multiply(dir, -1)
				if vector.equals(inverted_dir, top_dir) then
					burn = true
				end
			end
			-- Only burn in-game (for editor convenience)
			local gs = lzr_gamestate.get_state()
			if burn and (gs == lzr_gamestate.LEVEL or gs == lzr_gamestate.LEVEL_TEST) then
				vdata[vi] = minetest.get_content_id(active)
				table.insert(emit_state.burning_cache, vi)
			end
		else
			minetest.log("error", "[lzr_laser] Node definition of "..nodename.." has laser_destroys="..ld.." but no _lzr_active")
		end
		-- Laser collides
		return false
	-- Laser through laser (laser intersection)
	elseif minetest.get_item_group(nodename, "laser") > 0 then
		local laser_group = minetest.get_item_group(nodename, "laser")
		local dirstring_old = lzr_laser.laser_group_to_dirstring(laser_group)

		local dirs_new = lzr_laser.vector_and_color_to_dirs(dir, colorcode)
		local dirstring_new = lzr_laser.dirs_to_dirstring(dirs_new)

		local place_dirstring = lzr_laser.dirstring_or(dirstring_old, dirstring_new)
		vdata[vi] = minetest.get_content_id("lzr_laser:laser_"..place_dirstring)
		-- Advance straight ahead
		pos = vector.add(pos, dir)
		return { "laser", {pos, dir, colorcode}}
	-- Laser through skull
	elseif minetest.get_item_group(nodename, "skull_shy") > 0 or minetest.get_item_group(nodename, "skull_cursed") > 0 then
		local def = minetest.registered_nodes[nodename]
		local active = def._lzr_active
		if active then
			-- Activate skull node
			vdata[vi] = minetest.get_content_id(def._lzr_active)
		end
		pos = vector.add(pos, dir)
		return { "laser", {pos, dir, colorcode}}
	-- Mirror laser
	elseif minetest.get_item_group(nodename, "mirror") > 0 then
		local mirror_dir = lzr_laser.get_mirrored_laser_dir(nodename, param2, dir)
		if mirror_dir then
			local def = minetest.registered_nodes[nodename]
			local active = def._lzr_active
			if active then
				-- Activate mirror node and mix color
				local old_colorcode = minetest.get_item_group(nodename, "laser_block_color")
				local new_colorcode = bit.bor(colorcode, old_colorcode)
				vdata[vi] = minetest.get_content_id(active.."_"..new_colorcode)
			end
			-- Set new pos and dir after calculating mirror direction
			pos = vector.add(pos, mirror_dir)
			dir = mirror_dir
			return { "laser", {pos, dir, colorcode}}
		else
			return false
		end
	-- Mirror laser (double mirror)
	elseif minetest.get_item_group(nodename, "double_mirror") > 0 then
		local mirror_dir, _, mirror_side = lzr_laser.get_mirrored_laser_dir(nodename, param2, dir)
		if mirror_dir then
			local def = minetest.registered_nodes[nodename]
			local state = def._lzr_double_mirror_state
			if not state then
				minetest.log("error", "[lzr_laser] Double mirror node '"..nodename.."' does not have _lzr_double_mirror_state!")
				return false
			end
			-- Combine double mirror state (laser on frontside or
			-- backside of the mirror?)
			if mirror_side == true then
				state = lzr_laser.dirstring_or(state, "0"..colorcode)
			else
				state = lzr_laser.dirstring_or(state, colorcode.."0")
			end

			local is_takable = minetest.get_item_group(nodename, "takable") ~= 0
			local is_rotatable = minetest.get_item_group(nodename, "rotatable") == 1
			local active = "lzr_laser:double_mirror_"..state
			if is_takable then
				active = active.."_takable"
			elseif is_rotatable then
				active = active.."_rotatable"
			else
				active = active.."_fixed"
			end

			-- Set new double mirror node state
			vdata[vi] = minetest.get_content_id(active)

			-- Set new pos and dir after calculating mirror direction
			pos = vector.add(pos, mirror_dir)
			dir = mirror_dir
			return { "laser", {pos, dir, colorcode}}
		else
			-- Laser came from the wrong side so it ends here
			return false
		end

		local mirror_dir = lzr_laser.get_mirrored_laser_dir(nodename, param2, dir)
		if mirror_dir then
			local def = minetest.registered_nodes[nodename]
			local active = def._lzr_active
			if active then
				-- Activate mirror node and mix color
				local old_colorcode = minetest.get_item_group(nodename, "laser_block_color")
				local new_colorcode = bit.bor(colorcode, old_colorcode)
				vdata[vi] = minetest.get_content_id(active.."_"..new_colorcode)
			end
			-- Set new pos and dir after calculating mirror direction
			pos = vector.add(pos, mirror_dir)
			dir = mirror_dir
			return { "laser", {pos, dir, colorcode}}
		else
			return false
		end

	-- Mirror and split laser
	elseif minetest.get_item_group(nodename, "transmissive_mirror") > 0 then
		local mirror_dir, mirror_ingoing = lzr_laser.get_mirrored_laser_dir(nodename, param2, dir)
		if mirror_dir then
			local def = minetest.registered_nodes[nodename]
			local state = def._lzr_transmissive_mirror_state
			if not state then
				minetest.log("error", "[lzr_laser] Transmissive mirror node '"..nodename.."' does not have _lzr_transmissive_mirror_state!")
				return false
			end
			-- Combine internal laser state
			if mirror_ingoing == true then
				-- Combine current state with 0X (X = incoming colorcode)
				state = lzr_laser.dirstring_or(state, "0"..colorcode)
			else
				-- Combine current state with X0 (X = incoming colorcode)
				state = lzr_laser.dirstring_or(state, colorcode.."0")
			end

			local is_takable = minetest.get_item_group(nodename, "takable") ~= 0
			local is_rotatable = minetest.get_item_group(nodename, "rotatable") == 1
			local active = "lzr_laser:transmissive_mirror_"..state
			if is_takable then
				active = active.."_takable"
			elseif is_rotatable then
				active = active.."_rotatable"
			else
				active = active.."_fixed"
			end

			-- Set new node state
			vdata[vi] = minetest.get_content_id(active)

			-- Report new laser positions and directions.
			-- This always will report 2 new lasers.
			-- The laser goes right through ...
			local pos_straight = vector.add(pos, dir)
			local dir_straight = dir
			-- ... and it is also deflected
			local pos_mirrored = vector.add(pos, mirror_dir)
			local dir_mirrored = mirror_dir
			return {
				"laser",
				-- The laser that went straight through
				{pos_straight, dir_straight, colorcode},
				-- The mirrored laser
				{pos_mirrored, dir_mirrored, colorcode}
			}
		else
			-- Laser came from the wrong side so it ends here
			return false
		end
	-- Crystal: Spread laser to all directions
	elseif minetest.get_item_group(nodename, "crystal") > 0 then
		local def = minetest.registered_nodes[nodename]

		local poshash = minetest.hash_node_position(pos)
		if not emit_state.crystal_outgoing[poshash] then
			emit_state.crystal_outgoing[poshash] = {}
		end

		-- Set dirs to spread laser towards
		local dirs = {
			vector.new(0, -1, 0),
			vector.new(0, 1, 0),
			vector.new(-1, 0, 0),
			vector.new(1, 0, 0),
			vector.new(0, 0, -1),
			vector.new(0, 0, 1),
		}
		-- Don't spread to the direction we came from!
		local fromdir = vector.multiply(dir, -1)
		for d=#dirs, 1, -1 do
			local remove_dir = false
			if vector.equals(dirs[d], fromdir) then
				remove_dir = true
			else
				local outs = emit_state.crystal_outgoing[poshash]
				for o=1, #outs do
					if colorcode == outs[o].colorcode and vector.equals(dirs[d], outs[o].dir) then
						remove_dir = true
						break
					end
				end
			end
			if remove_dir then
				table.remove(dirs, d)
			else
				table.insert(emit_state.crystal_outgoing[poshash], { dir = dirs[d], colorcode = colorcode })
			end
		end
		if #dirs == 0 then
			return false
		end
		local output = { "laser" }
		for d=1, #dirs do
			table.insert(output, { vector.add(pos, dirs[d]), dirs[d], colorcode })
		end

		-- Activate node and mix color
		local active = def._lzr_active
		if active then
			local old_colorcode = minetest.get_item_group(nodename, "laser_block_color")
			local new_colorcode = bit.bor(colorcode, old_colorcode)
			vdata[vi] = minetest.get_content_id(active.."_"..new_colorcode)
		end

		return output
	-- Pillar Crystal: Spread laser to 4 horizontal directions
	elseif minetest.get_item_group(nodename, "pillar_crystal") > 0 then
		local def = minetest.registered_nodes[nodename]

		local poshash = minetest.hash_node_position(pos)
		if not emit_state.crystal_outgoing[poshash] then
			emit_state.crystal_outgoing[poshash] = {}
		end

		-- Set dirs to spread laser towards
		local dirs
		local axis = lzr_laser.get_pillar_axis(param2)
		if axis == "x" then
			dirs = {
				vector.new(0, -1, 0),
				vector.new(0, 1, 0),
				vector.new(0, 0, -1),
				vector.new(0, 0, 1),
			}
		elseif axis == "y" then
			dirs = {
				vector.new(-1, 0, 0),
				vector.new(1, 0, 0),
				vector.new(0, 0, -1),
				vector.new(0, 0, 1),
			}
		elseif axis == "z" then
			dirs = {
				vector.new(-1, 0, 0),
				vector.new(1, 0, 0),
				vector.new(0, -1, 0),
				vector.new(0, 1, 0),
			}
		end

		local crystal_hit = false
		-- Check if the laser hit the crystal, then
		-- make sure we don't spread to the direction we came from!
		local fromdir = vector.multiply(dir, -1)
		local add_to_outgoing_list = {}
		for d=#dirs, 1, -1 do
			local remove_dir = false
			if vector.equals(dirs[d], fromdir) then
				crystal_hit = true
				remove_dir = true
			else
				local outs = emit_state.crystal_outgoing[poshash]
				for o=1, #outs do
					if colorcode == outs[o].colorcode and vector.equals(dirs[d], outs[o].dir) then
						remove_dir = true
						break
					end
				end
			end
			if remove_dir then
				table.remove(dirs, d)
			else
				table.insert(add_to_outgoing_list, { dir = dirs[d], colorcode = colorcode })
			end
		end

		if not crystal_hit then
			return false
		end
		if #dirs == 0 then
			return false
		end

		for l=1, #add_to_outgoing_list do
			table.insert(emit_state.crystal_outgoing[poshash], add_to_outgoing_list[l])
		end

		-- Activate block
		local active = def._lzr_active
		if active then
			-- Activate node and mix color
			local old_colorcode = minetest.get_item_group(nodename, "laser_block_color")
			local new_colorcode = bit.bor(colorcode, old_colorcode)
			vdata[vi] = minetest.get_content_id(active.."_"..new_colorcode)
		end

		local output = { "laser" }
		for d=1, #dirs do
			table.insert(output, { vector.add(pos, dirs[d]), dirs[d], colorcode })
		end
		return output

	-- Triangle Crystal: Spread laser to 3 perpendicular directions
	elseif minetest.get_item_group(nodename, "triangle_crystal") > 0 then
		local def = minetest.registered_nodes[nodename]

		local poshash = minetest.hash_node_position(pos)
		if not emit_state.crystal_outgoing[poshash] then
			emit_state.crystal_outgoing[poshash] = {}
		end

		local crystal_dir = minetest.facedir_to_dir(param2)

		local crystal_axis_num = math.floor(param2 / 4)

		local crystal_dir_real
		if crystal_axis_num == 0 then
			crystal_dir_real = vector.new(0,1,0)
		elseif crystal_axis_num == 1 then
			crystal_dir_real = vector.new(0,0,1)
		elseif crystal_axis_num == 2 then
			crystal_dir_real = vector.new(0,0,-1)
		elseif crystal_axis_num == 3 then
			crystal_dir_real = vector.new(1,0,0)
		elseif crystal_axis_num == 4 then
			crystal_dir_real = vector.new(-1,0,0)
		elseif crystal_axis_num == 5 then
			crystal_dir_real = vector.new(0,-1,0)
		else
			minetest.log("error", "[lzr_laser] Invalid axis of triangle crystal at "..minetest.pos_to_string(pos).."?")
			return false
		end


		local crystal_rot = param2 % 4

		-- Axis of the hypotenuse of the triangle
		local hypotenuse_axis
		if crystal_dir.x ~= 0 then
			if crystal_dir_real.y ~= 0 then
				hypotenuse_axis = "z"
			else
				hypotenuse_axis = "y"
			end
		elseif crystal_dir.y ~= 0 then
			if crystal_rot == 0 or crystal_rot == 2 then
				hypotenuse_axis = "x"
			else
				hypotenuse_axis = "z"
			end
		elseif crystal_dir.z ~= 0 then
			if crystal_dir_real.y ~= 0 then
				hypotenuse_axis = "x"
			else
				hypotenuse_axis = "y"
			end
		end

		local outdirs = {}
		if hypotenuse_axis == "x" then
			if vector.equals(dir, crystal_dir) then
				table.insert(outdirs, vector.new(-1, 0, 0))
				table.insert(outdirs, vector.new(1, 0, 0))
			elseif dir.x ~= 0 then
				table.insert(outdirs, vector.multiply(crystal_dir, -1))
				table.insert(outdirs, dir)
			end
		elseif hypotenuse_axis == "y" then
			if vector.equals(dir, crystal_dir) then
				table.insert(outdirs, vector.new(0, -1, 0))
				table.insert(outdirs, vector.new(0, 1, 0))
			elseif dir.y ~= 0 then
				table.insert(outdirs, vector.multiply(crystal_dir, -1))
				table.insert(outdirs, dir)
			end
		elseif hypotenuse_axis == "z" then
			if vector.equals(dir, crystal_dir) then
				table.insert(outdirs, vector.new(0, 0, -1))
				table.insert(outdirs, vector.new(0, 0, 1))
			elseif dir.z ~= 0 then
				table.insert(outdirs, vector.multiply(crystal_dir, -1))
				table.insert(outdirs, dir)
			end
		end

		local fromdir = vector.multiply(dir, -1)
		local add_to_outgoing_list = {}
		for o=#outdirs, 1, -1 do
			local remove_dir = false
			if vector.equals(outdirs[o], fromdir) then
				remove_dir = true
			else
				local outgoings = emit_state.crystal_outgoing[poshash]
				for g=1, #outgoings do
					if colorcode == outgoings[g].colorcode and vector.equals(outdirs[o], outgoings[g].dir) then
						remove_dir = true
						break
					end
				end
			end
			if remove_dir then
				table.remove(outdirs, o)
			else
				table.insert(add_to_outgoing_list, { dir = outdirs[o], colorcode = colorcode })
			end
		end

		if #outdirs == 0 then
			return false
		end

		for l=1, #add_to_outgoing_list do
			table.insert(emit_state.crystal_outgoing[poshash], add_to_outgoing_list[l])
		end

		-- Activate block
		local active = def._lzr_active
		if active then
			-- Activate node and mix color
			local old_colorcode = minetest.get_item_group(nodename, "laser_block_color")
			local new_colorcode = bit.bor(colorcode, old_colorcode)
			vdata[vi] = minetest.get_content_id(active.."_"..new_colorcode)
		end

		local output = { "laser" }
		for d=1, #outdirs do
			table.insert(output, { vector.add(pos, outdirs[d]), outdirs[d], colorcode })
		end
		return output

	-- Mixer: Mix laser colors
	elseif minetest.get_item_group(nodename, "mixer") > 0 then
		local output_dir = lzr_laser.get_front_dir(param2)
		local input_dir_l, input_dir_r = lzr_laser.get_mixer_input_dirs(param2)
		if (input_dir_l and vector.equals(dir, input_dir_l)) or (input_dir_r and vector.equals(dir, input_dir_r)) then
			local def = minetest.registered_nodes[nodename]

			local active = def._lzr_active
			if active then
				-- Activate node and mix output color of node
				local old_colorcode = minetest.get_item_group(nodename, "laser_block_color")
				local new_colorcode = bit.bor(colorcode, old_colorcode)
				-- Stop laser propagation if we already output this laser color
				-- to break potential infinite loops.
				if old_colorcode == new_colorcode then
					return false
				end
				vdata[vi] = minetest.get_content_id(active.."_"..new_colorcode)
			end

			--[[ NOTE: The mixer does NOT actually mix the laser color,
			it just propagates the input laser to the
			output with the color unchanged. Since the mixer
			has multiple inputs, if lasers go in into all inputs,
			they will overlap behind the output (see the behavior
			on laser-to-laser collision). So internally,
			*all* input lasers will travel out of the mixer as they
			came in separately, but they will overlap as a result of the
			laser propagation algorithm. ]]

			local output_pos = vector.add(pos, output_dir)
			return {
				"laser",
				{output_pos, output_dir, colorcode},
			}
		else
			-- Laser came from the wrong side so it ends here
			return false
		end

	-- Detector
	elseif minetest.get_item_group(nodename, "detector") > 0 then
		local detected = lzr_laser.check_detector_input(nodename, param2, dir)
		if detected then
			local detector_color = minetest.get_item_group(nodename, "detector_color")
			-- Colorless detector always activates with any laser color
			if detector_color == 0 then
				local def = minetest.registered_nodes[nodename]
				local active = def._lzr_active
				if active then
					-- Activate node
					vdata[vi] = minetest.get_content_id(active)
					return false
				end
			-- Colored detector: We have to report this to the calling function
			else
				return { "detected", pos, colorcode }
			end
		end
		-- Laser ends here
		return false
	-- Hollow barrel
	elseif minetest.get_item_group(nodename, "hollow_barrel") > 0 then
		-- Laser can through the hollow part
		local axis = lzr_laser.get_pillar_axis(param2)
		if (dir.x ~= 0 and axis == "x") or
				(dir.y ~= 0 and axis == "y") or
				(dir.z ~= 0 and axis == "z") then
			local def = minetest.registered_nodes[nodename]
			local active = def._lzr_active
			if active then
				-- Combine color
				local old_colorcode = minetest.get_item_group(nodename, "laser_block_color")
				local new_colorcode = bit.bor(colorcode, old_colorcode)
				-- Activate barrel node
				vdata[vi] = minetest.get_content_id(active.."_"..new_colorcode)
			end
			pos = vector.add(pos, dir)
			return { "laser", {pos, dir, colorcode}}
		else
			return false
		end
	-- Pane
	elseif minetest.get_item_group(nodename, "pane") > 0 then
		-- Laser can through the pane
		local axis = lzr_laser.get_pane_axis(param2)
		if (dir.x ~= 0 and axis == "x") or
				(dir.y ~= 0 and axis == "y") or
				(dir.z ~= 0 and axis == "z") then
			local def = minetest.registered_nodes[nodename]
			local active = def._lzr_active
			if active then
				-- Combine color
				local old_colorcode = minetest.get_item_group(nodename, "laser_block_color")
				local new_colorcode = bit.bor(colorcode, old_colorcode)
				-- Activate pane node
				vdata[vi] = minetest.get_content_id(active.."_"..new_colorcode)
			end
			pos = vector.add(pos, dir)
			return { "laser", {pos, dir, colorcode}}
		else
			return false
		end
	-- An open empty chest, or a bottom slab
	elseif minetest.get_item_group(nodename, "chest_open") > 0 or minetest.get_item_group(nodename, "slab") == 1 then
		-- Laser can go into it from above
		-- (this also works for slab because it cannot rotate)
		if dir.y < 0 then
			local def = minetest.registered_nodes[nodename]
			local active = def._lzr_active
			if active then
				-- Laser beam from above goes into chest/slab
				vdata[vi] = minetest.get_content_id(active.."_"..colorcode)
			end
		end
		-- Laser ends here
		return false

	-- Palm leaves or half cabinet or top slab
	elseif minetest.get_item_group(nodename, "palm_leaves") > 0 or minetest.get_item_group(nodename, "cabinet_half") > 0 or minetest.get_item_group(nodename, "slab") == 2 then
		-- Laser can go into it from below
		if dir.y > 0 then
			local def = minetest.registered_nodes[nodename]
			local active = def._lzr_active
			if active then
				-- Laser beam from below
				vdata[vi] = minetest.get_content_id(active.."_"..colorcode)
			end
		end
		-- Laser ends here
		return false

	-- Ship's wheel
	elseif minetest.get_item_group(nodename, "ships_wheel") > 0 then
		local front_dir = lzr_laser.get_front_dir(param2)
		-- Laser can go into it from the front
		if vector.equals(front_dir, vector.multiply(dir, -1)) then
			local def = minetest.registered_nodes[nodename]
			local active = def._lzr_active
			if active then
				-- Laser beam from the front
				vdata[vi] = minetest.get_content_id(active.."_"..colorcode)
			end
		end
		-- Laser ends here
		return false

	-- Anything else terminates the laser
	else
		return false
	end
end

-- Emit a laser from an emitter and starts laser propagation.
-- * `pos`: position of emitter
-- * `colorcode`: laser colorcode
-- * `varea`, `vdata`, `vdata_p2`: See `lzr_laser.add_laser`
-- * `emit_state`: Table that contains more complex info about the laser state (call-by-reference)
-- * `max_iterations`: Maximum number of allowed iterations before halting (optional)
function lzr_laser.emit_laser(pos, colorcode, varea, vdata, vdata_p2, emit_state, max_iterations)
	local vi = varea:indexp(pos)
	local content_id = vdata[vi]
	local nodename = minetest.get_name_from_content_id(content_id)
	if minetest.get_item_group(nodename, "emitter") == 0 then
		minetest.log("error", "[lzr_laser] lzr_laser.emit_laser was called at invalid pos!")
		return false
	end
	local param2 = vdata_p2[vi]

	local dir = minetest.facedir_to_dir(param2)
	dir = vector.multiply(dir, -1)
	local i_pos = vector.add(pos, dir)
	lzr_laser.travel_laser(i_pos, dir, colorcode, varea, vdata, vdata_p2, emit_state, max_iterations)
end

-- Spawns and propagates a single laser from `pos` step-by-step
-- until either all laser beams that have been created during
-- travel have terminated, or the algorithm took too many
-- iterations (which is probably an error pointing to an infinite
-- laser loop; or the map is just very complex).
-- * `pos`: position of laser
-- * `dir`: direction towards the laser will travel to initially
-- * `colorcode`: laser colorcode
-- * `varea`, `vdata`, `vdata_p2`: See `lzr_laser.add_laser`
-- * `emit_state`: Table that contains more complex info about the laser state (call-by-reference)
-- * `max_iterations`: Maximum number of allowed iterations before halting (optional)
function lzr_laser.travel_laser(pos, dir, colorcode, varea, vdata, vdata_p2, emit_state, max_iterations)
	local i_pos = table.copy(pos)
	-- This is a list of all currently "travelling lasers" that
	-- spawned from the initial laser. Each travelling laser
	-- has a position, direction and colorcode.
	-- This list initializes with a single position, from where we start.
	-- Each time add_laser is called, the travelling laser is removed, but
	-- any new travelling lasers returned from add_laser will be added to the
	-- list.
	-- This essentially is a breadth-first search.
	local next_lasers = {{i_pos, dir, colorcode}}

	local custom_max_iterations = false
	if not max_iterations then
		max_iterations = MAX_LASER_ITERATIONS
	else
		custom_max_iterations = true
	end

	local i = 0
	while true do
		i = i + 1
		-- Halt execution for very long loops to prevent freezing the game
		if i > max_iterations then
			if not custom_max_iterations then
				minetest.log("error", "[lzr_laser] lzr_laser.travel_laser aborted (too many iterations!)")
			end
			for n=1, #next_lasers do
				local next_laser = next_lasers[n]
				local tex
				if n == 1 then
					tex = "lzr_laser_laser_end_debug.png"
				else
					tex = "lzr_laser_laser_end_debug_extra.png"
				end
				minetest.add_particle({
					pos = next_laser[1],
					glow = minetest.LIGHT_MAX,
					size = 2,
					texture = tex,
					expirationtime = 1,
				})
			end
			break
		end

		-- Get next laser and propagate it by one step
		local next_laser = next_lasers[1]
		local add_laser_result = lzr_laser.add_laser(next_laser[1], next_laser[2], next_laser[3], varea, vdata, vdata_p2, emit_state)

		table.remove(next_lasers, 1)
		if add_laser_result ~= false then
			local result_type = add_laser_result[1]
			-- Propagate lasers
			if result_type == "laser" then
				for a=2, #add_laser_result do
					table.insert(next_lasers, add_laser_result[a])
				end
			-- Laser reached out of bounds
			elseif result_type == "laser_out_of_bounds" then
				table.insert(emit_state.out_of_bounds, {pos=add_laser_result[2], dir=add_laser_result[3], colorcode=add_laser_result[4]})
			-- Colored detector hit
			elseif result_type == "detected" then
				local poshash = minetest.hash_node_position(add_laser_result[2])
				table.insert(emit_state.detections, {hash = poshash, colorcode = add_laser_result[3]})
			end
		end
		-- When the table is empty, this means all travelling lasers have terminated. Success!
		if #next_lasers == 0 then
			break
		end
	end
end

-- Remove all out-of-bounds lasers.
-- Since the starting positions and directions of out-of-bounds lasers are stored in a
-- variable, no arguments are needed.
-- NOTE: Unlike the other laser functions, this function acts on the map directly,
-- it does NOT work on a LuaVoxelManip.
-- This function MUST NOT be called in the middle of LuaVoxelManip operations.
function lzr_laser.clear_out_of_bounds_lasers(kept_lasers)
	local clear_oob_lasers = {}
	local clear_barriers = {}
	local destroyed_lasers = {}
	for hash, oob in pairs(out_of_bounds_lasers) do
		local hash = minetest.hash_node_position(oob.pos)
		if not kept_lasers or not kept_lasers[hash] then
			local oob_lasers, barrier_pos = lzr_laser.travel_laser_out_of_bounds(oob.pos, oob.dir, oob.colorcode)
			table.insert_all(clear_oob_lasers, oob_lasers)
			destroyed_lasers[hash] = true
			if barrier_pos then
				table.insert(clear_barriers, barrier_pos)
			end
			out_of_bounds_lasers[hash] = nil
		end
	end
	if #clear_oob_lasers > 0 then
		minetest.bulk_set_node(clear_oob_lasers, { name = "air" })
	end
	for b=1, #clear_barriers do
		local bpos = clear_barriers[b]
		local node = minetest.get_node(bpos)
		if minetest.get_item_group(node.name, "rain_membrane") > 0 then
			minetest.set_node(bpos, { name = "lzr_core:rain_membrane" })
		elseif minetest.get_item_group(node.name, "barrier") > 0 then
			minetest.set_node(bpos, { name = "lzr_core:barrier" })
		end
	end

	-- Restore 'laser_destroys=1' nodes (mostly plants)
	-- after the lasers were cleared
	local restoreds = {}
	for d=1, #out_of_bounds_destroyeds do
		local destr = out_of_bounds_destroyeds[d]
		local hash = minetest.hash_node_position(destr.start_pos)
		if destroyed_lasers[hash] then
			minetest.set_node(destr.pos, destr.node)
			table.insert(restoreds, d)
		end
	end
	for r=#restoreds, 1, -1 do
		table.remove(out_of_bounds_destroyeds, restoreds[r])
	end
end

-- Remove all lasers in the given area and disable all laser blocks
-- (e.g. mirrors, detectors)
-- * pos1: Minimum position of area
-- * pos2: Maximum position of area
-- * varea, vdata: See lzr_laser.add_laser
function lzr_laser.clear_lasers_in_area(pos1, pos2, varea, vdata)
	for z=pos1.z, pos2.z do
	for y=pos1.y, pos2.y do
	for x=pos1.x, pos2.x do
		local vi = varea:indexp({x=x,y=y,z=z})
		local cid = vdata[vi]
		local nodename = minetest.get_name_from_content_id(cid)
		if minetest.get_item_group(nodename, "laser") ~= 0 then
			vdata[vi] = minetest.CONTENT_AIR
		elseif minetest.get_item_group(nodename, "laser_block") ~= 0 then
			local def = minetest.registered_nodes[nodename]
			local is_ignored_node =
				minetest.get_item_group(nodename, "emitter") > 0 or
				minetest.get_item_group(nodename, "bomb") > 0 or
				minetest.get_item_group(nodename, "barricade") > 0
			if def and not is_ignored_node then
				local inactive = def._lzr_inactive
				if inactive then
					vdata[vi] = minetest.get_content_id(inactive)
				end
			end
		end
	end
	end
	end
end

-- Emit lasers from all *active* emitters in area.
-- * pos1: Minimum position of area
-- * pos2: Maximum position of area
-- * ignore_emitters
-- * varea, vdata, vdata_p2: See lzr_laser.add_laser
-- * emit_state: Table that contains more complex info about the laser state (call-by-reference)
-- * max_iterations: Maximum number of allowed iterations before halting (optional)
function lzr_laser.emit_lasers_in_area(pos1, pos2, varea, vdata, vdata_p2, emit_state, max_iterations)
	local emitters = minetest.find_nodes_in_area(pos1, pos2, {"group:emitter"})
	for e=1, #emitters do
		local epos = emitters[e]
		local vi = varea:indexp(epos)
		local emitter_cid = vdata[vi]
		local emittername = minetest.get_name_from_content_id(emitter_cid)
		local is_active = minetest.get_item_group(emittername, "emitter") == 2
		local colorcode = minetest.get_item_group(emittername, "emitter_color")
		if is_active and colorcode ~= 0 then
			lzr_laser.emit_laser(emitters[e], colorcode, varea, vdata, vdata_p2, emit_state, max_iterations)
		end
	end
end

-- Returns true if there are no unclaimed treasures remaining in area
function lzr_laser.check_treasures_in_area(pos1, pos2)
	local closed_chests = minetest.find_nodes_in_area(pos1, pos2, {"group:chest_closed"})
	return #closed_chests > 0
end

-- Returns the number of treasures found in current level in area
function lzr_laser.count_found_treasures(pos1, pos2)
	return #minetest.find_nodes_in_area(pos1, pos2, {"group:chest_open_treasure"})
end

-- Returns true if current level is won
function lzr_laser.check_level_won()
	local minpos, maxpos = lzr_world.get_level_bounds()
	return not lzr_laser.check_treasures_in_area(minpos, maxpos)
end

-- Returns a table of all detector states in the given area,
-- indexed by the VoxelArea index of a VoxelManip and the value
-- being either true for active, false for inactive and nil
-- for any position without a detector.
local function get_detector_states_in_area(pos1, pos2, varea, vdata)
	local states = {}
	for vi=1, #vdata do
		local cid = vdata[vi]
		local nodename = minetest.get_name_from_content_id(cid)
		local dstate = minetest.get_item_group(nodename, "detector")
		if dstate == 1 then
			states[vi] = false
		elseif dstate == 2 then
			states[vi] = true
		end
	end
	return states
end
local function get_sender_states_in_area(pos1, pos2, varea, vdata)
	local states = {}
	for vi=1, #vdata do
		local cid = vdata[vi]
		local nodename = minetest.get_name_from_content_id(cid)
		if minetest.get_item_group(nodename, "sender") ~= 0 then
			local def = minetest.registered_nodes[nodename]
			if def then
				local groupname = def._lzr_element_group
				if groupname then
					local dstate = minetest.get_item_group(nodename, groupname)
					if dstate == 1 then
						states[vi] = false
					elseif dstate == 2 then
						states[vi] = true
					end
				end
			end
		end
	end
	return states
end

-- Recalculate all lasers in area.
-- * pos1: Minimum position of area
-- * pos2: Maximum position of area
-- * force_update: If true, will recalculate lasers even if lasers are frozen (default: false)
-- * clear_first: If true, will clear the lasers first (default: true)
-- * max_iterations: Maximum number of allowed iterations before halting (optional)
-- * extra_state: Table that contains more complex info about the laser state (optional)
local function laser_update(pos1, pos2, force_update, clear_first, max_iterations, extra_state)
	if lzr_laser.get_lasers_frozen() and (force_update ~= true) then
		minetest.log("info", "[lzr_laser] laser_update skipped (lasers are frozen)")
		return
	end
	local benchmark_time_1 = minetest.get_us_time()

	local vmanip = minetest.get_voxel_manip(pos1, pos2)
	local vpos1, vpos2 = vmanip:get_emerged_area()
	local varea = VoxelArea:new({MinEdge = vpos1, MaxEdge = vpos2})
	local vdata = vmanip:get_data()
	local vdata_p2 = vmanip:get_param2_data()

	-- Remember the old state of the map and the state of
	-- detectors, then compare it to the new detector state.
	-- Used for the detector sound effect.
	local detector_states_old = get_detector_states_in_area(pos1, pos2, varea, vdata)
	-- Same for senders
	local sender_states_old = get_sender_states_in_area(pos1, pos2, varea, vdata)

	if extra_state then
		-- When an active sender was removed, we remember its pos
		-- to play the proper "disable" sound effect.
		if extra_state.removed_active_sender_pos then
			local vi = varea:indexp(extra_state.removed_active_sender_pos)
			sender_states_old[vi] = true
		end
		-- When a node was rotated by the hook, it deferrs the
		-- node update to the VoxelManip
		if extra_state.rotated_pos then
			if varea:containsp(extra_state.rotated_pos) then
				-- This is like minetest.swap_node, but in VManip
				local idx = varea:indexp(extra_state.rotated_pos)
				vdata[idx] = minetest.get_content_id(extra_state.rotated_node.name)
				vdata_p2[idx] = extra_state.rotated_node.param2
			else
				minetest.log("error", "[lzr_laser] extra_state.rotated_pos is out of VManip bounds!")
			end
		end
		-- When a lever was activated
		if extra_state.lever_pos_activate then
			local vi = varea:indexp(extra_state.lever_pos_activate)
			sender_states_old[vi] = false
		end
		-- When a lever was deactivated
		if extra_state.lever_pos_deactivate then
			local vi = varea:indexp(extra_state.lever_pos_deactivate)
			sender_states_old[vi] = true
		end
	end

	-- << THE MAIN LASER UPDATE HAPPENS HERE >> --

	-- Step 1: Remove all lasers and deactivate all laser blocks
	-- Step 2: Emit lasers from all emitters, updating
	--         lasers and laser blocks in the process, except
	--         colored detectors.
	-- Step 3: Handle burning cache
	-- Step 4: Activate colored detectors that have been
	--	   hit by the right color.

	-- step 1
	if clear_first ~= false then
		lzr_laser.clear_lasers_in_area(pos1, pos2, varea, vdata)
	end

	-- step 2
	local emit_state = {
		out_of_bounds = {},
		-- detections table stores which colored detectors have been
		-- hit and by which color. Necessary because we can only
		-- update colored detector state *after* all lasers have
		-- travelled.
		detections = {},
		-- list of outgoing lasers at crystals (all variants)
		-- each entry is a table with the fields:
		-- * pos: crystal position
		-- * out: { dir = <direction of outgoing laser>, colorcode = <colorcode of outgoing laser> }
		crystal_outgoing = {},
		burning_cache = {},
		destroy_cache = {},
	}
	lzr_laser.emit_lasers_in_area(pos1, pos2, varea, vdata, vdata_p2, emit_state, max_iterations)

	-- step 3
	-- Trigger node burning for nodes burned by laser

	local burning_barricades = {}
	local exploding_bombs = {}
	for b=1, #emit_state.burning_cache do
		local vi = emit_state.burning_cache[b]
		local cid = vdata[vi]
		local nodename = minetest.get_name_from_content_id(cid)
		if minetest.get_item_group(nodename, "barricade") == 2 then
			table.insert(burning_barricades, varea:position(vi))
		elseif minetest.get_item_group(nodename, "bomb") == 2 then
			table.insert(exploding_bombs, varea:position(vi))
		end
	end
	if #burning_barricades > 0 then
		local added = false
		local burn_time = BARRICADE_BURN_TIME
		if lzr_gamestate.get_state() == lzr_gamestate.LEVEL_TEST then
			burn_time = burn_time * lzr_globals.LEVEL_TEST_TIME_MULTIPLIER
		end
		local burn_destroy_time = minetest.get_us_time() + burn_time * 1000000
		for d=1, #destroy_events do
			if destroy_events[d].time == burn_destroy_time then
				table.insert_all(destroy_events[d].positions, burning_barricades)
				added = true
				break
			end
		end
		if not added then
			table.insert(destroy_events, {
				time = burn_destroy_time,
				positions = burning_barricades,
			})
		end
	end
	if #exploding_bombs > 0 then
		local added = false
		local burn_time = BOMB_BURN_TIME
		if lzr_gamestate.get_state() == lzr_gamestate.LEVEL_TEST then
			burn_time = burn_time * lzr_globals.LEVEL_TEST_TIME_MULTIPLIER
		end
		local burn_destroy_time = minetest.get_us_time() + burn_time * 1000000
		for d=1, #destroy_events do
			if destroy_events[d].time == burn_destroy_time then
				table.insert_all(destroy_events[d].positions, exploding_bombs)
				added = true
				break
			end
		end
		if not added then
			table.insert(destroy_events, {
				time = burn_destroy_time,
				positions = exploding_bombs,
			})
		end
	end

	-- step 4
	-- Check and update colored detectors.
	-- (colorless detectors are already dealt with)
	local combined_incoming_colors = {}
	for d=1, #emit_state.detections do
		-- Iterate through all affected colored detectos
		-- and combine the incoming travelling laser colors
		-- When this loop is complete, we have a list of the
		-- *actual* final laser color that hit the detector.
		local detection = emit_state.detections[d]
		local hash = detection.hash
		local colorcode = detection.colorcode
		if combined_incoming_colors[hash] then
			-- colors can be OR'ed
			combined_incoming_colors[hash] = bit.bor(combined_incoming_colors[hash], colorcode)
		else
			combined_incoming_colors[hash] = colorcode
		end
	end


	-- Check which colored detectors match the incoming laser
	-- color, then activate those
	for hash, laser_color in pairs(combined_incoming_colors) do
		local pos = minetest.get_position_from_hash(hash)
		local vi = varea:indexp(pos)
		local content_id = vdata[vi]
		local nodename = minetest.get_name_from_content_id(content_id)
		local detector_color = minetest.get_item_group(nodename, "detector_color")
		-- Only an exact color match counts
		if laser_color == detector_color then
			local def = minetest.registered_nodes[nodename]
			local active = def._lzr_active
			if active then
				vdata[vi] = minetest.get_content_id(active)
			end
		end
	end

	-- << END OF THE MAIN LASER UPDATE >> --

	-- Required for the detector sound effect.
	local detector_states_new = get_detector_states_in_area(pos1, pos2, varea, vdata)

	local sender_states_new = get_sender_states_in_area(pos1, pos2, varea, vdata)

	-- Write laser changes to map
	vmanip:set_data(vdata)
	vmanip:set_param2_data(vdata_p2)
	vmanip:write_to_map()

	-- Post-map update changes for stuff that the VManip can't do

	-- <<< OUT-OF-BOUNDS LASERS >>>
	-- Propagate the out-of-bounds lasers.
	-- These are lasers that extend out of the level bounds.
	-- This changes nodes, but doesn't have a gameplay effect, as these
	-- are outside the level.
	-- The start positions and directions of all out-of bounds lasers will be stored
	-- in the out_of_bounds_lasers variable to simplify later removal.

	-- First step: Mix laser colors of out-of-bounds lasers that start at the same position.
	local real_out_of_bounds = {}
	local real_out_of_bounds_hashes = {}
	for o=1, #emit_state.out_of_bounds do
		local oob = emit_state.out_of_bounds[o]
		local hash = minetest.hash_node_position(oob.pos)
		if real_out_of_bounds[hash] then
			real_out_of_bounds[hash].colorcode = bit.bor(real_out_of_bounds[hash].colorcode, oob.colorcode)
		else
			real_out_of_bounds[hash] = table.copy(oob)
			table.insert(real_out_of_bounds_hashes, hash)
		end
	end
	-- Second step: Place the out-of-bounds laser nodes
	local confirmed_out_of_bounds_lasers = {}
	local barrier_posses = {}
	for o=1, #real_out_of_bounds_hashes do
		local hash = real_out_of_bounds_hashes[o]
		local oob = real_out_of_bounds[hash]
		local oob_lasers, barrier_pos, destroyeds, laser_node = lzr_laser.travel_laser_out_of_bounds(oob.pos, oob.dir, oob.colorcode)
		table.insert_all(out_of_bounds_destroyeds, destroyeds)
		if out_of_bounds_lasers[hash] and out_of_bounds_lasers[hash].colorcode == oob.colorcode then
			-- Out-of-bounds laser with same color already exists, so we don't have to re-add it
			confirmed_out_of_bounds_lasers[hash] = true
		else
			out_of_bounds_lasers[hash] = { pos = oob.pos, dir = oob.dir, colorcode = oob.colorcode}
			confirmed_out_of_bounds_lasers[hash] = true
			-- Unlike the lasers inside the level, out-of-bounds lasers do NOT use the LuaVoxelManip.
			-- They are bulk-set insteaed.
			-- This is because the LuaVoxelManip is only covering the level bounds and a few
			-- nodes beyond, and out-of-bounds lasers are LONG. If we would handle
			-- out-of-bounds lasers in the LuaVoxelManip, we'd have to massively increase
			-- the size of its area which would then increase loading and writing times.
			minetest.bulk_set_node(oob_lasers, laser_node)

			if barrier_pos then
				-- Penetrate barriers and rain membranes by replacing those
				-- with a special barrier / rain membrane + laser combination node
				local dirs = lzr_laser.vector_and_color_to_dirs(oob.dir, oob.colorcode)
				local dirstring = lzr_laser.dirs_to_dirstring(dirs)
				local bnode = minetest.get_node(barrier_pos)
				if minetest.get_item_group(bnode.name, "rain_membrane") > 0 then
					minetest.set_node(barrier_pos, {name="lzr_laser:rain_membrane_laser_"..dirstring})
				elseif minetest.get_item_group(bnode.name, "barrier") > 0 then
					minetest.set_node(barrier_pos, {name="lzr_laser:barrier_laser_"..dirstring})
				end
			end
		end
	end

	-- Third step: Remove all no-longer valid out-of-bounds lasers
	lzr_laser.clear_out_of_bounds_lasers(confirmed_out_of_bounds_lasers)

	-- <<< END OF OUT-OF-BOUNDS LASERS CODE >>>


	-- <<< HANDLE TRIGGERS >>> --

	-- Trigger receivers for all senders that changed state
	for vindex, state_old in pairs(sender_states_old) do
		local state_new = sender_states_new[vindex]
		local pos = varea:position(vindex)
		local activate, deactivate, node_removed = false, false, false
		if (state_new == true and state_old == false) then
			activate = true
		elseif (state_new == false and state_old == true) then
			deactivate = true
		elseif (state_new == nil and state_old == true) then
			deactivate = true
			if (not (extra_state and (extra_state.lever_pos_activate or extra_state.lever_pos_deactivate))) then
				node_removed = true
			end
		end
		if activate or deactivate then
			if activate then
				minetest.log("info", "[lzr_laser] Sender activates at: "..minetest.pos_to_string(pos))
			else
				minetest.log("info", "[lzr_laser] Sender deactivates at: "..minetest.pos_to_string(pos))
			end
			local trigger_id
			if node_removed then
				trigger_id = extra_state.removed_active_sender_trigger_id
			else
				local tmeta = minetest.get_meta(pos)
				trigger_id = tmeta:get_string("trigger_id")
			end
			if trigger_id == nil or trigger_id == "" then
				-- A state change of a trigger node that has no trigger_id is an error,
				-- unless we're not in an active game.
				local gs = lzr_gamestate.get_state()
				if (gs == lzr_gamestate.LEVEL or gs == lzr_gamestate.LEVEL_TEST) then
					minetest.log("error", "[lzr_laser] Node at "..minetest.pos_to_string(pos).." should have a trigger_id but doesn't have one!")
				end
				break
			end
			local trigger = lzr_triggers.get_trigger(trigger_id)
			if not trigger then
				minetest.log("error", "[lzr_laser] Trigger '"..trigger_id.."' does not exist!")
				break
			end
			local receivers, sender_type
			if not node_removed then
				receivers = lzr_triggers.get_receivers(trigger_id)
				sender_type = trigger.sender_type
			else
				receivers = extra_state.removed_active_sender_send_to
				sender_type = extra_state.removed_active_sender_sender_type
			end
			if not receivers then
				receivers = {}
			end
			for r=1, #receivers do
				local receiver_id = receivers[r]
				local receiver_trigger = lzr_triggers.get_trigger(receiver_id)
				-- Get the trigger location (the node may either be in the map
				-- or player inventory. If it's at neither location, that's an error)
				local rloc = receiver_trigger.location
				local rtype = receiver_trigger.receiver_type
				local rname, rdef, toggle_func_name
				local rpos, rnode
				local ritem, rslot

				local player = minetest.get_player_by_name("singleplayer")
				if rloc == "player" then
					-- Send signal to node in player inventory
					if player then
						ritem, rslot = lzr_triggers.find_trigger_in_player_inventory(player, receiver_id)
						if ritem then
							rname = ritem:get_name()
							rdef = minetest.registered_nodes[rname]
							toggle_func_name = "_lzr_on_toggle_item"
						end
					end
				elseif type(rloc) == "table" then
					-- Send signal to node in map
					rpos = rloc
					rnode = minetest.get_node(rpos)
					rdef = minetest.registered_nodes[rnode.name]
					rname = rnode.name
					toggle_func_name = "_lzr_on_toggle"
				end

				if rdef and toggle_func_name and rdef[toggle_func_name] then

					local receiver_active
					local lb_group = minetest.get_item_group(rname, "lightbox")
					if lb_group > 0 then
						receiver_active = lb_group == 2
					else
						receiver_active = lzr_laser.is_laser_block_active(rname)
					end
					local signal = "NONE"
					-- Send signal state equivalent to our active status
					if sender_type == lzr_triggers.SENDER_TYPE_SYNC then
						if activate then
							signal = "ON"
						else
							signal = "OFF"
						end
					-- Send signal state inverse to our active status
					elseif sender_type == lzr_triggers.SENDER_TYPE_SYNC_INV then
						if deactivate then
							signal = "ON"
						else
							signal = "OFF"
						end
					-- Send TOGGLE signal on every toggle
					elseif sender_type == lzr_triggers.SENDER_TYPE_TOGGLE then
						signal = "TOGGLE"
					-- Send ON signal on every toggle
					elseif sender_type == lzr_triggers.SENDER_TYPE_TOGGLE_ON then
						signal = "ON"
					-- Send OFF signal on every toggle
					elseif sender_type == lzr_triggers.SENDER_TYPE_TOGGLE_OFF then
						signal = "OFF"
					-- Send ON signal when we activate
					elseif sender_type == lzr_triggers.SENDER_TYPE_ACTIVATE_ON then
						if activate then
							signal = "ON"
						end
					-- Send TOGGLE signal when we activate
					elseif sender_type == lzr_triggers.SENDER_TYPE_ACTIVATE_TOGGLE then
						if activate then
							signal = "TOGGLE"
						end
					-- Send OFF signal when we activate
					elseif sender_type == lzr_triggers.SENDER_TYPE_ACTIVATE_OFF then
						if activate then
							signal = "OFF"
						end
					-- Send ON signal when we deactivate
					elseif sender_type == lzr_triggers.SENDER_TYPE_DEACTIVATE_ON then
						if deactivate then
							signal = "ON"
						end
					-- Send TOGGLE signal when we deactivate
					elseif sender_type == lzr_triggers.SENDER_TYPE_DEACTIVATE_TOGGLE then
						if deactivate then
							signal = "TOGGLE"
						end
					-- Send OFF signal when we deactivate
					elseif sender_type == lzr_triggers.SENDER_TYPE_DEACTIVATE_OFF then
						if deactivate then
							signal = "OFF"
						end
					else
						minetest.log("error", "[lzr_laser] Invalid sender type for sender '"..trigger_id.."': "..tostring(sender_type))
					end

					-- It is possible a receiver may get triggered many times in the same tick.
					-- This is very likely due to a recursive call when the lasers and triggers are
					-- arranged in such a way that a receiver manages to invoke itself again and
					-- again, forever. This is called "recursive re-activation".
					-- This would cause a stack overflow, so we limit the amount of
					-- times a receiver can be triggered in the same tick. If a certain threshold is
					-- exceeded, the receiver shuts down and refuses to be triggered in this tick
					-- again.
					local check_receiver_overload = function(receiver_id)
						-- Recently touched receivers counts how many times each receivers was
						-- "touched" (i.e. toggled) in this tick.
						if not recently_touched_receivers[receiver_id] then
							recently_touched_receivers[receiver_id] = 1
							return false
						else
							recently_touched_receivers[receiver_id] = recently_touched_receivers[receiver_id] + 1
						end
						if recently_touched_receivers[receiver_id] >= lzr_globals.MAX_RECEIVER_RECALLS then
							return true
						else
							return false
						end
					end

					local particle_line = function(pos1, pos2, signal)
						local tex
						if signal == "ON" then
							lzr_laser.particle_line(pos1, pos2, "lzr_laser_particle_signal_on.png")
						elseif signal == "OFF" then
							lzr_laser.particle_line(pos1, pos2, "lzr_laser_particle_signal_off.png")
						elseif signal == "TOGGLE" then
							lzr_laser.particle_line(pos1, pos2, "lzr_laser_particle_signal_toggle.png")
						end
					end


					-- Send signal from sender to receiver.
					-- This means: The receiver's _on_toggle function is called
					-- when the signal would cause a state change. This also
					-- will spawn some fancy particle lines to make the
					-- signal visible to the player so the trigger relations
					-- can be followed more easily.
					local send_signal = function(signal)
						local toggle_receiver = false
						if signal == "ON" and not receiver_active then
							toggle_receiver = true
						elseif signal == "OFF" and receiver_active then
							toggle_receiver = true
						elseif signal == "TOGGLE" then
							toggle_receiver = true
						end

						if toggle_receiver and check_receiver_overload(receiver_id) then
							-- If the receiver is overloaded in this, we prevent triggering it on again,
							-- but we allow to shut down one last time.
							if not receiver_active then
								minetest.log("action", "[lzr_laser] Receiver '"..receiver_id.."' shuts down due to recursive re-activation")
								-- Add fancy smoke particles at the node that just shut down
								minetest.add_particlespawner({
									amount = 8,
									time = 0.1,
									minpos = vector.add(rpos, vector.new(-0.4, 0.1, -0.4)),
									maxpos = vector.add(rpos, vector.new(0.4, 0.45, 0.4)),
									minvel = vector.new(-0.1, 0.4, -0.1),
									maxvel = vector.new(0.1, 0.5, 0.1),
									minsize = 4,
									maxsize = 5,
									minexptime = 2,
									maxexptime = 2.85,
									texture = "lzr_laser_overload_smoke.png",
								})
								return
							end
						end
						if toggle_func_name == "_lzr_on_toggle" then
							particle_line(rloc, pos, signal)
							if toggle_receiver then
								rdef._lzr_on_toggle(rloc, rnode)
							end
							minetest.log("info", "[lzr_laser] Sent "..signal.." signal from '"..trigger_id.."' to '"..receiver_id.."'")
						elseif toggle_func_name == "_lzr_on_toggle_item" then
							if player and ritem and rslot then
								local ppos = player:get_pos()
								ppos = vector.offset(ppos, 0, 1, 0)
								particle_line(ppos, pos, signal)
								if toggle_receiver then
									local new_item = rdef._lzr_on_toggle_item(player, ritem, rslot)
									player:get_inventory():set_stack("main", rslot, new_item)
								end
								minetest.log("info", "[lzr_laser] Sent "..signal.." signal from '"..trigger_id.."' to '"..receiver_id.."' (in player inventory)")
							else
								minetest.log("error", "[lzr_laser] Failed to find receiver '"..receiver_id.." in player inventory for "..signal.." from '"..trigger_id.."'!")
							end
						end
					end

					-- Send the signal

					-- The simplest type: Just trigger directly
					if rtype == lzr_triggers.RECEIVER_TYPE_ANY then
						send_signal(signal)
					-- Basically a logical AND
					elseif rtype == lzr_triggers.RECEIVER_TYPE_SYNC_AND then
						local my_senders = lzr_triggers.get_senders(receiver_id)
						local all_on = true
						for s=1, #my_senders do
							local ms_id = my_senders[s]
							local ms_trigger = lzr_triggers.get_trigger(ms_id)
							local ms_sender_type = ms_trigger.sender_type
							if ms_sender_type == lzr_triggers.SENDER_TYPE_SYNC or ms_sender_type == lzr_triggers.SENDER_TYPE_SYNC_INV then
								local ms_is_active = false
								local location = ms_trigger.location
								if type(location) == "table" then
									local ms_node = minetest.get_node(location)
									ms_is_active = lzr_laser.is_laser_block_active(ms_node.name)
								elseif location == "string" then
									local item = lzr_triggers.find_trigger_in_player_inventory(player, ms_id)
									if item then
										ms_is_active = lzr_laser.is_laser_block_active(item:get_name())
									end
								end
								-- The state of senders with this sender type must be inverted for the all_on check
								if ms_sender_type == lzr_triggers.SENDER_TYPE_SYNC_INV then
									ms_is_active = not ms_is_active
								end
								if not ms_is_active then
									all_on = false
									break
								end
							end
						end
						if all_on then
							send_signal("ON")
						else
							send_signal("OFF")
						end
					else
						minetest.log("error", "[lzr_laser] Unknown receiver type: "..tostring(rtype))
					end
				end
			end
		end
	end
	-- <<< END OF TRIGGER HANDLING >>> --

	-- Play detector sound for all detectors that have changed their state
	for vindex, state_new in pairs(detector_states_new) do
		local state_old = detector_states_old[vindex]
		local pos = varea:position(vindex)
		if state_new == true and state_old == false then
			minetest.sound_play({name="lzr_laser_detector_activate", gain=0.7}, {pos=pos}, true)
		elseif state_new == false and state_old == true then
			minetest.sound_play({name="lzr_laser_detector_deactivate", gain=0.7}, {pos=pos}, true)
		end
	end

	-- Trigger burning sound for nodes burned by laser
	for b=1, math.min(#emit_state.burning_cache, lzr_globals.MAX_DESTROY_SOUNDS_AT_ONCE) do
		local bpos = varea:position(emit_state.burning_cache[b])
		local bnode = minetest.get_node(bpos)
		if minetest.get_item_group(bnode.name, "barricade") ~= 0 then
			minetest.sound_play({name="lzr_laser_quickburn", gain=1.0}, {pos=bpos}, true)
		elseif minetest.get_item_group(bnode.name, "bomb") ~= 0 then
			minetest.sound_play({name="lzr_laser_bomb_fuse", gain=0.5}, {pos=bpos}, true)
			lzr_laser.spawn_bomb_fuse_particles(bpos, BOMB_BURN_TIME)
		end
	end

	-- Destroy nodes in destroy_cache (nodes destroyed by laser)
	-- and trigger animation and sound effects
	for d=1, #emit_state.destroy_cache do
		local dpos = emit_state.destroy_cache[d].pos
		local nodename = emit_state.destroy_cache[d].nodename
		local def = minetest.registered_nodes[nodename]
		if def and def.sounds and def.sounds.dug then
			minetest.sound_play(def.sounds.dug, {pos=dpos}, true)
		end
		minetest.add_particlespawner({
			amount = 12,
			time = 0.001,
			minpos = vector.subtract(dpos, vector.new(0.5, 0.5, 0.5)),
			maxpos = vector.add(dpos, vector.new(0.5, 0.5, 0.5)),
			minvel = vector.new(-0.5, -0.2, -0.5),
			maxvel = vector.new(0.5, 0.2, 0.5),
			minacc = vector.new(0, -lzr_globals.GRAVITY, 0),
			maxacc = vector.new(0, -lzr_globals.GRAVITY, 0),
			minsize = 0.8,
			maxsize = 0.8,
			minexptime = 0.5,
			maxexptime = 0.55,
			node = {name=nodename},
		})
	end

	-- Print benchmark time
	local benchmark_time_2 = minetest.get_us_time()
	local diff = benchmark_time_2 - benchmark_time_1
	minetest.log("info", "[lzr_laser] laser_update took "..diff.." µs.")
end

-- Completely recalculate all lasers in area.
-- * pos1: Minimum position of area (optional, defaults to level start pos)
-- * pos2: Maximum position of area (optional, defaults to level end pos)
-- * extra_state: Info about previous level state
function lzr_laser.full_laser_update(pos1, pos2, extra_state)
	if not pos1 or not pos2 then
		pos1, pos2 = lzr_world.get_level_bounds()
	end
	laser_update(pos1, pos2, nil, nil, nil, extra_state)
end

-- Freezes the laser simulation. While frozen, lasers will
-- no longer update automatically on map changes.
function lzr_laser.freeze_lasers()
	lasers_frozen = true
end

-- Unfreezes the laser simulation. Lasers will
-- update automatically again.
function lzr_laser.unfreeze_lasers()
	lasers_frozen = false
end

-- Get current laser frozen state
function lzr_laser.get_lasers_frozen()
	-- lasers are always frozen in dev mode
	return lasers_frozen or lzr_gamestate.get_state() == lzr_gamestate.DEV
end

-- Several commands for debugging the lasers.
-- Only available with a hidden setting.
if minetest.settings:get_bool("lzr_debug", false) then

	minetest.register_chatcommand("set_freeze_lasers", {
		description = S("Enable or disable frozen lasers. When lasers are frozen, they won’t be updated automatically. Useful for debugging"),
		privs = { debug = true, server = true },
		params = "[on | off]",
		func = function(player, param)
			if param == "" then
				lasers_frozen = not lasers_frozen
			elseif param == "on" then
				lasers_frozen = true
			elseif param == "off" then
				lasers_frozen = false
			else
				return false
			end
			if lasers_frozen then
				return true, S("Lasers are now frozen. Map updates will no longer update the lasers.")
			else
				laser_update(lzr_world.get_level_pos(), vector.add(lzr_world.get_level_pos(), lzr_world.get_level_size()))
				return true, S("Lasers are now unfrozen. Map updates will update the lasers again.")
			end
		end,
	})

	minetest.register_chatcommand("force_laser_update", {
		description = S("Force a full laser update to occur in the current level boundaries"),
		privs = { debug = true, server = true },
		params = "",
		func = function(player, param)
			local minpos, maxpos = lzr_world.get_level_pos(), vector.add(lzr_world.get_level_pos(), lzr_world.get_level_size())
			laser_update(minpos, maxpos, true)
			return true
		end,
	})

	minetest.register_chatcommand("emit_lasers", {
		description = S("Emit lasers from all emitters in the current level boundaries"),
		privs = { debug = true, server = true },
		params = S("[<max. iterations>]"),
		func = function(player, param)
			local minpos, maxpos = lzr_world.get_level_pos(), vector.add(lzr_world.get_level_pos(), lzr_world.get_level_size())
			local max_iterations
			local num = tonumber(param)
			if num then
				max_iterations = math.floor(math.max(0, num))
			end
			laser_update(minpos, maxpos, true, false, max_iterations)
			return true
		end,
	})

	minetest.register_chatcommand("clear_lasers", {
		description = S("Remove all lasers in the current level boundaries and the current out-of-bounds lasers"),
		privs = { debug = true, server = true },
		params = "",
		func = function(player, param)
			local minpos, maxpos = lzr_world.get_level_pos(), vector.add(lzr_world.get_level_pos(), lzr_world.get_level_size())
			local vmanip = minetest.get_voxel_manip(minpos, maxpos)
			local vpos1, vpos2 = vmanip:get_emerged_area()
			local varea = VoxelArea:new({MinEdge = vpos1, MaxEdge = vpos2})
			local vdata = vmanip:get_data()
			lzr_laser.clear_lasers_in_area(minpos, maxpos, varea, vdata)

			-- Write laser changes to map
			vmanip:set_data(vdata)
			vmanip:write_to_map()

			lzr_laser.clear_out_of_bounds_lasers()

			return true
		end,
	})

end

lzr_laser.reset_destroy_events = function()
	destroy_events = {}
end

local handle_delayed_node_destructions = function()
	-- Remove burning barricades, propagate fire
	-- and explode bombs
	if lzr_laser.get_lasers_frozen() or #destroy_events == 0 then
		return
	end
	local time = minetest.get_us_time()
	local events_to_remove = {}
	local barricades_to_burn = {}
	local bombs_to_explode = {}
	for d=1, #destroy_events do
		local evnt = destroy_events[d]
		if evnt.time <= time then
			for n=1, #evnt.positions do
				local node = minetest.get_node(evnt.positions[n])
				if minetest.get_item_group(node.name, "barricade") == 2 then
					table.insert(barricades_to_burn, evnt.positions[n])
				elseif minetest.get_item_group(node.name, "bomb") == 2 then
					table.insert(bombs_to_explode, evnt.positions[n])
				end
			end
			table.insert(events_to_remove, d)
		end
	end
	if #barricades_to_burn == 0 and #bombs_to_explode == 0 then
		return
	end

	for t=#events_to_remove, 1 do
		table.remove(destroy_events, events_to_remove[t])
	end

	local new_positions = lzr_laser.burn_and_destroy({barricades=barricades_to_burn, bombs=bombs_to_explode})

	local new_position_types = {
		{ name = "barricades", burn_time = BARRICADE_BURN_TIME },
		{ name = "bombs_slow", burn_time = BOMB_BURN_TIME },
		{ name = "bombs_quick", burn_time = BOMB_BURN_TIME_QUICK },
	}

	for n=1, #new_position_types do
		local ptype = new_position_types[n]
		if #new_positions[ptype.name] > 0 then
			local burn_time = ptype.burn_time
			if lzr_gamestate.get_state() == lzr_gamestate.LEVEL_TEST then
				burn_time = burn_time * lzr_globals.LEVEL_TEST_TIME_MULTIPLIER
			end
			local new_destroy_event = {
				time = time + burn_time * 1000000,
				positions = new_positions[ptype.name],
			}
			table.insert(destroy_events, new_destroy_event)
		end
	end

	lzr_laser.full_laser_update_if_needed()
end

minetest.register_globalstep(function()
	recently_touched_receivers = {}

	handle_delayed_node_destructions()
end)

lzr_gamestate.register_on_enter_state(function(state)
	if state ~= lzr_gamestate.LEVEL and state ~= lzr_gamestate.LEVEL_TEST and state ~= lzr_gamestate.LEVEL_COMPLETE then
		lzr_laser.reset_destroy_events()
	end
end)
