From 8e0f53fdb51e9716ff65c51f7fccd75bc368ec85 Mon Sep 17 00:00:00 2001 From: Sokomine Date: Tue, 7 Jan 2025 21:04:37 +0100 Subject: [PATCH] added import_from_ink.lua - but no input for that yet --- export_to_ink.lua | 2 +- functions_dialogs.lua | 2 +- import_from_ink.lua | 1032 +++++++++++++++++++++++++++++++++++++++++ init.lua | 2 + 4 files changed, 1036 insertions(+), 2 deletions(-) create mode 100644 import_from_ink.lua diff --git a/export_to_ink.lua b/export_to_ink.lua index 4ff70d9..09caebf 100644 --- a/export_to_ink.lua +++ b/export_to_ink.lua @@ -410,7 +410,7 @@ yl_speak_up.export_to_ink_language = function(dialog, use_prefix) local tmp = {"-> ", main_loop, "\n=== ", main_loop, " ===", "\nWhat do you wish to do?", - "\n+ Talk to ", tostring(dialog.n_npc), " -> ", use_prefix..tostring(start_dialog), + "\n+ Talk to ", tostring(dialog.n_npc or prefix or "-unknown-"), " -> ", use_prefix..tostring(start_dialog), "\n+ End -> END"} local vars_used = ink_export.print_variables_used(tmp, dialog) diff --git a/functions_dialogs.lua b/functions_dialogs.lua index 292627c..b42df08 100644 --- a/functions_dialogs.lua +++ b/functions_dialogs.lua @@ -156,7 +156,7 @@ end yl_speak_up.update_dialog = function(log, dialog, dialog_name, dialog_text) if(dialog_name and yl_speak_up.is_special_dialog(dialog_name)) then -- d_trade, d_got_item, d_dynamic and d_end are not imported because they need to be handled diffrently - table.insert(log, "Note: Not importing dialog \""..tostring(dialog_name).."\" because it is a special dialog.") + table.insert(log, "Note: Not importing dialog text for \""..tostring(dialog_name).."\" because it is a special dialog.") -- the options of thes special dialogs are still relevant return dialog_name end diff --git a/import_from_ink.lua b/import_from_ink.lua new file mode 100644 index 0000000..c5ba066 --- /dev/null +++ b/import_from_ink.lua @@ -0,0 +1,1032 @@ +-- create the table for the functions: +yl_speak_up.parse_ink = {} +-- add a local abbreviation: +local parse_ink = yl_speak_up.parse_ink + +-- remove leading and tailing blanks, spaces, tabs, newlines and equal signs from names +-- of knots and stitches; +-- helper function for the parser +parse_ink.strip_name = function(s) + local CHAR_EQUAL = string.byte("=", 1) + local CHAR_BLANK = string.byte(" ", 1) + local CHAR_TAB = string.byte("\t", 1) + local CHAR_NEWLINE = string.byte('\n', 1) + local i = 1 + local k = string.len(s) + local b = string.byte(s, i) + while(i < k and (b==CHAR_EQUAL or b==CHAR_BLANK or b==CHAR_TAB or b==CHAR_NEWLINE)) do + i = i + 1 + b = string.byte(s, i) + end + b = string.byte(s, k) + while(k > i and (b==CHAR_EQUAL or b==CHAR_BLANK or b==CHAR_TAB or b==CHAR_NEWLINE)) do + k = k - 1 + b = string.byte(s, k) + end + return string.sub(s, i, k) +end + + +-- the actual parser for ink (more or less) +parse_ink.parse = function(text, print_debug) + -- just an abbreviation + local strip_name = parse_ink.strip_name + -- this is what this function shall find: + local knots = {} + + -- first: scan for things that have to start at the *beginning* of a line; + -- this limitation may or may not be part of ink (hard to tell), but it does + -- make parsing a lot easier while not imposing an undue limitation on writers + + -- optional whitespaces/tabs are allowed; + local CHAR_NEWLINE = string.byte('\n', 1) + -- whitespace characters + local CHAR_BLANK = string.byte(" ", 1) + local CHAR_TAB = string.byte("\t", 1) + -- allow to escape things at the start of a line + local CHAR_ESCAPE = string.byte("\\", 1) + + -- covers "* " choices and "*/" end of multiline comment + local CHAR_STAR = string.byte("*", 1) + local CHAR_SLASH = string.byte("/", 1) + local CHAR_PLUS = string.byte("+", 1) + local CHAR_MINUS = string.byte("-", 1) + -- for diverts + local CHAR_GREATER = string.byte(">", 1) + -- for text that is gluecd together + local CHAR_SMALLER = string.byte("<", 1) + -- == for knots; = for stitches + local CHAR_EQUAL = string.byte("=", 1) + -- for code: + local CHAR_TILDE = string.byte("~", 1) + -- for INCLUDE + local CHAR_I = string.byte("I", 1) + -- for VAR + local CHAR_V = string.byte("V", 1) + -- for CONST + local CHAR_C = string.byte("C", 1) + + -- detect labels at the start of choices and gathers + local CHAR_LABEL_START = string.byte("(", 1) + local CHAR_LABEL_END = string.byte(")", 1) + + -- this is used for a lot of things: + -- * printing values for variables + -- * checking conditions + -- * if/else and switch statements - which may even cover multiple lines + -- * alternatives, cycles, shuffles etc. + local CHAR_COND_START = string.byte("{", 1) + local CHAR_COND_END = string.byte("}", 1) + + local i = 1 + local i_max = string.len(text) + local at_line_start = false + -- TODO: turn that into constants? + local what = "" + local last_what = "" + local multiline_comment = false + local inline_comment = false + -- knots and stitches have names + local search_for_name = nil + local name_found = nil + local search_for_nesting = nil + -- choices and gathers can be nested + local nested = 0 + local last_nested = 0 + local search_for_label = nil + -- choices and gathers can have labels + local label_start = -1 + local label_found = nil + -- choices can have conditions + local search_for_condition = nil + local cond_start = -1 + local conditions = {} + -- the actual text/content of a choice or gather + local content = "" + -- we start with text (usually a divert to the main knot) + local content_start = 1 + -- the main program text...not a knot at the start + local content_type = "MAIN_PROGRAM" + local last_content_type = content_type + -- we need to know if we're inside a mutiline condition or alternative + local counted_curly_brackets = 0 + + local started_at = 1 -- used for printing out the line for debugging + while(i < i_max) do + local b = string.byte(text, i) + if( b == CHAR_NEWLINE) then + -- this is text (from node, stitch, gather, choice) that spans more than + -- one line + if(not(multiline_comment) + and not(inline_comment)) then + content = content..string.sub(text, content_start, i) + end + if(search_for_name) then + name_found = content + content = "" + search_for_name = false + end + -- the content of this line may become relevant + content_start = i + 1 + + at_line_start = true + + + if(print_debug) then + print("|LINE| "..string.sub(text, started_at, i - 1)) + end + if(what == "START_OF_MULTILINE_COMMENT") then + what = "INSIDE_MULTILINE_COMMENT" + else + what = "" + end + + started_at = i + 1 + -- inline comments end here + inline_comment = false + search_for_nesting = nil + search_for_label = nil + search_for_condition = nil + search_for_name = nil + elseif(multiline_comment) then + -- nothing can start inside a multilne comment apart from it ending + -- (we only allow multiline comments to start and end in their own lines, + -- not inside other texts or structures) + if(b == CHAR_STAR and string.byte(text, i + 1) == CHAR_SLASH) then + what = "END_OF_MULTILINE_COMMENT" + multiline_comment = false + -- just to be sure that the rest of the line is really skipped + inline_comment = true + at_line_start = false + end + -- TODO: handle inline comments + elseif(not(at_line_start)) then + -- choices and gathers can be nested + if(search_for_nesting) then + -- a divert -> is following a gather - + if((b == CHAR_MINUS and string.byte(text, i + 1) == CHAR_GREATER) + -- if escaped: no point searching for label or conditions + or b == CHAR_ESCAPE) then + search_for_nesting = nil + search_for_label = nil + search_for_condition = nil + elseif(b == search_for_nesting) then + nested = nested + 1 + elseif(b ~= CHAR_BLANK and b ~= CHAR_TAB) then + search_for_nesting = nil + -- choices (* and +) and gathers (-) may be followed by a (label): + if(b == CHAR_LABEL_START) then + label_start = i + 1 + search_for_label = CHAR_LABEL_END + -- if no label: did we find the start of a condition already? + elseif(b == CHAR_COND_START) + and (what == "NORMAL_CHOICE" or what == "STICKY_CHOICE") then + cond_start = i + 1 + search_for_condition = CHAR_COND_END + else + content_start = i + content = "" + end + end + elseif(search_for_label and search_for_label == CHAR_LABEL_END) then + if(b == CHAR_LABEL_END) then + label = string.sub(text, label_start, i-1) + search_for_label = nil + if(what == "NORMAL_CHOICE" or what == "STICKY_CHOICE") then + search_for_condition = CHAR_COND_START + end + content_start = i + 1 + content = "" + end + + -- choices can have multiple conditions (at least they are not multiline) + elseif(search_for_condition and search_for_condition == CHAR_COND_START) then + if(b == CHAR_COND_START) then + cond_start = i + 1 + search_for_condition = CHAR_COND_END + elseif(b ~= CHAR_BLANK and b ~= CHAR_TAB) then + -- no condition found + cond_start = -1 + search_for_condition = nil + -- next we get the actual text of the choice or gather + content_start = i + content = "" + end + elseif(search_for_condition and search_for_condition == CHAR_COND_END) then + if(b == CHAR_COND_END) then + table.insert(conditions, string.sub(text, cond_start, i-1)) + cond_start = -1 + -- there may be more conditions comming + search_for_condition = CHAR_COND_START + end + end + + -- not at line start: multiline alternatives or conditions + if(b == CHAR_COND_START and string.byte(text, i - 1) ~= CHAR_ESCAPE) then + -- one more open + counted_curly_brackets = counted_curly_brackets + 1 + elseif(b == CHAR_COND_END and string.byte(text, i - 1) ~= CHAR_ESCAPE) then + counted_curly_brackets = counted_curly_brackets - 1 + end + + -- nothing to do; we read until the end of the line + -- TODO: there are some inline things we need to check for as well + -- TODO: inline commends need to be processed here + elseif(b == CHAR_BLANK or b == CHAR_TAB) then + -- nothing to do; real start of the line not yet found + at_line_start = true + -- blanks at the beginning are usually just identation for better + -- readability of weaved content - best ignore these + if(not(multiline_comment) + and not(inline_comment) + and content_start > -1 + and content_start < i + 1) then + content_start = i + 1 + end + + elseif(at_line_start) then + last_what = what + last_nested = nested + nested = 1 + + -- "\" (escape the next character) + if( b == CHAR_ESCAPE) then + -- the line can no longer start with a special char; it is text + what = "TEXT" + -- at line start: multiline alternatives or conditions + elseif(b == CHAR_COND_START and string.byte(text, i - 1) ~= CHAR_ESCAPE) then + -- one more open + counted_curly_brackets = counted_curly_brackets + 1 + what = "TEXT" + elseif(b == CHAR_COND_END and string.byte(text, i - 1) ~= CHAR_ESCAPE) then + counted_curly_brackets = counted_curly_brackets - 1 + what = "TEXT" + -- "//" (start of inline comment) or "/* " (start of multiline comment) + elseif(b == CHAR_SLASH) then + local b2 = string.byte(text, i + 1) + if( b2 == CHAR_SLASH) then + what = "COMMENT" -- inline comment + -- remember that we are inside a comment + inline_comment = true + -- the text of the comment is not part of the content + content_start = -1 + elseif(b2 == CHAR_STAR) then + what = "START_OF_MULTILINE_COMMENT" + -- remember that we are inside a multiline comment + multiline_comment = true + -- the text of the multiline comment is not part of the content + content_start = -1 + else + what = "ERROR:_UNKNOWN_SYMBOL_FOLLOWING_\"/\"" + end + -- "*" (choice) - no matter what follows - it's a choice + elseif(b == CHAR_STAR) then + if(counted_curly_brackets > 0) then + -- we are inside a condition or alternate + -- normally choices would be allowed here in ink - but that'd be + -- too complicated + what = "TEXT" + else + what = "NORMAL_CHOICE" + nested = 1 + search_for_nesting = b + end + -- "+" (sticky choice) - no mater what follows - it's a sticky choice + elseif(b == CHAR_PLUS) then + if(counted_curly_brackets > 0) then + -- we are inside a condition or alternate + -- normally choices would be allowed here in ink - but that'd be + -- too complicated + what = "TEXT" + else + what = "STICKY_CHOICE" + nested = 1 + search_for_nesting = b + end + -- "-" (gather) or "->" divert + elseif(b == CHAR_MINUS) then + local b2 = string.byte(text, i + 1) + if(b2 == CHAR_GREATER) then + what = "TEXT" --DIVERT" + elseif(counted_curly_brackets > 0) then + -- we are inside a condition or alternate + what = "TEXT" + else + -- TODO: we might be inside a multiline if/else/switch branch + what = "GATHER" + nested = 1 + search_for_nesting = b + end + -- "==" (knot) or "= " (stitch) + elseif(b == CHAR_EQUAL) then + local b2 = string.byte(text, i + 1) + if( b2 == CHAR_EQUAL) then + what = "KNOT" + search_for_name = true + else + what = "STITCH" + search_for_name = true + end + -- "<>" (glue) apart from that it's a normal text line + elseif(b == CHAR_SMALLER) then + local b2 = string.byte(text, i + 1) + if( b2 == CHAR_GREATER) then + what = "TEXT_GLUE" + end + -- "~ " (code/calculation) + elseif(b == CHAR_TILDE) then + local b2 = string.byte(text, i + 1) + what = "CODE" + -- "INCLUDE " (code/calculation) + elseif(b == CHAR_I) then + local s = string.sub(text, i, i + 7) + if( s == "INCLUDE ") then + what = "INCLUDE" + else + -- else it is just text starting with a capital I + what = "TEXT" + end + -- "VAR " (variable definition) + elseif(b == CHAR_V) then + local s = string.sub(text, i, i + 3) + if( s == "VAR ") then + what = "VAR" + else + -- else it is just text starting with a capital V + what = "TEXT" + end + -- "CONST " (variable definition) + elseif(b == CHAR_C) then + local s = string.sub(text, i, i + 5) + if( s == "CONST ") then + what = "CONST" + else + -- else it is just text starting with a capital V + what = "TEXT" + end + else + what = "TEXT" + end + -- we have processed the start of the line and found a type + at_line_start = false + + -- observation: choices end at the end of a line + if(what == "TEXT" + and (content_type == "NORMAL_CHOICE" or content_type == "STICKY_CHOICE")) then + what = "WEAVE" + end + -- the collected content of the last knot, stitch, gather or choice ends + -- TODO: handle the situation of the last line + if( what == "NORMAL_CHOICE" + or what == "STICKY_CHOICE" + or what == "MAIN_PROGRAM" + or what == "GATHER" + or what == "KNOT" + or what == "STITCH" + or what == "WEAVE") then + -- there may have been content in the last line + if(content_start > -1) then + content = content..string.sub(text, content_start, i - 1) + end + -- remove tailing newlines and blanks + if(content) then + content = strip_name(content) + end + local divert_to = "" + if(content_type == "NORMAL_CHOICE" or content_type == "STICKY_CHOICE") then + local start_1, start = string.find(content, "->") + if(start) then + start = start + 1 + while(start < string.len(content) + and string.byte(content, start) == CHAR_BLANK) do + start = start + 1 + end + local ende = start + 1 + while(ende < string.len(content) + and string.byte(content, ende) ~= CHAR_BLANK + and string.byte(content, ende) ~= CHAR_NEWLINE) do + ende = ende + 1 + end + divert_to = strip_name(string.sub(content, start, ende)) + content = strip_name(string.sub(content, 1, start_1 - 1)) + end + end + + local knot = {} + knot.content_type = content_type + knot.name = strip_name(name_found or "") + knot.label = label + knot.nested = last_nested + knot.conditions = conditions + knot.text = content + knot.divert_to = divert_to + table.insert(knots, knot) + + if(not(print_debug)) then + name_found = nil + else + local s = " FOUND: "..content_type + if(name_found) then + -- name of a knot or stitch + -- TODO: strip leading and tailing blanks, tabs and = from found_name + s = s.." Name: "..tostring(knot.name) + name_found = nil + end + if(last_nested and last_nested > 0) then + s = s.."\n NESTED: "..tostring(last_nested) + end + if(label) then + s = s.."\n LABEL: ["..tostring(label).."]" + end + if(conditions and #conditions > 0) then + s = s.."\n COND: ["..table.concat(conditions, "] [").."]" + end + if(divert_to and divert_to ~= "") then + s = s.."\n TARGET: ["..tostring(divert_to).."]" + end + if(content and content ~= "") then + s = s.."\n STR: ["..tostring(content).."]:STR\n" + end + print(s) + end + + last_content_type = content_type + content_type = what + -- the old content ends + content_start = i + content = "" + + label = nil + conditions = {} + counted_curly_brackets = 0 + end + end + -- continue with the next charcter + i = i + 1 + end + return knots +end + + + +-- TODO: intended for future parsing of ink inline functionality +parse_ink.inspect_inline = function(text) + + local CHAR_INLINE_START = string.byte("{", 1) + local CHAR_INLINE_END = string.byte("}", 1) + local CHAR_ESCAPE = string.byte("\\", 1) + local CHAR_SEPERATOR = string.byte("|", 1) + local CHAR_ML_SEPERATOR = string.byte("-", 1) + local CHAR_DOPPELPUNKT = string.byte(":", 1) + local CHAR_NEWLINE = string.byte('\n', 1) + local CHAR_BLANK = string.byte(" ", 1) + local CHAR_TAB = string.byte("\t", 1) + local CHAR_CYCLE = string.byte("&", 1) + local CHAR_ONCE_ONLY = string.byte("!", 1) + local CHAR_SHUFFLE = string.byte("~", 1) + + local inline_starts = {} + local sep_starts = {} + local is_multiline = {} + local level = 0 + local i = 1 + local i_max = string.len(text) + while(i < i_max) do + local b = string.byte(text, i) + if(at_line_start and level > 0 and is_multiline[level] + and (b ~= CHAR_ML_SEPERATOR and b ~= CHAR_BLANK and b ~= CHAR_TAB)) then + at_line_start = false + end + if(string.byte(text, i-1) == CHAR_ESCAPE) then + -- do nothing; escape sign + elseif( b == CHAR_INLINE_START) then + table.insert(inline_starts, i) + table.insert(sep_starts, {i}) + table.insert(is_multiline, false) + level = level + 1 + elseif(level == 0) then + -- not inside an inline expression; do nothing + elseif(b == CHAR_NEWLINE) then + at_line_start = true + is_multiline[level] = true + elseif(b == CHAR_ML_SEPERATOR and is_multiline[level] and at_line_start) then +-- if(#sep_starts[level] == 1 and string.byte(text, sep_starts[level][1] == TODO) + table.insert(sep_starts[level], i) + elseif(b == CHAR_SEPERATOR and not(is_multiline[level])) then + table.insert(sep_starts[level], i) + elseif(b == CHAR_DOPPELPUNKT and #sep_starts[level] == 1) then + print("Condition: "..string.sub(text, sep_starts[level][1] + 1, i)) + -- up until now we had the condition + sep_starts[level][1] = i + elseif(b == CHAR_INLINE_END) then + local prefix = "" + for j = 1, level do + prefix = " "..prefix + end + local t = prefix..string.sub(text, inline_starts[level], i) + if(is_multiline[level]) then + print("MULTIL: "..tostring(t)) + else + print("INLINE: "..tostring(t)) + end + + table.insert(sep_starts[level], i) + local max = #sep_starts[level] + for c, pos in ipairs(sep_starts[level]) do + if(c < max) then + local t2 = prefix.." "..tostring(c)..") ".. string.sub(text, + pos + 1, sep_starts[level][c+1] - 1) + print(t2) + end + end + table.remove(sep_starts, level) + table.remove(is_multiline, level) + + local b2 = string.byte(text, inline_starts[level] + 1) +-- if(b2 == CHAR_CYCLE) then + +-- -- get the inline part +-- j = inline_starts[level] + 1 +-- -- remove leading blanks +-- while(j < i and string.byte(text, j) == CHAR_BLANK) do +-- j = j + 1 +-- end +-- -- remove tailing blanks +-- k = i +-- while(k > j and string.byte(text, j) == CHAR_BLANK) do +-- k = k - 1 +-- end +-- + table.remove(inline_starts, level) + level = level - 1 + end + i = i + 1 + end +end + + +-- analyze the intermediate knot data strutcture parse_ink.parse created: + +-- log to a table (debug_level > 0) and additionally to stdout (debug_level > 1) +parse_ink.print_debug = function(log_level, log, text) + if(not(log_level) or log_level < 1) then + return + end + if(log_level > 1) then + print(text) + end + if(not(log) or log_level <= 1) then + return + end + table.insert(log, text) +end + + +-- actions and effects are knots in ink but only part of the dialog in NPC dialogs; +-- find out which of their option knots serves which purpose +parse_ink.analyze_actions_or_effects = function(knot_list, what_to_analyze, cap_name, log_level, log) + parse_ink.print_debug(log_level, log, "\nAnalyzing "..what_to_analyze.."s:") + local success_str = "["..cap_name.." was successful]" + local failure_str = "["..cap_name.." failed]" + + for knot_name, knot in pairs(knot_list) do + parse_ink.print_debug(log_level, log, " Found "..tostring(what_to_analyze)..": "..tostring(knot_name)) + -- examine options (we are mostly intrested in their links) + for o_name, o_knot in pairs(knot.options) do + if(o_knot.text and o_knot.text == success_str) then + knot_list[knot_name].on_success = o_knot.divert_to + elseif(o_knot.text and o_knot.text == failure_str) then + knot_list[knot_name].on_failure = o_knot.divert_to + elseif(o_knot.text and o_knot.text == "[Back]") then + knot_list[knot_name].back = o_knot.divert_to + else + -- log that error regardless of log_level + parse_ink.print_debug(2, log, + "ERROR: Unsupported text \""..tostring(o_knot.text).. + "\" for option \""..tostring(o_name).. + "\" of "..what_to_analyze.." in knot name \"".. + tostring(knot_name).."\".") + end +-- print("o_knot "..tostring(o_name).." target: "..tostring(o_knot.divert_to or "- none -")) + end + end +end + + +-- this serarches for the *real* target dialog; +-- it populates option_knot[option_name].actions and option_knot.effects with the +-- actions and effects it encountered +-- (there had to be extra knots inserted for actions and effects - which are no dialogs in NPC +-- dialog sense but are required as ink knots so that divert_to can be used in a sensible way; +-- after all actions and effects can fail) +parse_ink.find_real_target_dialog = function(option_knot, target, prefix, start_dialog, dialogs, actions, effects, log_level, log) +-- print(" option_knot: "..tostring(option_knot).." Next link: "..tostring(target)) + -- go back to the start + if(not(target) or target == "" or target == start_dialog) then + return start_dialog + -- we found the end of the conversation - or another dialog + elseif(target == "END" or dialogs[target]) then + return target + -- we found a special dialog + elseif(yl_speak_up.is_special_dialog(parse_ink.strip_prefix(target, prefix))) then + return parse_ink.strip_prefix(target, prefix) + -- we found an action + elseif(actions[target]) then + -- avoid loops by restricting the number of actions visited + if(#option_knot.actions > 0) then + parse_ink.print_debug(2, log, + "WARNING: Aborting finding real target dialog. ".. + "Only one action allowed per option. Using start dialog instead.") + return start_dialog + end + -- remember that we visited this action + table.insert(option_knot.actions, target) + -- recursively find the option that corresponds to the success option + return parse_ink.find_real_target_dialog(option_knot, actions[target].on_success, prefix, start_dialog, dialogs, actions, effects, log_level, log) + -- we found an effect + elseif(effects[target]) then + -- avoid loops by restricting the number of effects visited + if(#option_knot.effects > 20) then + parse_ink.print_debug(2, log, + "WARNING: Aborting finding real target dialog. ".. + "Too many effects chained. Using start dialog instead.") + return start_dialog + end + -- remember that we visited this efffect + table.insert(option_knot.effects, target) + -- recursively find the option that corresponds to the success option + return parse_ink.find_real_target_dialog(option_knot, effects[target].on_success, prefix, start_dialog, dialogs, actions, effects, log_level, log) + -- the name was not found + else + parse_ink.print_debug(2, log, + "WARNING: Aborting finding real target dialog. ".. + "Could not find target dialog \""..tostring(target).."\". Using start dialog.") + return start_dialog + end +end + + +parse_ink.print_knots = function(knots) + -- debug output + for i, knot in ipairs(knots) do + -- TODO: knot.conditions + print("Knot: "..tostring(knot.content_type).." ["..tostring(knot.nested).."] ".. + tostring(knot.name).." ("..tostring(knot.label)..")".. + "\n Text: "..tostring(knot.text)) + end +end + + +-- analyzes all knots and populates the lists knot_data, dialogs, actions and effects +-- with the names of the knots +parse_ink.analyze_knots_by_type = function(knots, log_level, log) + -- start interpreting the meaning of the knots + local knot_data = {} + local dialogs = {} + local actions = {} + local effects = {} + local start_knot = nil + local last_knot_name = nil + local dialog_list = {} + parse_ink.print_debug(log_level, log, "\nIdentifying dialogs, actions, effects and options:") + for i, knot in ipairs(knots) do + if(knot.content_type == "KNOT") then + if(knot_data[knot_name]) then + parse_ink.print_debug(2, log, + "WARNING: Knot "..tostring(knot_name).." already defined.\n".. + "Using this new data and discarding old.") + end + knot_data[knot.name] = knot + knot_data[knot.name].options = {} + knot_data[knot.name].option_list = {} + last_knot_name = knot.name + -- actions and effects are just simulated as knots - they do not represent + -- any actual dialogs in the NPC sense + if(string.sub(knot.text, 1, 9) == ":action: ") then + actions[knot.name] = knot + parse_ink.print_debug(log_level, log, "ACTION: "..tostring(knot.name).."\n") + elseif(string.sub(knot.text, 1, 9) == ":effect: ") then + effects[knot.name] = knot + parse_ink.print_debug(log_level, log, "EFFECT: "..tostring(knot.name).."\n") + else + dialogs[knot.name] = knot + -- remember the sort order of the options + parse_ink.print_debug(log_level, log, "=== "..tostring(knot.name).." ===\n") + -- keep the order of appearance of the dialog names + table.insert(dialog_list, knot.name) + end + elseif(last_knot_name + and (knot.content_type == "NORMAL_CHOICE" or knot.content_type == "STICKY_CHOICE")) then + -- we need a label for the option so that it can be referenced correctly + -- between ink and NPC dialogs; if none is provided (new dialog), then we'll + -- create a temporary name (there may also be grey_out_ options); + -- [Farewell!] is also still included + if(not(knot.label) or knot.label == "") then + knot.label = "new_"..tostring(i) + end + knot_data[last_knot_name].options[knot.label] = knot + table.insert(knot_data[last_knot_name].option_list, knot.label) + parse_ink.print_debug(log_level, log, "- "..tostring(knot.label or "ERROR")..": "..knot.text) + elseif(knot.content_type == "WEAVE" + -- this is not really a WEAVE - it's the effects list and divert of an option + and i > 1 + and (knots[i-1].content_type == "NORMAL_CHOICE" + or knots[i-1].content_type == "STICKY_CHOICE") + and (knots[i-1].divert_to + or knots[i-1].divert_to == "")) then + local p = string.find(knot.text or "", "-> ") + if(p) then + knots[i-1].divert_to = string.sub(knot.text or "", p + 3) + if(p > 4) then + knots[i-1].effect_list = string.sub(knot.text, 1, p - 2) + else + knots[i-1].effect_list = "" + parse_ink.print_debug(2, log, + "ERROR: WEAVE with no text apart from divert: [".. + tostring(knot.text).."]") + end + elseif(knot.text) then + knots[i-1].text2 = knot.text + end + elseif(knot.content_type == "MAIN_PROGRAM") then + start_knot = knot + else + -- TODO "GATHER" + -- TODO "STITCH" + -- TODO "WEAVE" (real ones) + parse_ink.print_debug(2, log, + "ERROR: Cannot handle knots of type \""..tostring(knot.content_type).."\" yet.") + end + end + + return {knot_data = knot_data, dialogs = dialogs, actions = actions, effects = effects, start_knot = start_knot, dialog_list = dialog_list} +end + + + +parse_ink.find_dialog_name_prefix = function(start_knot, log_level, log) + -- the ink program has to start with "-> PREFIXd_end" (with PREFIX usually beeing something + -- like n_, so i.e. "-> n_105_d_end" might be a valid first line + -- Export to ink supports prefixes. + -- Prefixes are particulary useful if you want to combine the texts of multiple NPC in one + -- ink program for testing. + local prefix = "" + if(not(start_knot) or not(start_knot.text)) then + parse_ink.print_debug(2, log, + "ERROR: MAIN_PROGRAM/divert_to start knot not found. Please start your ink file ".. + "with a divert to the main dialog!") + else + prefix = start_knot.text + end + if(string.sub(prefix, string.len(prefix) - 4) ~= "d_end" + or string.sub(prefix, 1, 3) ~= "-> ") then + parse_ink.print_debug(2, log, + "ERROR: Your MAIN_PROGRAM/divert_to ought to contain a prefix (usually n__) ".. + ", followed by d_end. Example: \"-> n_105_d_end\". You used: \"".. + tostring(prefix).."\".") + else + prefix = string.sub(prefix, 4, string.len(prefix) - 5) + end + parse_ink.print_debug(log_level, log, "\nUsing prefix: "..tostring(prefix)) + return prefix +end + + +parse_ink.find_start_dialog_name = function(prefix, dialogs, log_level, log) + -- find out the real start dialog + local start_dialog = prefix.."d_end" + if(dialogs[start_dialog]) then + for o_name, o_knot in pairs(dialogs[start_dialog].options) do + -- the last option that diverts to anything else than END is what we're looking for + if(o_knot.divert_to and o_knot.divert_to ~= "END") then + start_dialog = o_knot.divert_to + end + end + end + parse_ink.print_debug(log_level, log, "\nUsing start dialog: "..tostring(start_dialog)) + return start_dialog +end + + +-- options link to *actions* and *effects* - because that is necessary to simulate +-- NPC behaviour in inc. But for importing that data, we need the real target link +-- that happens when all actions and effects were successful. +parse_ink.adjust_links = function(dialogs, actions, effects, start_dialog, prefix, log_level, log) + -- determine on_success, on_failure and back links for actions and effects + parse_ink.analyze_actions_or_effects(actions, "action", "Action", log_level, log) + parse_ink.analyze_actions_or_effects(effects, "effect", "Effect", log_level, log) + + parse_ink.print_debug(log_level, log, + "\nAssigning actions and effects to their options and adjusting those options' target dialog:") + -- now check if all options are linked properly; for this we check dialogs only + -- (actions and effects are referenced through their options) + for d_name, dialog_knot in pairs(dialogs) do + -- only the options have links + for o_name, o_knot in pairs(dialog_knot.options) do + -- actions that belong to this dialog + o_knot.actions = {} + -- effects that belong to this dialog + o_knot.effects = {} + local old_target = o_knot.divert_to + -- this function also fills the .actions and .effects table with any actions + -- and effects it finds on its way while following the successful option of + -- each action and effect encountered + local target = parse_ink.find_real_target_dialog(o_knot, o_knot.divert_to, prefix, start_dialog, dialogs, actions, effects, log_level, log) + if(target ~= o_knot.divert_to) then + parse_ink.print_debug(log_level, log, + "INFO: Changing target dialog for option \""..tostring(o_name).. + "\" of dialog "..tostring(d_name).. + "\n from "..tostring(o_knot.divert_to).. + "\n to: "..tostring(target).. + "\n Actions: ".. + table.concat(o_knot.actions, ", ").. + "\n Effects: ".. + table.concat(o_knot.effects, ", ")) + o_knot.divert_to = target + end + end + end +end + + +parse_ink.strip_prefix = function(text, prefix) + local i = string.len(prefix) + if(string.sub(text or "", 1, i) == prefix) then + return string.sub(text, i+1) + end + return text +end + + +-- strip "[" and "]" enclosing text +parse_ink.strip_brackets = function(text) + if(not(text)) then + return + end + local p1 = 1 + if(string.sub(text, 1, 1) == "[") then + p1 = 2 + end + local p2 = string.len(text) + -- remove tailing "]" (needed for ink) + if(string.sub(text, string.len(text)) == "]") then + p2 = p2 - 1 + end + return string.sub(text, p1, p2) +end + + + +-- actually doing the import +parse_ink.import_dialogs = function(dialog, dialogs, actions, effects, start_dialog, prefix, orig_dialog_list, log_level, log) + parse_ink.print_debug(log_level, log, "\n\nStarting dialog import:") + + local dialog_list = {} + -- remove the extra wrapper dialog (added for ink) from the list + for i, d_name in ipairs(orig_dialog_list) do + if(d_name ~= prefix.."d_end") then + table.insert(dialog_list, d_name) + end + end + -- we need to add the dialogs as such first so that target_dialog will work + for i, d_name in ipairs(dialog_list) do + local dialog_knot = dialogs[d_name] + local dialog_name = parse_ink.strip_prefix(d_name, prefix) + + local d_id = yl_speak_up.update_dialog(log, dialog, dialog_name, dialog_knot.text) + dialog_knot.d_id = d_id + end + + -- now we can add the options + for i, d_name in ipairs(dialog_list) do + local dialog_knot = dialogs[d_name] + local dialog_name = parse_ink.strip_prefix(d_name, prefix) + local d_id = dialog_knot.d_id + + -- identify and remove options that are grey_out_ texts + -- and store them in table greyed_out + local greyed_out = {} + local tmp_option_list = {} + for i, o_name in ipairs(dialog_knot.option_list) do + if(string.sub(o_name, 1, 9) == "grey_out_") then + local o_id = string.sub(o_name, 10) + greyed_out[o_id] = parse_ink.strip_brackets(dialog_knot.options[o_name].text) + else + table.insert(tmp_option_list, o_name) + end + end + + -- o_random belongs to the dialog; if one option is randomly, then the entire dialog is + local is_random = false + for i, o_name in ipairs(tmp_option_list) do + if(string.sub(o_name, 1, 9) == "randomly_") then + is_random = true + end + end + -- we now have all information to decude about the dialogs' o_random + if(is_random and not(dialog.n_dialogs[d_id].o_random)) then + parse_ink.print_debug(log_level, log, "Changed DIALOG \""..tostring(d_id).."\" to RANDOMLY SELECTED.") + dialog.n_dialogs[d_id].o_random = 1 + elseif(not(is_random) + and dialog.n_dialogs[d_id] + and dialog.n_dialogs[d_id].o_random) then + parse_ink.print_debug(log_level, log, "Changed DIALOG \""..tostring(d_id).."\" back to normal (was: RANDOMLY SELECTED).") + dialog.n_dialogs[d_id].o_random = nil + end + + + for i, o_name in ipairs(tmp_option_list) do + local o_knot = dialog_knot.options[o_name] + local option_text = o_knot.text + local visit_only_once = (o_knot.content_type == "NORMAL_CHOICE") + local alternate_text = nil + local target_dialog = o_knot.divert_to + target_dialog = parse_ink.strip_prefix(target_dialog, prefix) + + -- remove leading "[" (needed for ink but not for dialogs) + option_text = parse_ink.strip_brackets(option_text) + +-- if(o_knot.effect_list and o_knot.effect_list ~= "") then +-- print("EFFECT LIST: "..tostring(d_id).." "..tostring(o_name).." "..tostring(o_knot.effect_list)) +-- end + -- extract the alternate text + local p = string.find(o_knot.effect_list or "", "\n#") + if(p and p > 1 and string.sub(o_knot.effect_list, 1, 1) ~= "#") then + alternate_text = string.sub(o_knot.effect_list or "", 1, p) + else + -- TODO: the rest of this is the effect list as comment + p = 1 + end + -- ignore the automaticly added Farewell!-option + if(option_text ~= "Farewell!" or target_dialog == prefix.."d_end") then + local o_id = yl_speak_up.update_dialog_option(log, dialog, d_id, o_name, + option_text, greyed_out[o_name], target_dialog, + alternate_text, visit_only_once, + -- make sure o_sort gets set approprately: + i) + end + -- TODO: deal with preconditions + -- TODO: deal with actions + -- TODO: deal with effects + -- TODO: effects are not parsed yet + end + -- the dialog may already contain further options that are no longer needed; + -- those need o_sort set and a false precondition; update_dialog did prepare the + -- options already for this, and this function here does the necessary cleanup: + yl_speak_up.update_dialog_options_completed(log, dialog, dialog_knot.d_id) + end + + -- make sure the right start dialog is set + yl_speak_up.update_start_dialog(log, dialog, parse_ink.strip_prefix(start_dialog, prefix)) + + return dialog +end + + + + + + +-- helper function - yl_speak_up includes this, but we here not +-- TODO: use the same for export +yl_speak_up.show_effect = function(r) + local text = r.r_type or "ERROR: no r.r_type! " + for k, v in pairs(r or {}) do + if(k == "r_craft_grid") then + text = text.." "..tostring(k)..": "..table.concat(v or {}, ",") + elseif(k ~= "r_id" and k ~= "r_type") then + text = text.." "..tostring(k)..": "..tostring(v) + end + end + return text +end + + +-- parameters: +-- dialog a yl_speak_up NPC dialog data structure containing the dialogs of the NPC +-- text the ink program that is to be parsed +-- log_level how detailled a report shall be created in the table log +-- log table - log entries will be appended to it +parse_ink.import_from_ink = function(dialog, text, log_level, log) + -- parse the ink program; + -- the "false" stands for "do not print out the parsed lines to stdout" + local knots = parse_ink.parse(text, false) + + -- prepare the knots so that they can be turned into dialogs + local res = parse_ink.analyze_knots_by_type(knots, log_level, log) + -- the prefix helps to combine multiple NPC dialogs into one ink program; it is usally n__ + local prefix = parse_ink.find_dialog_name_prefix(res.start_knot, log_level, log) + -- which dialog is the start dialog? + local start_dialog = parse_ink.find_start_dialog_name(prefix, res.dialogs, log_level, log) + -- if there is an action or effect of the type "If the previous effect failed, ..", then the + -- export_to_ink will create extra knots for these actions and effects - and adjust the links; + -- this code here just turns it back to the target dialog that is shown when the action and all + -- effects were successful + parse_ink.adjust_links(res.dialogs, res.actions, res.effects, start_dialog, prefix, log_level, log) + + -- now we're ready to actually import this into the dialog datastructure; uses functions_dialog.lua + parse_ink.import_dialogs(dialog, res.dialogs, res.actions, res.effects, start_dialog, prefix, res.dialog_list, log_level, log) + + return dialog +end + diff --git a/init.lua b/init.lua index 3165626..8302207 100644 --- a/init.lua +++ b/init.lua @@ -231,6 +231,8 @@ yl_speak_up.reload = function(modpath, log_entry) dofile(modpath .. "export_to_ink.lua") dofile(modpath .. "fs/fs_export.lua") + dofile(modpath .. "import_from_ink.lua") + -- edit_mode.lua has been moved to the mod npc_talk_edit: -- dofile(modpath .. "editor/edit_mode.lua")