Mercurial > parpg-source
diff dialogueprocessor.py @ 0:7a89ea5404b1
Initial commit of parpg-core.
author | M. George Hansen <technopolitica@gmail.com> |
---|---|
date | Sat, 14 May 2011 01:12:35 -0700 |
parents | |
children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/dialogueprocessor.py Sat May 14 01:12:35 2011 -0700 @@ -0,0 +1,378 @@ +# This file is part of PARPG. +# +# PARPG 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 3 of the License, or +# (at your option) any later version. +# +# PARPG 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 PARPG. If not, see <http://www.gnu.org/licenses/>. +""" +Provides the core interface to the dialogue subsystem used to process player +L{Dialogues<Dialogue>} with NPCs. +""" +import logging + +from parpg.common.utils import dedent_chomp + +if (__debug__): + from collections import Sequence, MutableMapping + from parpg.dialogue import Dialogue + +logger = logging.getLogger('dialogueprocessor') + +class DialogueProcessor(object): + """ + Primary interface to the dialogue subsystem used to initiate and process a + L{Dialogue} with an NPC. + + To begin a dialogue with an NPC a L{DialogueProcessor} must first be + instantiated with the dialogue data to process and a dictionary of Python + objects defining the game state for testing of response conditionals. The + L{initiateDialogue} must be called to initialized the L{DialogueProcessor}, + and once it is initialized processing of + L{DialogueSections<DialogueSection>} and + L{DialogueResponses<DialogueResponse>} can be initiated via the + L{continueDialogue} and L{reply} class methods. + + The state of dialogue processing is stored via the + L{dialogue_section_stack} class attribute, which stores a list of + L{DialogueSections<DialogueSection>} that have been or are currently being + processed. Each time L{reply} is called with a L{DialogueResponse} its + next_section_id attribute is used to select a new L{DialogueSection} from + the L{dialogue}. The selected L{DialogueSection} is then pushed + onto the end of the L{dialogue_section_stack}, ready to be processed via + L{continueDialogue}. The exception to this rule occurs when L{reply} is + called with a L{DialogueResponse} whose next_section_id attribute is "end" + or "back". "end" terminates the dialogue as described below, while "back" + removes the last L{DialogueSection} on the L{dialogue_section_stack} + effectively going back to the previous section of dialogue. + + The L{DialogueProcessor} terminates dialogue processing once L{reply} is + called with a L{DialogueResponse} whose next_section_id == 'end'. + Processing can also be manually terminated by calling the L{endDialogue} + class method. + + @note: See the dialogue_demo.py script for a complete example of how the + L{DialogueProcessor} can be used. + + @ivar dialogue: dialogue data currently being processed. + @type dialogue: L{Dialogue} + @ivar dialogue_section_stack: sections of dialogue that have been or are + currently being processed. + @type dialogue_section_stack: list of L{DialogueSections<DialogueSection>} + @ivar game_state: objects defining the game state that should be made + available for testing L{DialogueResponse} conditionals. + @type game_state: dict of Python objects + @ivar in_dialogue: whether a dialogue has been initiated. + @type in_dialogue: Bool + + Usage: + >>> game_state = {'pc': player_character, 'quest': quest_engine} + >>> dialogue_processor = DialogueProcessor(dialogue, game_state) + >>> dialogue_processor.initiateDialogue() + >>> while dialogue_processor.in_dialogue: + ... valid_responses = dialogue_processor.continueDialogue() + ... response = choose_response(valid_responses) + ... dialogue_processor.reply(response) + """ + _logger = logging.getLogger('dialogueengine.DialogueProcessor') + + def dialogue(): + def fget(self): + return self._dialogue + + def fset(self, dialogue): + assert isinstance(dialogue, Dialogue), \ + '{0} does not implement Dialogue interface'.format(dialogue) + self._dialogue = dialogue + + return locals() + dialogue = property(**dialogue()) + + def dialogue_section_stack(): + def fget(self): + return self._dialogue_section_stack + + def fset(self, new_value): + assert isinstance(new_value, Sequence) and not \ + isinstance(new_value, basestring), \ + 'dialogue_section_stack must be a Sequence, not {0}'\ + .format(new_value) + self._dialogue_section_stack = new_value + + return locals() + dialogue_section_stack = property(**dialogue_section_stack()) + + def game_state(): + def fget(self): + return self._game_state + + def fset(self, new_value): + assert isinstance(new_value, MutableMapping),\ + 'game_state must be a MutableMapping, not {0}'\ + .format(new_value) + self._game_state = new_value + + return locals() + game_state = property(**game_state()) + + def in_dialogue(): + def fget(self): + return self._in_dialogue + + def fset(self, value): + assert isinstance(value, bool), '{0} is not a bool'.format(value) + self._in_dialogue = value + + return locals() + in_dialogue = property(**in_dialogue()) + + def __init__(self, dialogue, game_state): + """ + Initialize a new L{DialogueProcessor} instance. + + @param dialogue: dialogue data to process. + @type dialogue: L{Dialogue} + @param game_state: objects defining the game state that should be made + available for testing L{DialogueResponse} conditions. + @type game_state: dict of objects + """ + self._dialogue_section_stack = [] + self._dialogue = dialogue + self._game_state = game_state + self._in_dialogue = False + + def getDialogueGreeting(self): + """ + Evaluate the L{RootDialogueSections<RootDialogueSection>} conditions + and return the valid L{DialogueSection} which should be displayed + first. + + @return: Valid root dialogue section. + @rtype: L{DialogueSection} + + @raise: RuntimeError - evaluation of a DialogueGreeting condition fails + by raising an exception (e.g. due to a syntax error). + """ + dialogue = self.dialogue + dialogue_greeting = None + for greeting in dialogue.greetings: + try: + condition_met = eval(greeting.condition, self.game_state) + except Exception as exception: + error_message = dedent_chomp(''' + exception raised in DialogueGreeting {id} condition: + {exception} + ''').format(id=greeting.id, exception=exception) + self._logger.error(error_message) + if (condition_met): + dialogue_greeting = greeting + if (dialogue_greeting is None): + dialogue_greeting = dialogue.default_greeting + + return dialogue_greeting + + def initiateDialogue(self): + """ + Prepare the L{DialogueProcessor} to process the L{Dialogue} by pushing + the starting L{DialogueSection} onto the L{dialogue_section_stack}. + + @raise RuntimeError: Unable to determine the root L{DialogueSection} + defined by the L{Dialogue}. + """ + if (self.in_dialogue): + self.endDialogue() + dialogue_greeting = self.getDialogueGreeting() + self.dialogue_section_stack.append(dialogue_greeting) + self.in_dialogue = True + self._logger.info('initiated dialogue {0}'.format(self.dialogue)) + + def continueDialogue(self): + """ + Process the L{DialogueSection} at the top of the + L{dialogue_section_stack}, run any L{DialogueActions<DialogueActions>} + it contains and return a list of valid + L{DialogueResponses<DialogueResponses> after evaluating any response + conditionals. + + @returns: valid responses. + @rtype: list of L{DialogueResponses<DialogueResponse>} + + @raise RuntimeError: Any preconditions are not met. + + @precondition: dialogue has been initiated via L{initiateDialogue}. + """ + if (not self.in_dialogue): + error_message = dedent_chomp(''' + dialogue has not be initiated via initiateDialogue yet + ''') + raise RuntimeError(error_message) + current_dialogue_section = self.getCurrentDialogueSection() + self.runDialogueActions(current_dialogue_section) + valid_responses = self.getValidResponses(current_dialogue_section) + + return valid_responses + + def getCurrentDialogueSection(self): + """ + Return the L{DialogueSection} at the top of the + L{dialogue_section_stack}. + + @returns: section of dialogue currently being processed. + @rtype: L{DialogueSection} + + @raise RuntimeError: Any preconditions are not met. + + @precondition: dialogue has been initiated via L{initiateDialogue} and + L{dialogue_section_stack} contains at least one L{DialogueSection}. + """ + if (not self.in_dialogue): + error_message = dedent_chomp(''' + getCurrentDialogueSection called but the dialogue has not been + initiated yet + ''') + raise RuntimeError(error_message) + try: + current_dialogue_section = self.dialogue_section_stack[-1] + except IndexError: + error_message = dedent_chomp(''' + getCurrentDialogueSection called but no DialogueSections are in + the stack + ''') + raise RuntimeError(error_message) + + return current_dialogue_section + + def runDialogueActions(self, dialogue_node): + """ + Execute all L{DialogueActions<DialogueActions>} contained by a + L{DialogueSection} or L{DialogueResponse}. + + @param dialogue_node: section of dialogue or response containing the + L{DialogueActions<DialogueAction>} to execute. + @type dialogue_node: L{DialogueNode} + """ + self._logger.info('processing commands for {0}'.format(dialogue_node)) + for command in dialogue_node.actions: + try: + command(self.game_state) + except (Exception,) as error: + self._logger.error('failed to execute DialogueAction {0}: {1}' + .format(command.keyword, error)) + # TODO Technomage 2010-11-18: Undo previous actions when an + # action fails to execute. + else: + self._logger.debug('ran {0} with arguments {1}' + .format(getattr(type(command), '__name__'), + command.arguments)) + + def getValidResponses(self, dialogue_section): + """ + Evaluate all L{DialogueResponse} conditions for a L{DialogueSection} + and return a list of valid responses. + + @param dialogue_section: section of dialogue containing the + L{DialogueResponses<DialogueResponse>} to process. + @type dialogue_section: L{DialogueSection} + + @return: responses whose conditions were met. + @rtype: list of L{DialogueResponses<DialogueResponse>} + """ + valid_responses = [] + for dialogue_response in dialogue_section.responses: + condition = dialogue_response.condition + try: + condition_met = condition is None or \ + eval(condition, self.game_state) + except (Exception,) as exception: + error_message = dedent_chomp(''' + evaluation of condition {condition} for {response} failed + with error: {exception} + ''').format(condition=dialogue_response.condition, + response=dialogue_response, exception=exception) + self._logger.error(error_message) + else: + self._logger.debug( + 'condition "{0}" for {1} evaluated to {2}' + .format(dialogue_response.condition, dialogue_response, + condition_met) + ) + if (condition_met): + valid_responses.append(dialogue_response) + + return valid_responses + + def reply(self, dialogue_response): + """ + Reply with a L{DialogueResponse}, execute the + L{DialogueActions<DialogueAction>} it contains and push the next + L{DialogueSection} onto the L{dialogue_section_stack}. + + @param dialogue_response: response to reply with. + @type dialogue_response: L{DialogueReponse} + + @raise RuntimeError: Any precondition is not met. + + @precondition: L{initiateDialogue} must be called before this method + is used. + """ + if (not self.in_dialogue): + error_message = dedent_chomp(''' + reply cannot be called until the dialogue has been initiated + via initiateDialogue + ''') + raise RuntimeError(error_message) + self._logger.info('replied with {0}'.format(dialogue_response)) + # FIXME: Technomage 2010-12-11: What happens if runDialogueActions + # raises an error? + self.runDialogueActions(dialogue_response) + next_section_id = dialogue_response.next_section_id + if (next_section_id == 'back'): + if (len(self.dialogue_section_stack) == 1): + error_message = dedent_chomp(''' + attempted to run goto: back action but stack does not + contain a previous DialogueSection + ''') + raise RuntimeError(error_message) + else: + try: + self.dialogue_section_stack.pop() + except (IndexError,): + error_message = dedent_chomp(''' + attempted to run goto: back action but the stack was + empty + ''') + raise RuntimeError(error_message) + else: + self._logger.debug( + 'ran goto: back action, restored last DialogueSection' + ) + elif (next_section_id == 'end'): + self.endDialogue() + self._logger.debug('ran goto: end action, ended dialogue') + else: + try: + next_dialogue_section = \ + self.dialogue.sections[next_section_id] + except KeyError: + error_message = dedent_chomp(''' + {0} is not a recognized goto: action or DialogueSection + identifier + ''').format(next_section_id) + raise RuntimeError(error_message) + else: + self.dialogue_section_stack.append(next_dialogue_section) + + def endDialogue(self): + """ + End the current dialogue and clean up any resources in use by the + L{DialogueProcessor}. + """ + self.dialogue_section_stack = [] + self.in_dialogue = False