403 lines
18 KiB
Python
403 lines
18 KiB
Python
# -*- 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)
|