maps/python/CFDialog.py

403 lines
18 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

# -*- 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)