2024-10-21 18:07:52 +02:00

612 lines
17 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 the core levels
local full_test = false
-- level number of currently tested core level
local full_test_level = 0
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_core_solution_callback = function()
local level_data = lzr_levels.get_core_level_data()
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_id = full_test_level
local level = level_data[level_id]
if not level then
-- Level does not exist
minetest.log("error", "[lzr_solutions] Core level "..tostring(level_id).." does not exist")
return false
end
if not level.filename_solution then
-- No solution in level. Skip test.
minetest.log("error", "[lzr_solutions] Core level "..tostring(level_id).." doesn't have solution")
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 core level "..level_id)
return true
else
minetest.log("error", "[lzr_solutions] Error in solution CSV file for core level "..level_id)
return false
end
else
minetest.log("error", "[lzr_solutions] Error while loading solution CSV file for core level "..level_id)
return false
end
end
local test_next_core_solution = function()
full_test_level = full_test_level + 1
if full_test_level > lzr_levels.LAST_LEVEL then
return false
end
if full_test_level == 1 then
minetest.log("verbose", "[lzr_solutions] Loading core level "..full_test_level.." ...")
lzr_levels.start_level(full_test_level)
end
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_core_solution_callback()
end
end)
lzr_solutions.test_core_solutions = function()
full_test = true
full_test_level = 0
local ok = test_next_core_solution()
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()/5000)
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), 5)
s_pointed_thing_above = minetest.pos_to_string(w2l(action.pointed_thing.above), 5)
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)
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 not full_test then
-- Go back to idle state
current_solution = nil
state = "idle"
end
return
end
local action = current_solution.actions[current_action]
if current_replay_time >= action.time/1000 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
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,
})
-- 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" and full_test then
-- If level was completed during full test, continue with next level
if new_state == lzr_gamestate.LEVEL_COMPLETE then
local ok = test_next_core_solution()
if not ok then
full_test = false
current_solution = nil
state = "idle"
end
end
end
end)
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)
local gstate = lzr_gamestate.get_state()
if gstate ~= lzr_gamestate.LEVEL then
return false, S("Not playing in a level!")
end
if state == "playing" then
return false, S("Already replaying a solution!")
elseif state == "recording" then
return false, S("Already recording!")
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_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_core_solutions", {
privs = { debug = true, server = true },
params = "",
description = S("Test the solution of all core levels"),
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
lzr_solutions.test_core_solutions()
return true
end,
})