lzr_solutions = {} local S = minetest.get_translator("lzr_solutions") -- "idle", "recording" or "playing" local state = "idle" -- true if recording should automatically stop when reaching LEVEL_COMPLETE state -- and trigger a file save local autostop = false -- true if running a full solution test of a level pack local full_test = false -- level number of currently tested level pack level local full_test_level = 0 -- level pack name for the full solution test local full_test_pack = nil local current_replay_time = 0 local current_action local current_solution local current_solution_start_time -- shortcuts local w2l = lzr_world.world_pos_to_level_pos local l2w = lzr_world.level_pos_to_world_pos --[[ solution: { actions = { action, action, action, ... }, } action: { type = "dig" / "place" / "rotate" / "find_treasure", time = , origin = { pos, pitch, yaw, }, -- for action "dig": pos = , -- for action "place": pos = , node = , itemstack = , param2 = , pointed_thing = -- for action "rotate": pos = , node = , } ]] local test_next_pack_solution_callback = function() local level_id = full_test_level minetest.log("action", "[lzr_solutions] Testing solution for level "..level_id.." of level pack '"..full_test_pack.."'") local level_data = lzr_levels.get_level_pack(full_test_pack) if not level_data.solutions_path then -- No solutions path. Nothing to test! minetest.log("error", "[lzr_solutions] No solutions path") return false end local level = level_data[level_id] if not level then -- Level does not exist minetest.log("error", "[lzr_solutions] Level "..tostring(level_id).." does not exist in level pack '"..full_test_pack.."'") return false end if not level.filename_solution then -- No solution in level. Skip test. minetest.log("error", "[lzr_solutions] Level "..tostring(level_id).." of level pack '"..full_test_pack.."' doesn't have a solution file") return false end local full_path = level_data.solutions_path.."/"..level.filename_solution local solution_file = io.open(full_path, "r") if solution_file then local csv = solution_file:read("*a") local solution = lzr_solutions.csv_to_solution(csv) if solution then lzr_solutions.replay_solution(solution) minetest.log("action", "[lzr_solutions] Playing solution for level "..level_id.." of level pack '"..full_test_pack.."'") return true else minetest.log("error", "[lzr_solutions] Error in solution CSV file for level "..level_id.." of level pack '"..full_test_pack.."'") return false end else minetest.log("error", "[lzr_solutions] Error while loading solution CSV file for level "..level_id.." of level pack '"..full_test_pack.."'") return false end end local test_next_pack_solution = function(pack) local level_data = lzr_levels.get_level_pack(pack) full_test_level = full_test_level + 1 if full_test_level > #level_data then return false end minetest.log("info", "[lzr_solutions] Loading level "..full_test_level.." of level pack '"..pack.."' ...") lzr_gamestate.set_state(lzr_gamestate.LEVEL_TEST) lzr_levels.start_level(full_test_level, level_data) return true end lzr_levels.register_on_level_start(function() minetest.log("verbose", "[lzr_solutions] on_level_start event") if full_test then test_next_pack_solution_callback() end end) lzr_solutions.test_pack_solutions = function(pack) full_test = true full_test_level = 0 full_test_pack = pack local ok = test_next_pack_solution(pack) if not ok then full_test = false end end local record_action = function(solution, action) local new_action = table.copy(action) local time = math.floor(minetest.get_us_time()/1000) new_action.time = time - current_solution_start_time table.insert(solution.actions, new_action) end local replay_action = function(player, action) player:set_pos(action.origin.pos) player:set_look_horizontal(action.origin.yaw) player:set_look_vertical(action.origin.pitch) if action.type == "dig" then -- Check if node is the expected node local node = minetest.get_node(action.pos) if node.name ~= action.node.name or node.param2 ~= action.node.param2 then minetest.log("error", "[lzr_solutions] Expected to dig node '"..action.node.name.."' (param2="..action.node.param2..") but found '"..node.name.."' (param2="..node.param2..")") return false end minetest.node_dig(action.pos, action.node, player) local def = minetest.registered_nodes[action.node.name] if def and def.sounds and def.sounds.dug then minetest.sound_play(def.sounds.dug, {pos=action.pos}, true) end lzr_laser.full_laser_update_if_needed() elseif action.type == "place" then -- Check if action itemstack exists in player inventory local inv = player:get_inventory() local idx = nil local astack = ItemStack(action.itemstack) -- Set count of both itemstacks to 1 and clear metadata astack:set_count(1) astack:get_meta():from_table(nil) -- this clears metadata for i=1, inv:get_size("main") do local checkstack = inv:get_stack("main", i) checkstack:set_count(1) checkstack:get_meta():from_table(nil) if astack:equals(checkstack) then idx = i break end end if not idx then minetest.log("error", "[lzr_solutions] Tried to place an item not found in the player inventory: "..action.itemstack:to_string()) return false end local itemstack, position = minetest.item_place_node(action.itemstack, player, action.pointed_thing, action.param2, false) if not position then minetest.log("error", "[lzr_solutions] Tried to place item as node but failed") return false end -- Update player inventory inv:set_stack("main", idx, itemstack) local def = minetest.registered_nodes[action.itemstack:get_name()] if def and def.sounds and def.sounds.place then minetest.sound_play(def.sounds.place, {pos=action.pointed_thing.under}, true) end lzr_laser.full_laser_update_if_needed() elseif action.type == "rotate" then local node = minetest.get_node(action.pos) if node.name ~= action.node.name then minetest.log("error", "[lzr_solutions] Tried to rotate node '"..action.node.name.."' but found '"..node.name.."'") return false end minetest.swap_node(action.pos, action.node) local def = minetest.registered_nodes[action.node.name] if def and def.sounds and def.sounds._rotate then minetest.sound_play(def.sounds._rotate, {pos=action.pos}, true) end lzr_laser.full_laser_update_if_needed() elseif action.type == "find_treasure" then local node = minetest.get_node(action.pos) local def = minetest.registered_nodes[node.name] if def.on_punch then def.on_punch(action.pos, node, player) else minetest.log("error", "[lzr_solutions] Expected punchable open treasure chest at "..minetest.pos_to_string(pos).." while replaying solution but didn't find one") return false end end return true end lzr_solutions.record_solution = function(activate_autostop) if activate_autostop then autostop = true else autostop = false end state = "recording" current_solution = { actions = {}, } current_solution_start_time = math.floor(minetest.get_us_time()/1000) end lzr_solutions.stop_recording_solution = function() state = "idle" autostop = false if current_solution then local solution = table.copy(current_solution) current_solution = nil current_solution_start_time = nil return solution else return end end lzr_solutions.replay_solution = function(solution) current_replay_time = 0 current_action = 1 current_solution = table.copy(solution) state = "playing" end lzr_solutions.solution_to_csv = function(solution) local rows = {} for a=1, #solution.actions do local action = solution.actions[a] local s_pos, s_node_name, s_node_param2 = "", "", "" local s_itemstack = "" local s_pointed_thing_under = "" local s_pointed_thing_above = "" if action.pos then s_pos = minetest.pos_to_string(w2l(action.pos)) end if action.node then s_node_name = action.node.name s_node_param2 = action.node.param2 end if action.itemstack then s_itemstack = action.itemstack:to_string() end if action.pointed_thing and action.pointed_thing.type == "node" then s_pointed_thing_under = minetest.pos_to_string(w2l(action.pointed_thing.under)) s_pointed_thing_above = minetest.pos_to_string(w2l(action.pointed_thing.above)) end local row = { action.type, action.time, minetest.pos_to_string(w2l(action.origin.pos), 5), string.format("%.5g", action.origin.pitch), string.format("%.5g", action.origin.yaw), -- action-specific fields: -- pos s_pos, -- node (name and param2) s_node_name, s_node_param2, -- itemstack s_itemstack, -- pointed_thing (type node) s_pointed_thing_under, s_pointed_thing_above, } table.insert(rows, row) end return lzr_csv.write_csv(rows) end lzr_solutions.csv_to_solution = function(csv) local rows, csv_error = lzr_csv.parse_csv(csv) if not rows then return nil, csv_error end local solution = { actions = {}, } for r=1, #rows do local row = rows[r] local function row_exists(i) return row[i] and row[i] ~= "" end local action = {} action.type = row[1] action.time = tonumber(row[2]) action.origin = { pos = l2w(minetest.string_to_pos(row[3])), pitch = tonumber(row[4]), yaw = tonumber(row[5]), } if row_exists(6) then action.pos = l2w(minetest.string_to_pos(row[6])) end if row_exists(7) and row_exists(8) then action.node = { name = row[7], param2 = tonumber(row[8]), } end if row_exists(9) then action.itemstack = ItemStack(row[9]) end if row_exists(10) and row_exists(11) then action.pointed_thing = { type = "node", under = l2w(minetest.string_to_pos(row[10])), above = l2w(minetest.string_to_pos(row[11])), } end table.insert(solution.actions, action) end return solution end minetest.register_on_placenode(function(pos, newnode, placer, oldnode, itemstack, pointed_thing) if state ~= "recording" then return end if not placer then return end local action = { type = "place", origin = { pos = placer:get_pos(), yaw = placer:get_look_horizontal(), pitch = placer:get_look_vertical(), }, pos = table.copy(pos), param2 = newnode.param2, itemstack = ItemStack(itemstack), pointed_thing = table.copy(pointed_thing), } record_action(current_solution, action) end) minetest.register_on_dignode(function(pos, oldnode, digger) if state ~= "recording" then return end if not digger then return end local action = { type = "dig", origin = { pos = digger:get_pos(), yaw = digger:get_look_horizontal(), pitch = digger:get_look_vertical(), }, pos = pos, node = oldnode, } record_action(current_solution, action) end) lzr_hook.register_after_rotate(function(pos, new_node, player) if state ~= "recording" then return end if not player then return end local action = { type = "rotate", origin = { pos = player:get_pos(), yaw = player:get_look_horizontal(), pitch = player:get_look_vertical(), }, pos = pos, node = new_node, } record_action(current_solution, action) end) lzr_treasure.register_after_found_treasure(function(pos, player) if state ~= "recording" then return end if not player then return end local action = { type = "find_treasure", origin = { pos = player:get_pos(), yaw = player:get_look_horizontal(), pitch = player:get_look_vertical(), }, pos = pos, } record_action(current_solution, action) end) local function passed_message(message) minetest.chat_send_all(message) local player = minetest.get_player_by_name("singleplayer") if player then lzr_messages.show_message(player, message, 4) minetest.sound_play({name = "lzr_levels_level_complete", gain = 1}, {to_player=player:get_player_name()}, true) end end minetest.register_globalstep(function(dtime) if state ~= "playing" then return end current_replay_time = current_replay_time + dtime if not current_solution then minetest.log("error", "[lzr_solutions] In 'playing' state but current_solution is nil!") state = "idle" full_test = false return end if current_action > #current_solution.actions then -- Replay is finished if full_test then if not lzr_gamestate.is_loading() then if lzr_laser.check_level_won() then minetest.log("action", "[lzr_solutions] Solution for level "..full_test_level.." of level pack '"..full_test_pack.."' completed!") local ok = test_next_pack_solution(full_test_pack) if not ok then minetest.log("action", "[lzr_solutions] Level pack solution test for '"..full_test_pack.."' successfully completed!") passed_message(S("Level pack solution test PASSED!")) full_test = false current_solution = nil state = "idle" lzr_levels.go_to_menu() end else minetest.log("error", "[lzr_solutions] Replay for level solution is done but level wasn't solved") full_test = false current_solution = nil state = "idle" end end else -- Go back to idle state current_solution = nil state = "idle" if lzr_laser.check_level_won() then passed_message(S("Level solution test PASSED!")) minetest.log("action", "[lzr_solutions] Replay for level solution successfully completed") lzr_levels.go_to_menu() else minetest.log("error", "[lzr_solutions] Replay for level solution completed but level wasn't solved") end end return end local action = current_solution.actions[current_action] if current_replay_time >= action.time*(0.001*lzr_globals.LEVEL_TEST_TIME_MULTIPLIER) then local player = minetest.get_player_by_name("singleplayer") if not player then return end local ok = replay_action(player, action) if not ok then -- Abort replay if replay_action returns false (in case of error) current_solution = nil state = "idle" full_test = false end current_action = current_action + 1 end end) lzr_solutions.save_solution_for_current_level = function(solution) local csv = lzr_solutions.solution_to_csv(solution) local world_solutions_path = minetest.get_worldpath().."/solutions" local ok = minetest.mkdir(world_solutions_path) if not ok then return false, S("Could not create and/or access world solutions path.") end local level_data = lzr_levels.get_current_level_data() local level_id = lzr_levels.get_current_level() if not level_data or not level_id then minetest.log("error", "[lzr_solutions] save_solution_for_current_level: Missing level_data or level ID!") return false end local level = level_data[level_id] local filename_solution = string.sub(level.filename, 1, -5) .. ".sol.csv" local full_path = world_solutions_path.."/"..filename_solution local solution_file = io.open(full_path, "w") if solution_file then solution_file:write(csv) solution_file:close() return true, S("Solution file written to: @1", full_path) else return false, S("Could not write solution file.") end end -- Automatically stop recording when entering the LEVEL_COMPLETE state -- and save solution to file. lzr_gamestate.register_on_enter_state(function(new_state) if state == "recording" and autostop then if new_state == lzr_gamestate.LEVEL_COMPLETE then local solution = lzr_solutions.stop_recording_solution() local msg = S("Recording finished.") local ok, save_msg = lzr_solutions.save_solution_for_current_level(solution) msg = msg .. "\n"..save_msg minetest.chat_send_player("singleplayer", msg) elseif new_state ~= lzr_gamestate.LEVEL then lzr_solutions.stop_recording_solution() minetest.chat_send_player("singleplayer", S("Recording cancelled.")) end elseif state == "playing" then if new_state ~= lzr_gamestate.LEVEL_TEST then full_test = false state = "idle" current_solution = nil end end end) -- Show chat commands only in debug mode if minetest.settings:get_bool("lzr_debug", false) == true then minetest.register_chatcommand("replay_solution", { privs = { debug = true, server = true }, params = "", description = S("Replay saved solution for current level, if one exists"), func = function(name, param) if state == "playing" then return false, S("Already replaying a solution!") elseif state == "recording" then return false, S("Already recording!") end local gstate = lzr_gamestate.get_state() if gstate ~= lzr_gamestate.LEVEL and gstate ~= lzr_gamestate.LEVEL_TEST then return false, S("Not playing in a level!") end local level_data = lzr_levels.get_current_level_data() local level_id = lzr_levels.get_current_level() local level = level_data[level_id] if not level_data.solutions_path or not level.filename_solution then return false, S("No solution available.") end local full_path = level_data.solutions_path.."/"..level.filename_solution local solution_file = io.open(full_path, "r") if solution_file then local csv = solution_file:read("*a") local solution, csv_error = lzr_solutions.csv_to_solution(csv) if solution then lzr_gamestate.set_state(lzr_gamestate.LEVEL_TEST) local player = minetest.get_player_by_name(name) lzr_gui.set_play_gui(player, true) lzr_player.set_play_inventory(player, nil, true) lzr_solutions.replay_solution(solution) return true, S("Replay started.") else return false, S("CSV error in solution: @1.", csv_error) end else return false, S("No solution file available.") end end, }) minetest.register_chatcommand("test_pack_solutions", { privs = { debug = true, server = true }, params = S("[]"), description = S("Test the solutions of all levels of a level pack"), func = function(name, param) if state == "playing" then return false, S("Already replaying a solution!") elseif state == "recording" then return false, S("Already recording!") end local pack = param if pack == "" then pack = "__core" end if lzr_levels.get_level_pack(pack) then lzr_solutions.test_pack_solutions(pack) return true else return false, S("This level pack doesn’t exist!") end end, }) minetest.register_chatcommand("record_solution", { privs = {}, params = "[ start | stop | cancel ]", description = S("Start or stop recording solution for current level, writing to a solution file when stopping"), func = function(name, param) local gstate = lzr_gamestate.get_state() if gstate ~= lzr_gamestate.LEVEL then return false, S("Not playing in a level!") end -- Starts recording if param == "start" or param == "" then if state == "playing" then return false, S("Already replaying a solution!") elseif state == "recording" then return false, S("Already recording!") end lzr_solutions.record_solution(true) return true, S("Recording started.") -- Stops recording and finalizes it by writing to a file elseif param == "stop" then if state ~= "recording" then return false, S("Not recording!") end local solution = lzr_solutions.stop_recording_solution() local ok, msg = lzr_solutions.save_solution_for_current_level(solution) if ok then return true, S("Recording stopped.").."\n"..msg else return false, S("Recording stopped.").."\n"..msg end -- Cancels recording without writing to file elseif param == "cancel" then if state ~= "recording" then return false, S("Not recording!") end lzr_solutions.stop_recording_solution() return true, S("Recording cancelled.") else return false end end, }) end