666 lines
19 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = <number>,
origin = {
pos,
pitch,
yaw,
},
-- for action "dig":
pos = <pos of node to dig>,
-- for action "place":
pos = <pos of placed node>,
node = <node to place>,
itemstack = <itemstack to place>,
param2 = <param2 of created node>,
pointed_thing = <pointed_thing of placement (of type "node")>
-- for action "rotate":
pos = <pos of rotated node>,
node = <node after rotation>,
}
]]
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("[<level pack>]"),
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 doesnt 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