666 lines
19 KiB
Lua
666 lines
19 KiB
Lua
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 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
|