yl_speak_up/functions.lua
2024-02-14 01:58:18 +01:00

703 lines
24 KiB
Lua

--###
-- Init
--###
-- self (the npc as such) is rarely passed on to any functions; in order to be able to check if
-- the player really owns the npc, we need to have that data available;
-- format: yl_speak_up.npc_owner[ npc_id ] = owner_name
yl_speak_up.npc_owner = {}
-- store the current trade between player and npc in case it gets edited in the meantime
yl_speak_up.trade = {}
-- store what the player last entered in an text_input action
yl_speak_up.last_text_input = {}
yl_speak_up.reset_vars_for_player = function(pname, reset_fs_version)
yl_speak_up.speak_to[pname] = nil
yl_speak_up.last_text_input[pname] = nil
-- when just stopping editing: don't reset the fs_version
if(reset_fs_version) then
yl_speak_up.fs_version[pname] = nil
end
end
--###
-- Debug
--###
yl_speak_up.debug = true
--###
-- Helpers
--###
yl_speak_up.get_number_from_id = function(any_id)
if(not(any_id) or any_id == "d_got_item" or any_id == "d_end" or any_id == "d_dynamic") then
return "0"
end
return string.split(any_id, "_")[2]
end
local function save_path(n_id)
return yl_speak_up.worldpath .. yl_speak_up.path .. DIR_DELIM .. n_id .. ".json"
end
yl_speak_up.get_error_message = function()
local formspec = {
"size[13.4,8.5]",
"bgcolor[#FF0000]",
"label[0.2,0.35;Please save a NPC file first]",
"button_exit[0.2,7.7;3,0.75;button_back;Back]"
}
return table.concat(formspec, "")
end
yl_speak_up.find_next_id = function(t)
local start_id = 1
if t == nil then
return start_id
end
local keynum = 1
for k, _ in pairs(t) do
local keynum = tonumber(yl_speak_up.get_number_from_id(k))
if keynum and keynum >= start_id then
start_id = keynum + 1
end
end
return start_id
end
yl_speak_up.sanitize_sort = function(options, value)
local retval = value
if value == "" or value == nil or tonumber(value) == nil then
local temp = 0
for k, v in pairs(options) do
if v.o_sort ~= nil then
if tonumber(v.o_sort) > temp then
temp = tonumber(v.o_sort)
end
end
end
retval = tostring(temp + 1)
end
return retval
end
--###
--Load and Save
--###
-- we can't really log changes here in this function because we don't know *what* has been changed
yl_speak_up.save_dialog = function(n_id, dialog)
if type(n_id) ~= "string" or type(dialog) ~= "table" then
return false
end
local p = save_path(n_id)
-- save some data (in particular usage of quest variables)
yl_speak_up.update_stored_npc_data(n_id, dialog)
-- make sure we never store any automaticly added generic dialogs
dialog = yl_speak_up.strip_generic_dialogs(dialog)
-- never store d_dynamic dialogs
if(dialog.n_dialogs and dialog.n_dialogs["d_dynamic"]) then
dialog.n_dialogs["d_dynamic"] = nil
end
local content = minetest.write_json(dialog)
return minetest.safe_file_write(p, content)
end
-- if a player is supplied: include generic dialogs
yl_speak_up.load_dialog = function(n_id, player) -- returns the saved dialog
local p = save_path(n_id)
-- note: add_generic_dialogs will also add an empty d_dynamic dialog
local file, err = io.open(p, "r")
if err then
return yl_speak_up.add_generic_dialogs({}, n_id, player)
end
io.input(file)
local content = io.read()
local dialog = minetest.parse_json(content)
io.close(file)
if type(dialog) ~= "table" then
dialog = {}
end
return yl_speak_up.add_generic_dialogs(dialog, n_id, player)
end
-- used by staff and input_inital_config
yl_speak_up.fields_to_dialog = function(pname, fields)
local n_id = yl_speak_up.speak_to[pname].n_id
local dialog = yl_speak_up.load_dialog(n_id, false)
local save_d_id = ""
if next(dialog) == nil then -- No file found. Let's create the basic values
dialog = {}
dialog.n_dialogs = {}
end
if dialog.n_dialogs == nil or next(dialog.n_dialogs) == nil then --No dialogs found. Let's make a table
dialog.n_dialogs = {}
end
if fields.d_text ~= "" then -- If there is dialog text, then save new or old dialog
if fields.d_id == yl_speak_up.text_new_dialog_id then --New dialog --
-- Find highest d_id and increase by 1
save_d_id = "d_" .. yl_speak_up.find_next_id(dialog.n_dialogs)
-- Initialize empty dialog
dialog.n_dialogs[save_d_id] = {}
else -- Already existing dialog
save_d_id = fields.d_id
end
-- Change dialog
dialog.n_dialogs[save_d_id].d_id = save_d_id
dialog.n_dialogs[save_d_id].d_type = "text"
dialog.n_dialogs[save_d_id].d_text = fields.d_text
dialog.n_dialogs[save_d_id].d_sort = fields.d_sort
end
--Context
yl_speak_up.speak_to[pname].d_id = save_d_id
-- Just in case the NPC vlaues where changed or set
dialog.n_id = n_id
dialog.n_description = fields.n_description
dialog.n_npc = fields.n_npc
dialog.npc_owner = fields.npc_owner
return dialog
end
yl_speak_up.delete_dialog = function(n_id, d_id)
if d_id == yl_speak_up.text_new_dialog_id then
return false
end -- We don't delete "New dialog"
local dialog = yl_speak_up.load_dialog(n_id, false)
dialog.n_dialogs[d_id] = nil
yl_speak_up.save_dialog(n_id, dialog)
end
--###
--Formspecs
--###
-- get formspecs
-- talk
-- receive fields
-- talk
-- helper function
-- the option to override next_id and provide a value is needed when a new dialog was
-- added, then edited, and then discarded; it's still needed after that, but has to
-- be reset to empty state (wasn't stored before)
yl_speak_up.add_new_dialog = function(dialog, pname, next_id, dialog_text)
if(not(next_id)) then
next_id = yl_speak_up.find_next_id(dialog.n_dialogs)
end
local future_d_id = "d_" .. next_id
-- Initialize empty dialog
dialog.n_dialogs[future_d_id] = {
d_id = future_d_id,
d_type = "text",
d_text = (dialog_text or ""),
d_sort = next_id
}
-- store that there have been changes to this npc
-- (better ask only when the new dialog is changed)
-- table.insert(yl_speak_up.npc_was_changed[ yl_speak_up.edit_mode[pname] ],
-- "Dialog "..future_d_id..": New dialog added.")
-- add an option for going back to the start of the dialog;
-- this is an option which the player can delete and change according to needs,
-- not a fixed button which may not always fit
if(not(dialog_text)) then
-- we want to go back to the start from here
local target_dialog = yl_speak_up.get_start_dialog_id(dialog)
-- this text will be used for the button
local option_text = "Let's go back to the start of our talk."
-- we just created this dialog - this will be the first option
yl_speak_up.add_new_option(dialog, pname, "1", future_d_id, option_text, target_dialog)
end
return future_d_id
end
-- add a new option/answer to dialog d_id with option_text (or default "")
-- option_text (optional) the text that shall be shown as option/answer
-- target_dialog (optional) the target dialog where the player will end up when choosing
-- this option/answer
yl_speak_up.add_new_option = function(dialog, pname, next_id, d_id, option_text, target_dialog)
if(not(dialog) or not(dialog.n_dialogs) or not(dialog.n_dialogs[d_id])) then
return nil
end
if dialog.n_dialogs[d_id].d_options == nil then
-- make sure d_options exists
dialog.n_dialogs[d_id].d_options = {}
else
-- we don't want an infinite amount of answers per dialog
local sorted_list = yl_speak_up.get_sorted_options(dialog.n_dialogs[d_id].d_options, "o_sort")
local anz_options = #sorted_list
if(anz_options >= yl_speak_up.max_number_of_options_per_dialog) then
-- nothing added
return nil
end
end
if(not(next_id)) then
next_id = yl_speak_up.find_next_id(dialog.n_dialogs[d_id].d_options)
end
local future_o_id = "o_" .. next_id
dialog.n_dialogs[d_id].d_options[future_o_id] = {
o_id = future_o_id,
o_hide_when_prerequisites_not_met = "false",
o_grey_when_prerequisites_not_met = "false",
o_sort = -1,
o_text_when_prerequisites_not_met = "",
o_text_when_prerequisites_met = (option_text or ""),
}
-- necessary in order for it to work
local s = yl_speak_up.sanitize_sort(dialog.n_dialogs[d_id].d_options, yl_speak_up.speak_to[pname].o_sort)
dialog.n_dialogs[d_id].d_options[future_o_id].o_sort = s
-- log only in edit mode
local n_id = yl_speak_up.speak_to[pname].n_id
-- would be too difficult to add an exception for edit_mode here; thus, we do it directly here:
if(yl_speak_up.npc_was_changed
and yl_speak_up.npc_was_changed[n_id]) then
table.insert(yl_speak_up.npc_was_changed[ n_id ],
"Dialog "..d_id..": Added new option/answer "..future_o_id..".")
end
-- letting d_got_item point back to itself is not a good idea because the
-- NPC will then end up in a loop; plus the d_got_item dialog is intended for
-- automatic processing, not for showing to the player
if(d_id == "d_got_item") then
-- unless the player specifies something better, we go back to the start dialog
-- (that is where d_got_item got called from anyway)
target_dialog = yl_speak_up.get_start_dialog_id(dialog)
-- ...and this option needs to be selected automaticly
dialog.n_dialogs[d_id].d_options[future_o_id].o_autoanswer = 1
elseif(d_id == "d_trade") then
-- we really don't want to go to another dialog from here
target_dialog = "d_trade"
-- ...and this option needs to be selected automaticly
dialog.n_dialogs[d_id].d_options[future_o_id].o_autoanswer = 1
end
local future_r_id = nil
-- create a fitting dialog result automaticly if possible:
-- give this new dialog a dialog result that leads back to this dialog
-- (which is more helpful than creating tons of empty dialogs)
if(target_dialog and (dialog.n_dialogs[target_dialog] or target_dialog == "d_end")) then
future_r_id = yl_speak_up.add_new_result(dialog, d_id, future_o_id)
-- actually store the new result
dialog.n_dialogs[d_id].d_options[future_o_id].o_results = {}
dialog.n_dialogs[d_id].d_options[future_o_id].o_results[future_r_id] = {
r_id = future_r_id,
r_type = "dialog",
r_value = target_dialog}
end
-- the d_got_item dialog is special; players can easily forget to add the
-- necessary preconditions and effects, so we do that manually here
if(d_id == "d_got_item") then
-- we also need a precondition so that the o_autoanswer can actually get called
dialog.n_dialogs[d_id].d_options[future_o_id].o_prerequisites = {}
-- we just added this option; this is the first and for now only precondition for it;
-- the player still has to adjust it, but at least it is a reasonable default
dialog.n_dialogs[d_id].d_options[future_o_id].o_prerequisites["p_1"] = {
p_id = "p_1",
p_type = "player_offered_item",
p_item_stack_size = tostring(next_id),
p_match_stack_size = "exactly",
-- this is just a simple example item and ought to be changed after adding
p_value = "default:stick "..tostring(next_id)}
-- we need to show the player that his action was successful
dialog.n_dialogs[d_id].d_options[future_o_id].o_results[future_r_id].alternate_text =
"Thank you for the "..tostring(next_id).." stick(s)! "..
"Never can't have enough sticks.\n$TEXT$"
-- we need an effect for accepting the item;
-- taking all that was offered and putting it into the NPC's inventory is a good default
future_r_id = yl_speak_up.add_new_result(dialog, d_id, future_o_id)
dialog.n_dialogs[d_id].d_options[future_o_id].o_results[future_r_id] = {
r_id = future_r_id,
r_type = "deal_with_offered_item",
r_value = "take_all"}
-- the trade dialog is equally special
elseif(d_id == "d_trade") then
dialog.n_dialogs[d_id].d_options[future_o_id].o_prerequisites = {}
-- this is just an example
dialog.n_dialogs[d_id].d_options[future_o_id].o_prerequisites["p_1"] = {
p_id = "p_1",
p_type = "npc_inv",
p_value = "inv_does_not_contain",
p_inv_list_name = "npc_main",
p_itemstack = "default:stick "..tostring(100-next_id)}
future_r_id = yl_speak_up.add_new_result(dialog, d_id, future_o_id)
-- example craft
dialog.n_dialogs[d_id].d_options[future_o_id].o_results[future_r_id] = {
r_id = future_r_id,
r_type = "craft",
r_value = "default:stick 4",
o_sort = "1",
r_craft_grid = {"default:wood", "", "", "", "", "", "", "", ""}}
end
return future_o_id
end
-- add a new result to option o_id of dialog d_id
yl_speak_up.add_new_result = function(dialog, d_id, o_id)
if(not(dialog) or not(dialog.n_dialogs) or not(dialog.n_dialogs[d_id])
or not(dialog.n_dialogs[d_id].d_options) or not(dialog.n_dialogs[d_id].d_options[o_id])) then
return
end
-- create a new result (first the id, then the actual result)
local future_r_id = "r_" .. yl_speak_up.find_next_id(dialog.n_dialogs[d_id].d_options[o_id].o_results)
if future_r_id == "r_1" then
dialog.n_dialogs[d_id].d_options[o_id].o_results = {}
end
dialog.n_dialogs[d_id].d_options[o_id].o_results[future_r_id] = {}
return future_r_id
end
-- this is useful for result types that can exist only once per option
-- (apart from editing with the staff);
-- examples: "dialog" and "trade";
-- returns tue r_id or nil if no result of that type has been found
yl_speak_up.get_result_id_by_type = function(dialog, d_id, o_id, result_type)
if(not(dialog) or not(dialog.n_dialogs) or not(dialog.n_dialogs[d_id])
or not(dialog.n_dialogs[d_id].d_options) or not(dialog.n_dialogs[d_id].d_options[o_id])) then
return
end
local results = dialog.n_dialogs[d_id].d_options[o_id].o_results
if(not(results)) then
return
end
for k, v in pairs(results) do
if(v.r_type == result_type) then
return k
end
end
end
-- helper function for sorting options/answers using options[o_id].o_sort
-- (or dialogs by d_sort)
yl_speak_up.get_sorted_options = function(options, sort_by)
local sorted_list = {}
for k,v in pairs(options) do
table.insert(sorted_list, k)
end
table.sort(sorted_list,
function(a,b)
if(not(options[a][sort_by])) then
return false
elseif(not(options[b][sort_by])) then
return true
-- sadly not all entries are numeric
elseif(tonumber(options[a][sort_by]) and tonumber(options[b][sort_by])) then
return (tonumber(options[a][sort_by]) < tonumber(options[b][sort_by]))
-- numbers have a higher priority
elseif(tonumber(options[a][sort_by])) then
return true
elseif(tonumber(options[b][sort_by])) then
return false
-- if the value is the same: sort by index
elseif(options[a][sort_by] == options[b][sort_by]) then
return (a < b)
else
return (options[a][sort_by] < options[b][sort_by])
end
end
)
return sorted_list
end
-- simple sort of keys of a table numericly;
-- this is not efficient - but that doesn't matter: the lists are small and
-- it is only executed when configuring an NPC
-- simple: if the parameter is true, the keys will just be sorted (i.e. player names) - which is
-- not enough for d_<nr>, o_<nr> etc. (which need more care when sorting)
yl_speak_up.sort_keys = function(t, simple)
local keys = {}
for k, v in pairs(t) do
-- add a prefix so that p_2 ends up before p_10
if(not(simple) and string.len(k) == 3) then
k = "a"..k
end
table.insert(keys, k)
end
table.sort(keys)
if(simple) then
return keys
end
for i,k in ipairs(keys) do
-- avoid cutting the single a from a_1 (action 1)
if(k and string.sub(k, 1, 1) == "a" and string.sub(k, 2, 2) ~= "_") then
-- remove the leading blank
keys[i] = string.sub(k, 2)
end
end
return keys
end
-- identify multiple results that lead to target dialogs
yl_speak_up.check_for_disambigous_results = function(n_id, pname)
local errors_found = false
-- this is only checked when trying to edit this npc;
-- let's stick to check the dialogs of this one without generic dialogs
local dialog = yl_speak_up.load_dialog(n_id, false)
-- nothing defined yet - nothing to repair
if(not(dialog.n_dialogs)) then
return
end
-- iterate over all dialogs
for d_id, d in pairs(dialog.n_dialogs) do
if(d_id and d and d.d_options) then
-- iterate over all options
for o_id, o in pairs(d.d_options) do
if(o_id and o and o.o_results) then
local dialog_results = {}
-- iterate over all results
for r_id, r in pairs(o.o_results) do
if(r.r_type == "dialog") then
table.insert(dialog_results, r_id)
end
end
if(#dialog_results>1) then
local msg = "ERROR: Dialog "..
tostring(d_id)..", option "..tostring(o_id)..
", has multiple results of type dialog: "..
minetest.serialize(dialog_results)..". Please "..
"let someone with npc_master priv fix that first!"
yl_speak_up.log_change(pname, n_id, msg, "error")
if(pname) then
minetest.chat_send_player(pname, msg)
end
errors_found = true
end
end
end
end
end
return errors_found
end
-- Make the NPC talk
-- assign n_ID
-- usually this happens when talking to the NPC for the first time;
-- but if you want to you can call this function earlier (on spawn)
-- so that logging of spawning with the ID is possible
yl_speak_up.initialize_npc = function(self)
-- already configured?
if(not(self) or (self.yl_speak_up and self.yl_speak_up.id)) then
return self
end
local m_talk = yl_speak_up.talk_after_spawn or true
local m_id = yl_speak_up.number_of_npcs + 1
yl_speak_up.number_of_npcs = m_id
yl_speak_up.modstorage:set_int("amount", m_id)
self.yl_speak_up = {
talk = m_talk,
id = m_id,
textures = self.textures
}
return self
end
function yl_speak_up.talk(self, clicker)
if not clicker and not clicker:is_player() then
return
end
if not self then
return
end
local id_prefix = "n"
-- we are not dealing with an NPC but with a position/block on the map
if(self.is_block) then
id_prefix = "p"
local owner = "- unknown -"
local talk_name = "- unknown -"
if(self.pos and self.pos and self.pos.x) then
local meta = minetest.get_meta(self.pos)
if(meta) then
owner = meta:get_string("owner") or ""
talk_name = meta:get_string("talk_name") or ""
end
end
self.yl_speak_up = {
is_block = true,
talk = true,
id = minetest.pos_to_string(self.pos, 0),
textures = {},
owner = owner,
npc_name = talk_name,
object = nil, -- blocks don't have an object
}
-- TODO: remember somewhere that this block is relevant
-- initialize the mob if necessary; this happens at the time of first talk, not at spawn time!
elseif(not(self.yl_speak_up) or not(self.yl_speak_up.id)) then
self = yl_speak_up.initialize_npc(self)
end
-- TODO: load inventory only when the npc actually uses one?
-- create a detached inventory for the npc and load its inventory
yl_speak_up.load_npc_inventory(id_prefix.."_"..tostring(self.yl_speak_up.id))
local npc_id = self.yl_speak_up.id
local n_id = id_prefix.."_" .. npc_id
-- remember whom the npc belongs to (as long as we still have self.owner available for easy access)
yl_speak_up.npc_owner[ n_id ] = self.owner
local pname = clicker:get_player_name()
if not self.yl_speak_up or not self.yl_speak_up.talk or self.yl_speak_up.talk~=true then
local was = "This NPC"
if(id_prefix ~= "n") then
was = "This block"
end
-- show a formspec to other players that this NPC is busy
if(not(yl_speak_up.may_edit_npc(clicker, n_id))) then
-- show a formspec so that the player knows that he may come back later
yl_speak_up.show_fs(player, "msg", {input_to = "yl_spaek_up:ignore", formspec =
"size[6,2]"..
"label[1.2,0.0;"..minetest.formspec_escape((self.yl_speak_up.npc_name or was)..
" [muted]").."]"..
"label[0.2,0.5;Sorry! I'm currently busy learning new things.]"..
"label[0.2,1.0;Please come back later.]"..
"button_exit[2.5,1.5;1,0.9;ok;Ok]"})
return
end
-- allow the owner to edit (and subsequently unmute) the npc
minetest.chat_send_player(pname, was.." is muted. It will only talk to you.")
end
yl_speak_up.speak_to[pname] = {}
yl_speak_up.speak_to[pname].n_id = n_id -- Memorize which player talks to which NPC
yl_speak_up.speak_to[pname].textures = self.yl_speak_up.textures
yl_speak_up.speak_to[pname].option_index = 1
-- the object itself may be needed in load_dialog for adding generic dialogs
yl_speak_up.speak_to[pname].obj = self.object
-- Load the dialog and see what we can do with it
-- this inculdes generic dialog parts;
yl_speak_up.speak_to[pname].dialog = yl_speak_up.load_dialog(n_id, clicker)
-- is this player explicitly allowed to edit this npc?
if(yl_speak_up.speak_to[pname].dialog
and yl_speak_up.speak_to[pname].dialog.n_may_edit
and yl_speak_up.speak_to[pname].dialog.n_may_edit[pname]
and minetest.check_player_privs(clicker, {npc_talk_owner=true})) then
yl_speak_up.speak_to[pname].may_edit_this_npc = true
end
local dialog = yl_speak_up.speak_to[pname].dialog
if(not(dialog.trades)) then
dialog.trades = {}
end
-- some NPC may have reset the animation; at least set it to the desired
-- value whenever we talk to the NPC
if self.yl_speak_up and self.yl_speak_up.animation then
self.object:set_animation(self.yl_speak_up.animation)
end
-- maintain a list of existing NPC, but do not force saving
yl_speak_up.update_npc_data(self, dialog, false)
yl_speak_up.show_fs(clicker, "talk", {n_id = n_id})
end
-- mute the npc; either via the appropriate staff or via talking to him
yl_speak_up.set_muted = function(p_name, obj, set_muted)
if(not(obj)) then
return
end
local luaentity = obj:get_luaentity()
if(not(luaentity)) then
return
end
local npc = luaentity.yl_speak_up.id
local npc_name = luaentity.yl_speak_up.npc_name
-- fallback
if(not(npc_name)) then
npc_name = npc
end
if(set_muted and luaentity.yl_speak_up.talk) then
-- the npc is willing to talk
luaentity.yl_speak_up.talk = false
yl_speak_up.update_nametag(luaentity)
-- minetest.chat_send_player(p_name,"NPC with ID n_"..npc.." will shut up at pos "..
-- minetest.pos_to_string(obj:get_pos(),0).." on command of "..p_name)
minetest.chat_send_player(p_name, "NPC n_"..tostring(npc).." is now muted and will "..
"only talk to those who can edit the NPC.")
yl_speak_up.log_change(p_name, "n_"..npc, "muted - NPC stops talking")
elseif(not(set_muted) and not(luaentity.yl_speak_up.talk)) then
-- mute the npc
luaentity.yl_speak_up.talk = true
yl_speak_up.update_nametag(luaentity)
minetest.chat_send_player(p_name, "NPC n_"..tostring(npc).." is no longer muted and "..
"will talk with any player who right-clicks the NPC.")
-- minetest.chat_send_player(p_name,"NPC with ID n_"..npc.." will resume speech at pos "..
-- minetest.pos_to_string(obj:get_pos(),0).." on command of "..p_name)
yl_speak_up.log_change(p_name, "n_"..npc, "unmuted - NPC talks again")
end
end
-- checks if dialog contains d_id and o_id
yl_speak_up.check_if_dialog_has_option = function(dialog, d_id, o_id)
return (dialog and d_id and o_id
and dialog.n_dialogs
and dialog.n_dialogs[d_id]
and dialog.n_dialogs[d_id].d_options
and dialog.n_dialogs[d_id].d_options[o_id])
end
-- has the player the right privs?
-- this is used for the "I am your master" talk based configuration; *NOT* for the staffs!
yl_speak_up.may_edit_npc = function(player, n_id)
if(not(player)) then
return false
end
local pname = player:get_player_name()
-- is the player allowed to edit this npc?
return ((yl_speak_up.npc_owner[ n_id ] == pname
and minetest.check_player_privs(player, {npc_talk_owner=true}))
or minetest.check_player_privs(player, {npc_talk_master=true})
or minetest.check_player_privs(player, {npc_master=true})
or (yl_speak_up.speak_to[pname]
and yl_speak_up.speak_to[pname].may_edit_this_npc))
end