diff --git a/README.md b/README.md index 0bf7289..9af66da 100644 --- a/README.md +++ b/README.md @@ -294,3 +294,50 @@ contain all the blocks which do not allow the NPCs this kind of interaction. You may i.e. set the put and take tables for blocks that do extensive checks on the player object which the NPC simply can't provide. + +Generic behaviour +================= +Sometimes you may have a group of NPC that ought to show a common behaviour +- like for example guards, smiths, bakers, or inhabitants of a town, or other +NPC that have something in common. Not each NPC may warrant its own, individual +dialogs. + +The Tutoial (TODO!) is another example: Not each NPC needs to present the player +with a tutorial, but those that are owned and where the owner tries to +program them ought to offer some help. + +That's where generic dialogs come in. You can create a new type of generic +dialog with any NPC. That NPC can from then on only be used for this one +purpose and ought not to be found in the "normal" world! Multiple such generic +dialogs and NPC for their creation can exist. + +Generic dialogs have to start with a dialog with just one option. This option +has to be set to "automaticly" (see Autoanswer). The preconditions of this +option are very important: They determine if this particular generic dialog +fits to this particular NPC or not. If it fits, all dialogs that are part of +this NPC that provides the generic dialog will be added to the "normal" +dialogs the importing actual NPC offers. If it doesn't fit, these generic +dialogs will be ignored here. + +The creator of a generic dialog can't know all situations where NPC may want +to use his dialogs and where those NPC will be standing and by whom they +are owned. Therefore only a limited amount of types of preconditions are +allowed for the preconditions of this first automatic option: state, +player_inv and custom. + +The other dialogs that follow after this first automatic dialog may contain +a few more types of preconditions: player_offered_item, function and other +are allowed here as well, while block, trade, npc_inv and block_inv make no +sense and are not available. + +All types of actions are allowed. + +Regarding effects/results, the types block, put_into_block_inv, +take_from_block_inv and craft are not supported. + +The "automaticly" selected only option from the start dialog leads +via the usual "dialog" effect to the actual start dialog for the +imported dialogs from that NPC. The options found there will be +added into the target NPC and the dialog text will be appended to +its dialog text. + diff --git a/add_generic_dialogs.lua b/add_generic_dialogs.lua index 9fe1e25..2e27fbb 100644 --- a/add_generic_dialogs.lua +++ b/add_generic_dialogs.lua @@ -1,8 +1,388 @@ +-- this table holds all generic dialogs with indices +-- generic_dialog[n_id][d_id_n_id] +yl_speak_up.generic_dialogs = {} +-- this table holds the preconditions for all generic dialogs +-- so that we know which ones are relevant for a particular NPC +yl_speak_up.generic_dialog_conditions = {} +-- the start dialog of a generic dialog needs to have just one option, +-- and that option has to be an autoanswer; all those options of the +-- dialogs that follow this start dialog are stored in dialog +-- "d_generic_start_dialog" -yl_speak_up.add_generic_dialogs = function(dialog, n_id, player) - if(not(player)) then +-- this is similar to the function +-- yl_speak_up.calculate_displayable_options +-- in exec_eval_preconditions.lua +-- - except that some things like sorting or extensive debug output can be skipped +yl_speak_up.calculate_available_generic_dialogs = function(current_n_id, player) + -- if this is a generic npc: don't inject anything + if(yl_speak_up.generic_dialogs[current_n_id]) then + return {} + end + local pname = player:get_player_name() + -- the IDs of all those NPCs whose dialogs can be added + local n_id_list = {} + -- Let's go through all the options and see if we need to display them to the user + -- check all options: option key (n_id), option value/data (list of preconditions) + for n_id, prereq in pairs(yl_speak_up.generic_dialog_conditions) do + -- if true: this generic dialog fits and will be added + local include_this = true + -- check all preconditions: precondition key, precondition data/data + for p_id, p in pairs(prereq) do + -- as soon as we locate one precondition that is false, this option (and thus the + -- generic dialog it stands for) cannot be included + if(not(include_this) + -- only certain types of preconditions are allowed because the other ones would + -- be too expensive or make no sense here + or not(yl_speak_up.eval_precondition(player, current_n_id, p, nil))) then + include_this = false + break + end + end + if(include_this) then + table.insert(n_id_list, n_id) + end + end + return n_id_list +end + + +-- helper function for yl_speak_up.check_and_add_as_generic_dialog; +-- appends append-str to data[field_name] if the name of the +-- target dialog stored there needs to be rewritten +yl_speak_up.rewrite_dialog_id = function(data, field_name, start_dialog, append_str) + if(not(data) or not(data[field_name])) then + return nil + end + -- we need this to point to a *common* start dialog + if(data[field_name] == start_dialog + or data[field_name]..append_str == start_dialog) then + return "d_generic_start_dialog" + elseif(data[field_name] ~= "d_got_item") then + return data[field_name]..append_str + end + return data[field_name] +end + + +-- returns "OK" if the dialog can be used as a generic dialog, that is: +-- * has at least one dialog +yl_speak_up.check_and_add_as_generic_dialog = function(dialog, n_id) + -- get the start dialog + local d_id = yl_speak_up.get_start_dialog_id(dialog) + if(not(d_id) + or not(dialog.n_dialogs[d_id]) + or not(dialog.n_dialogs[d_id].d_options)) then + return "No start dialog found." + end + -- the start dialog shall have exactly one option/answer + local one_option = nil + local anz_options = 0 + for o_id, o in pairs(dialog.n_dialogs[d_id].d_options) do + anz_options = anz_options + 1 + one_option = o_id + end + if(anz_options ~= 1) then + return "The start dialog has more than one option/answer." + end + local option = dialog.n_dialogs[d_id].d_options[one_option] + -- and this one option/answer shall be of the type autoanswer + if(not(option) or not(option.o_autoanswer) or not(option.o_autoanswer ~= "1")) then + return "The option of the start dialog is set to \"by clicking manually\" instead ".. + "of \"automatcily\"." + end + -- only some few types are allowed for preconditions + -- (these checks have to be cheap and quick, and any npc inventory is not available + -- at the time of these checks; let alone any block inventories or the like) + local prereq = option.o_prerequisites + if(prereq) then + for p_id, p in pairs(prereq) do + if(p.p_type ~= "state" and p.p_type ~= "player_inv" and p.p_type ~= "custom") then + return "Precondition "..tostring(p_id).. + " of option "..tostring(one_option).. + " of dialog "..tostring(d_id).. + " has unsupported type: "..tostring(p.p_type).. + ". Supported types: state, player_inv and custom." + end + end + end + -- the original start dialog of the generic dialog, containing only the + -- one automatic option + local orig_start_dialog = d_id + + -- not everything is suitable for generic dialogs + -- for all dialogs (doesn't hurt here to check the start dialog again): + for d_id, d in pairs(dialog.n_dialogs) do + -- if there are any options + if(d.d_options) then + -- for all options: + for o_id, o in pairs(d.d_options) do + -- if there are any preconditions: + if(o.o_prerequisites) then + -- for all preconditions: + for p_id, p in pairs(o.o_prerequisites) do + -- makes no sense: block, trade, npc_inv, npc_inv, block_inv + if( p.p_type ~= "state" + and p.p_type ~= "player_inv" + and p.p_type ~= "player_offered_item" + and p.p_type ~= "function" + and p.p_type ~= "custom" + -- depends on the preconditions of another option + and p.p_type ~= "other") then + return "Precondition "..tostring(p_id).. + " of option "..tostring(o_id).. + " of dialog "..tostring(d_id).. + " has (for generic dialogs) unsupported ".. + "type: "..tostring(p.p_type).."." + end + end + end + -- actions ought to be fully supported but MUST NOT access the NPCs inventory; + -- if there are any effects/results: + if(o.o_results) then + -- for all actions (there ought to be only one) + for r_id, r in pairs(o.o_results) do + -- makes no sense: + -- block, put_into_block_inv, take_from_block_inv, + -- craft + if( r.r_type ~= "state" + -- only accepting or refusing makes sense here + and r.r_type ~= "deal_with_offered_item" + and r.r_type ~= "on_failure" + and r.r_type ~= "chat_all" + and r.r_type ~= "give_item" + and r.r_type ~= "take_item" + and r.r_type ~= "move" + and r.r_type ~= "function" + and r.r_type ~= "custom" + and r.r_type ~= "dialog" + ) then + return "Effect "..tostring(r_id).. + " of option "..tostring(o_id).. + " of dialog "..tostring(d_id).. + " has (for generic dialogs) unsupported ".. + "type: "..tostring(r.r_type).."." + end + end + end + end + end + end + + -- this looks good; we may actually add these dialogs; + -- if data for this generic dialog (from this NPC) was stored before: reset it + yl_speak_up.generic_dialogs[n_id] = {} + + -- store the prerequirements for this generic dialog + yl_speak_up.generic_dialog_conditions[n_id] = option.o_prerequisites + + -- some modifications are necessary because dialog IDs are only uniq regarding + -- *one* NPC; for example, the dialog d_1 will be present in almost all NPC + -- we need to append a uniq postfix to all IDs + -- - except for d_got_item; that needs diffrent treatment + -- - and except for the start_dialog + local append_str = "_"..tostring(n_id) + + -- this is the actual dialog where all those options of intrest are that + -- later on need to be injected into the receiving NPC + local start_dialog = nil + local effects = option.o_results + if(effects) then + for r_id, r in pairs(option.o_results) do + if(r.r_type == "dialog") then + -- the start_dialog will soon be renamed for uniqueness; so store that name + start_dialog = r.r_value..append_str + end + end + end + + -- if no first dialog has been found or if the option loops back to + -- the first one: give up + if(not(start_dialog) + or start_dialog == orig_start_dialog + or start_dialog == "d_got_item"..append_str) then + yl_speak_up.generic_dialog_conditions[n_id] = {} + return "Option of first dialog loops back to first dialog. Giving up." + end + + -- we need to make sure that o_sort and d_sort make some sense; + -- anz_generic is multipiled with a factor later on, so we want at least 1 here + -- so that the values of the inserted dialogs are sufficiently high compared to + -- the "normal" ones of the npc + local anz_generic = 1 + for n_id, d in pairs(yl_speak_up.generic_dialogs) do + anz_generic = anz_generic + 1 + end + + -- for setting d_sort to a useful, non-conflicting value + local anz_dialogs = 0 + -- we rename the dialogs where necessary; + -- we also mark each dialog, precondition, action and effect with a + -- ?_is_generic = append_str entry so that it can later be easily recognized + -- and any interactions with the inventory of the NPC be avoided + for d_id, d in pairs(dialog.n_dialogs) do + -- if there are any options + if(d.d_options) then + -- for all options: + for o_id, o in pairs(d.d_options) do + -- if there are any preconditions: + if(o.o_prerequisites) then + -- for all preconditions: + for p_id, p in pairs(o.o_prerequisites) do + -- this comes from a generic dialog + p.p_is_generic = append_str + if(p.p_type == "other") then + -- ID of the other dialog that is checked + p.p_value = yl_speak_up.rewrite_dialog_id(p, + "p_value", start_dialog, append_str) + end + end + end + -- if there are any actions: + if(o.actions) then + -- for all actions (usually just one): + for a_id, a in pairs(o.actions) do + -- this comes from a generic dialog + a.a_is_generic = append_str + -- ID of the target dialog when the action failed + a.a_on_failure = yl_speak_up.rewrite_dialog_id(a, + "a_on_failure", start_dialog, append_str) + end + end + -- if there are any effects/results: + if(o.o_results) then + -- for all actions (there ought to be only one) + for r_id, r in pairs(o.o_results) do + -- this comes from a generic dialog + r.r_is_generic = append_str + if(r.r_type == "on_failure") then + r.r_on_failure = yl_speak_up.rewrite_dialog_id(r, + "r_on_failure", start_dialog, append_str) + elseif(r.r_type=="dialog") then + -- ID of the normal target dialog + r.r_value = yl_speak_up.rewrite_dialog_id(r, + "r_value", start_dialog, append_str) + end + end + end + end + end + -- d_sort needs to be set accordingly + local d_sort = tonumber(d.d_sort or "0") + if(not(d_sort) or d_sort < 0) then + d_sort = anz_dialogs + end + -- make sure there is enough room for internal sorting + d.d_sort = d_sort + (anz_generic * 100000) + anz_dialogs = anz_dialogs + 1 + + -- remember where this generic dialog comes from and that it is a generic one + d.is_generic = append_str + -- store this new dialog with its new ID + -- Note: This may also create d_got_item_n_ + d.d_id = yl_speak_up.rewrite_dialog_id(d, "d_id", start_dialog, append_str) + -- the dialog ID will only be equal to start_dialog after adding append_str, + -- so...check again here + if(d.d_id == start_dialog) then + d.d_id = "d_generic_start_dialog" + end + yl_speak_up.generic_dialogs[n_id][d.d_id] = d + end + + start_dialog = "d_generic_start_dialog" + -- make the necessary adjustments for the options of the start_dialog + local options = yl_speak_up.generic_dialogs[n_id][start_dialog].d_options + if(not(options)) then + return "There are no options/answers that might be injected/made generic." + end + local new_options = {} + local anz_options = 0 + for o_id, o in pairs(options) do + -- o_sort needs to be set accordingly + local o_sort = tonumber(o.o_sort or "0") + if(not(o_sort) or o_sort < 0) then + o_sort = anz_options + end + -- make sure there is enough room for internal sorting + o.o_sort = o_sort + (anz_generic * 100000) + anz_options = anz_options + 1 + + -- adjust o_id + o.o_id = o_id..append_str + -- the options of the first dialog need to be renamed to r.r_id_n_id to avoid duplicates + new_options[o_id..append_str] = o + -- TODO: there may be preconditions refering this option which also might need changing + end + yl_speak_up.generic_dialogs[n_id][start_dialog].d_options = new_options + + -- all fine - no error occoured, no error message to display; + -- the NPC's dialog has been added + return "OK" +end + + +yl_speak_up.add_generic_dialogs = function(dialog, current_n_id, player) + if(not(player) or not(current_n_id)) then return dialog end - -- TODO: extend the dialog with generic dialog texts + + -- which is the start dialog of the current NPC? where do we want to insert the options? + local start_dialog_current = yl_speak_up.get_start_dialog_id(dialog) + if(not(start_dialog_current)) then + start_dialog_current = "d_1" + end + -- unconfigured NPC are in special need of generic dialogs + if(not(dialog)) then + dialog = {} + end + if(not(dialog.n_dialogs)) then + dialog.n_dialogs = {} + end + if(not(dialog.n_dialogs[start_dialog_current])) then + dialog.n_dialogs[start_dialog_current] = {} + end + if(not(dialog.n_dialogs[start_dialog_current].d_options)) then + dialog.n_dialogs[start_dialog_current].d_options = {} + end + + -- TODO: supply npc.self directly as parameter? + + -- which generic dialogs shall be included? + local n_id_list = yl_speak_up.calculate_available_generic_dialogs(current_n_id, player) + -- TODO: just debug info +-- minetest.chat_send_player("singleplayer", "Ok, generic option will be included: "..minetest.serialize(n_id_list)) + -- actually integrate those generic parts + for i, n_id in ipairs(n_id_list) do + -- add the dialogs as such first + for d_id, d in pairs(yl_speak_up.generic_dialogs[n_id]) do + -- no need to deep copy here - this is not added in edit mode + dialog.n_dialogs[d_id] = d +-- minetest.chat_send_player("singleplayer","copying d_id: "..tostring(d_id)) + end + -- add the options so that these new dialogs can be accessed + local d = yl_speak_up.generic_dialogs[n_id]["d_generic_start_dialog"] + if(d and d.d_options) then + for o_id, o in pairs(d.d_options) do + -- actually insert the new option + dialog.n_dialogs[start_dialog_current].d_options[o.o_id] = o +-- minetest.chat_send_player("singleplayer","injecting option "..tostring(o_id).." into "..tostring(start_dialog_current)..": "..minetest.serialize(o)) + end + end + end + -- TODO: deal with d_got_item return dialog end + +yl_speak_up.load_generic_dialogs = function() + -- TODO: keep list of added npc dialogs + local n_id = "n_31" + local dialog = yl_speak_up.load_dialog(n_id, false) + -- TODO: do this check when the generic dialog is added + local res = yl_speak_up.check_and_add_as_generic_dialog(dialog, n_id) + if(res == "OK") then + minetest.log("action", "[MOD] yl_speak_up: ".. + "Generic dialog from NPC "..tostring(n_id).." loaded successfully.") + else + minetest.log("action", "[MOD] yl_speak_up: ".. + "Generic dialog from NPC "..tostring(n_id).." failed to load: "..tostring(res)..".") + end +end diff --git a/fs_talkdialog.lua b/fs_talkdialog.lua index ce1e591..b29aae7 100644 --- a/fs_talkdialog.lua +++ b/fs_talkdialog.lua @@ -164,7 +164,8 @@ yl_speak_up.input_talk = function(player, formname, fields) end for k, v in pairs(fields) do - local s = string.split(k, "_") + -- only split into 2 parts at max + local s = string.split(k, "_", false, 2) if s[1] == "button" and s[2] ~= nil and s[2] ~= "" and s[2] ~= "exit" and s[2] ~= "back" and s[3] ~= nil and @@ -271,7 +272,7 @@ end -- helper function for -- yl_speak_up.get_fs_talkdialog and --- yl_speak_up.check_generic_dialog_get_errors +-- yl_speak_up.check_and_add_as_generic_dialog -- find the dialog with d_sort == 0 or lowest number yl_speak_up.get_start_dialog_id = function(dialog) if(not(dialog) or not(dialog.n_dialogs)) then diff --git a/init.lua b/init.lua index bf13608..20df128 100644 --- a/init.lua +++ b/init.lua @@ -107,4 +107,8 @@ minetest.mkdir(yl_speak_up.worldpath..yl_speak_up.inventory_path) yl_speak_up.mob_table = yl_speak_up.init_mob_table() or {} +-- initialize and load all registered generic dialogs +yl_speak_up.load_generic_dialogs() + minetest.log("action","[MOD] yl_speak_up loaded") +