# -*- coding: utf-8 -*- # CFDialog.py - Dialog helper class # # Copyright (C) 2007 Yann Chachkoff # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. # # The author can be reached via e-mail at lauwenmark@gmail.com # What is CFDialog? # ================= # # This is a small set of utility classes, to help you create complex dialogs. # It is made for those who do not want to bother about complex programming, # but just want to make a few dialogs that are better than the @match system # used in the server. # You will not normally use this directly, but will instead want to call # dialog/npc_dialog.py which will handle most common uses for dialogs. # # How to use CFDialog # =================== # # First, create a script that imports the DialogRule and Dialog classes. Add # the following line at the beginning of your script: # # from CFDialog import DialogRule, Dialog # # Next, build the dialog by creating a sequence of several rules made up of # keywords, answers, preconditions, and postconditions. # # - Keywords are what the rule answers to. For example, if you want a rule to # trigger when the player says "hi", then "hi" must appear in the keyword # list. One or more keywords are specified in a string list in the form # ["keyword1", "keyword2" ...]. A "*" character is a special keyword that # means: "match everything", and is useful to create rules that provide # generic answers no matter what the player character says. # # NOTE: Like the @match system, CFDialog converts both keywords and the # things the player says to lowercase before checking for a match, # so it is never necessary to include multiple keywords that only # differ in case. # # - Answers are what the rule will respond, or say, to the player when it is # triggered. This is what the NPC replies to the player. Answers are stored # in a list of one or more strings in the form ["Answer1", "Answer2" ...]. # When there is more than one answer in that list, each time the rule is # triggered, a single random reply will be selected from the list. # # NOTE: Answers may contain line breaks. To insert one, use "\n". # # - Preconditions are checks that must pass in order for a rule to be # triggered. The checks that can be used are to be found in dialog/pre/*.py # Each file describes how to use the check in question. # # - Postconditions are changes that should be made to the player and/or the # game world after the rule triggers. The effects that are available are to # be found in dialog/post/*.py Each file describes how to use the effect in # question. # # - Replies are what the player will be informed of possible replies. # Each should be an array in the form [word, text, type], with # 'word' the actual word the player should say, 'text' the text the player # will actually say if she says the word, 'type' an optional integer # to specify if the text is a regular sentence (0), a reply (1) or a question # to ask (2). # # # Once the rules are all defined, assemble them into a dialog. Each dialog # involves somebody who triggers it, somebody who answers, and also a unique # name so it cannot be confused with other dialogs. Typically, the "one who # triggers" will be the player, and the "one who answers" is an NPC the player # was taking to. You are free to choose whatever you want for the dialog name, # as long as it contains no whitespace or special characters, and as long as # it is not used by another dialog. You can then add the rules you created to # the dialog. Rules are parsed in a given order, so you must add the most # generic answer last. # # http://wiki.metalforge.net/doku.php/cfdialog?s=cfdialog#a_simple_example # # A more complex example # ====================== # # A ./misc/npc_dialog.py script has been written that uses CFDialog, but # allows the dialog data to be written in JSON format. # This also permits the inclusion of additional files to take in more rules # (this is mostly useful when you have a character who has some specific lines # of dialog but also some other lines that are shared with other characters # - the character can reference their specific lines of dialog directly and # include the general ones. # # ../scorn/kar/gork.msg is an example that uses multiple keywords and multiple # precondition values. Whereas the above example has a linear and predicable # conversation paths, note how a conversation with Gork can fork, merge, and # loop back on itself. The example also illustrates how CFDialog can allow # dialogs to affect how other NPCs react to a player. ../scorn/kar/mork.msg # is a completely different dialog, but it is part of a quest that requires # the player to interact with both NPCs in a specific way before the quest # prize can be obtained. With the old @match system, once the player knew # the key words, he could short-circuit the conversation the map designer # intended to occur. CFDialog constrains the player to follow the proper # conversation thread to qualify to receive the quest reward. # # Debugging # ========= # # When debugging, if changes are made to this file, the Crossfire Server must # be restarted for it to register the changes. import Crossfire import string import random import sys import os import CFItemBroker class DialogRule: def __init__(self, keywords, presemaphores, messages, postsemaphores, suggested_response = None): self.__keywords = keywords self.__presems = presemaphores self.__messages = messages self.__postsems = postsemaphores self.__suggestions = suggested_response self.__prefunction = None # The keyword is a string. Multiple keywords may be defined in the string # by delimiting them with vertical bar (|) characters. "*" is a special # keyword that matches anything. def getKeyword(self): return self.__keywords # Messages are stored in a list of strings. One or more messages may be # defined in the list. If more than one message is present, a random # string is returned. def getMessage(self): msg = self.__messages l = len(msg) r = random.randint(0, l - 1) return msg[r] # Return the preconditions of a rule. They are a list of one or more lists # that specify a flag name to check, and one or more acceptable values it # may have in order to allow the rule to be triggered. def getPreconditions(self): return self.__presems # Return the postconditions for a rule. They are a list of one or more # lists that specify a flag to be set in the player file and what value it # should be set to. def getPostconditions(self): return self.__postsems # Return the possible responses to this rule # This is when a message is sent. def getSuggests(self): return self.__suggestions # Return a possible pre function, that will be called to ensure the rule matches. def getPreFunction(self): return self.__prefunction # Define a prefunction that will be called to match the rule. def setPreFunction(self, function): self.__prefunction = function # This is a subclass of the generic dialog rule that we use for determining whether to # 'include' additional rules. class IncludeRule(DialogRule): def __init__(self, presemaphores): DialogRule.__init__(self, None, presemaphores, None, None, None ) class Dialog: # A character is the source that supplies keywords that drive the dialog. # The speaker is the NPC that responds to the keywords. A location is an # unique identifier that is used to distinguish dialogs from each other. def __init__(self, character, speaker, location): self.__character = character self.__location = location self.__speaker = speaker self.__rules = [] # Create rules of the DialogRule class that define dialog flow. An index # defines the order in which rules are processed. FIXME: addRule could # very easily create the index. It is unclear why this mundane activity # is left for the dialog maker. def addRule(self, rule, index): self.__rules.insert(index, rule) # A function to call when saying something to an NPC to elicit a response # based on defined rules. It iterates through the rules and determines if # the spoken text matches a keyword. If so, the rule preconditions and/or # prefunctions are checked. If all conditions they define are met, then # the NPC responds, and postconditions, if any, are set. Postfunctions # also execute if present. # some variable substitution is done on the message here, $me and $you # are replaced by the names of the npc and the player respectively def speak(self, msg): # query the animation system in case the NPC is playing an animation if self.__speaker.Event(self.__speaker, self.__speaker, "query_object_is_animated", 1): return 0 if self.__character.DungeonMaster and msg == 'resetdialog': self.__character.WriteKey(self.keyName(), "", 0) Crossfire.NPCSay(self.__speaker, "Dialog state reset!") return 0 key = self.uniqueKey() replies = None if key in Crossfire.GetPrivateDictionary(): replies = Crossfire.GetPrivateDictionary()[key] Crossfire.GetPrivateDictionary()[key] = None for rule in self.__rules: if self.isAnswer(msg, rule.getKeyword()) == 1: if self.matchConditions(rule) == 1: message = rule.getMessage() message = message.replace('$me', self.__speaker.QueryName()) message = message.replace('$you', self.__character.QueryName()) Crossfire.NPCSay(self.__speaker, message) if rule.getSuggests() != None: for reply in rule.getSuggests(): Crossfire.AddReply(reply[0], reply[1]) Crossfire.GetPrivateDictionary()[key] = rule.getSuggests() self.setConditions(rule) # change the player's text if found if replies != None: for reply in replies: if reply[0] == msg: type = Crossfire.ReplyType.SAY if len(reply) > 2: type = int(reply[2]) Crossfire.SetPlayerMessage(reply[1], type) break return 0 return 1 def uniqueKey(self): return self.__location + '_' + self.__character.QueryName() # Determine if the message sent to an NPC matches a string in the keyword # list. The match check is case-insensitive, and succeeds if a keyword # string is found in the message. This means that the keyword string(s) # only need to be a substring of the message in order to trigger a reply. def isAnswer(self, msg, keywords): for ckey in keywords: if ckey == "*" or msg.lower().find(ckey.lower()) != -1: return 1 return 0 # Check the preconditions specified in rule have been met. Preconditions # are lists of one or more conditions to check. Each condition specifies # a check to perform and the options it should act on. # separate files are used for each type of check. def matchConditions(self, rule): character = self.__character location = self.__location speaker = self.__speaker verdict = True for condition in rule.getPreconditions(): action = condition[0] args = condition[1:] script_args = {'args': args, 'character': character, 'location': location, 'action': action, 'self': self, 'verdict': verdict} path = os.path.join(Crossfire.DataDirectory(), Crossfire.MapDirectory(), 'python/dialog/pre/', action + '.py') if os.path.isfile(path): try: exec(open(path).read(), {}, script_args) verdict = script_args['verdict'] except Exception as ex: Crossfire.Log(Crossfire.LogError, "CFDialog: Failed to evaluate condition %s: %s." % (condition, str(ex))) verdict = False if verdict == False: return 0 else: Crossfire.Log(Crossfire.LogError, "CFDialog: Pre Block called with unknown action %s." % action) return 0 if rule.getPreFunction() != None: if rule.getPreFunction()(self.__character, rule) != True: return 0 return 1 # If a rule triggers, this function goes through each condition and runs the file that handles it. def setConditions(self, rule): character = self.__character location = self.__location speaker = self.__speaker for condition in rule.getPostconditions(): Crossfire.Log(Crossfire.LogDebug, "CFDialog: Trying to apply %s." % condition) action = condition[0] args = condition[1:] path = os.path.join(Crossfire.DataDirectory(), Crossfire.MapDirectory(), 'python/dialog/post/', action + '.py') if os.path.isfile(path): try: exec(open(path).read()) except: Crossfire.Log(Crossfire.LogError, "CFDialog: Failed to set post-condition %s." %condition) else: Crossfire.Log(Crossfire.LogError, "CFDialog: Post Block called with unknown action %s." % action) def keyName(self): return "dialog_" + self.__location # Search the player file for a particular flag, and if it exists, return # its value. Flag names are combined with the unique dialog "location" # identifier, and are therefore are not required to be unique. This also # prevents flags from conflicting with other non-dialog-related contents # in the player file. def getStatus(self, key): character_status=self.__character.ReadKey(self.keyName()) if character_status == "": return "0" pairs=character_status.split(";") for i in pairs: subpair=i.split(":") if subpair[0] == key: return subpair[1] return "0" # Store a flag in the player file and set it to the specified value. Flag # names are combined with the unique dialog "location" identifier, and are # therefore are not required to be unique. This also prevents flags from # conflicting with other non-dialog-related contents in the player file. def setStatus(self, key, value): if value == "*": return ishere = 0 finished = "" character_status = self.__character.ReadKey(self.keyName()) if character_status != "": pairs = character_status.split(";") for i in pairs: subpair = i.split(":") if subpair[0] == key: subpair[1] = value ishere = 1 if finished != "": finished = finished+";" finished = finished + subpair[0] + ":" + subpair[1] if ishere == 0: if finished != "": finished = finished + ";" finished = finished + key + ":" + value self.__character.WriteKey(self.keyName(), finished, 1) # Search the NPC for a particular flag, and if it exists, return # its value. Flag names are combined with the unique dialog "location" # identifier and the player's name, and are therefore are not required # to be unique. This also prevents flags from conflicting with other # non-dialog-related contents in the NPC. def getNPCStatus(self, key): npc_status=self.__speaker.ReadKey(self.keyName() + "_" + self.__character.Name) if npc_status == "": return "0" pairs=npc_status.split(";") for i in pairs: subpair=i.split(":") if subpair[0] == key: return subpair[1] return "0" # Store a flag in the NPC and set it to the specified value. Flag # names are combined with the unique dialog "location" identifier # and the player's name, and are therefore are not required to be unique. # This also prevents flags from conflicting with other non-dialog-related # contents in the player file. def setNPCStatus(self, key, value): if value == "*": return ishere = 0 finished = "" npc_status = self.__speaker.ReadKey(self.keyName() + "_" + self.__character.Name) if npc_status != "": pairs = npc_status.split(";") for i in pairs: subpair = i.split(":") if subpair[0] == key: subpair[1] = value ishere = 1 if finished != "": finished = finished+";" finished = finished + subpair[0] + ":" + subpair[1] if ishere == 0: if finished != "": finished = finished + ";" finished = finished + key + ":" + value self.__speaker.WriteKey(self.keyName() + "_" + self.__character.Name, finished, 1)