source: branches/active/character_customization/game/parpg/dialogueprocessor.py @ 797

Revision 797, 15.4 KB checked in by aspidites, 9 years ago (diff)

Patch by Aspidites:

  • converted print statements to logging messages.
  • all of parpg's log messages go to the same file, but are differenciated by their class names
  • need to find a way to manipulate fife's log level
  • Property svn:executable set to *
  • Property svn:mime-type set to text/plain
RevLine 
[668]1#   This file is part of PARPG.
[680]2#
[668]3#   PARPG is free software: you can redistribute it and/or modify
4#   it under the terms of the GNU General Public License as published by
5#   the Free Software Foundation, either version 3 of the License, or
6#   (at your option) any later version.
[680]7#
[668]8#   PARPG is distributed in the hope that it will be useful,
9#   but WITHOUT ANY WARRANTY; without even the implied warranty of
10#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11#   GNU General Public License for more details.
[680]12#
[668]13#   You should have received a copy of the GNU General Public License
14#   along with PARPG.  If not, see <http://www.gnu.org/licenses/>.
[680]15"""
16Provides the core interface to the dialogue subsystem used to process player
17L{Dialogues<Dialogue>} with NPCs.
18"""
[668]19import logging
20
[736]21from parpg.common.utils import dedent_chomp
[706]22
[684]23if (__debug__):
[706]24    from collections import Sequence, MutableMapping
[736]25    from parpg.dialogue import Dialogue
[684]26
[797]27logger = logging.getLogger('dialogueprocessor')
[668]28
[684]29class DialogueProcessor(object):
[680]30    """
31    Primary interface to the dialogue subsystem used to initiate and process a
32    L{Dialogue} with an NPC.
33   
[684]34    To begin a dialogue with an NPC a L{DialogueProcessor} must first be
35    instantiated with the dialogue data to process and a dictionary of Python
36    objects defining the game state for testing of response conditionals. The
37    L{initiateDialogue} must be called to initialized the L{DialogueProcessor},
38    and once it is initialized processing of
39    L{DialogueSections<DialogueSection>} and
[680]40    L{DialogueResponses<DialogueResponse>} can be initiated via the
41    L{continueDialogue} and L{reply} class methods.
42   
43    The state of dialogue processing is stored via the
44    L{dialogue_section_stack} class attribute, which stores a list of
45    L{DialogueSections<DialogueSection>} that have been or are currently being
46    processed. Each time L{reply} is called with a L{DialogueResponse} its
47    next_section_id attribute is used to select a new L{DialogueSection} from
[684]48    the L{dialogue}. The selected L{DialogueSection} is then pushed
[680]49    onto the end of the L{dialogue_section_stack}, ready to be processed via
50    L{continueDialogue}. The exception to this rule occurs when L{reply} is
51    called with a L{DialogueResponse} whose next_section_id attribute is "end"
52    or "back". "end" terminates the dialogue as described below, while "back"
53    removes the last L{DialogueSection} on the L{dialogue_section_stack}
54    effectively going back to the previous section of dialogue.
55   
[684]56    The L{DialogueProcessor} terminates dialogue processing once L{reply} is
[680]57    called with a L{DialogueResponse} whose next_section_id == 'end'.
58    Processing can also be manually terminated by calling the L{endDialogue}
59    class method.
60   
61    @note: See the dialogue_demo.py script for a complete example of how the
[684]62        L{DialogueProcessor} can be used.
[680]63   
[684]64    @ivar dialogue: dialogue data currently being processed.
65    @type dialogue: L{Dialogue}
66    @ivar dialogue_section_stack: sections of dialogue that have been or are
[680]67        currently being processed.
68    @type dialogue_section_stack: list of L{DialogueSections<DialogueSection>}
[684]69    @ivar game_state: objects defining the game state that should be made
[680]70        available for testing L{DialogueResponse} conditionals.
71    @type game_state: dict of Python objects
[684]72    @ivar in_dialogue: whether a dialogue has been initiated.
[680]73    @type in_dialogue: Bool
74   
75    Usage:
76    >>> game_state = {'pc': player_character, 'quest': quest_engine}
[684]77    >>> dialogue_processor = DialogueProcessor(dialogue, game_state)
78    >>> dialogue_processor.initiateDialogue()
79    >>> while dialogue_processor.in_dialogue:
80    ...     valid_responses = dialogue_processor.continueDialogue()
[680]81    ...     response = choose_response(valid_responses)
[684]82    ...     dialogue_processor.reply(response)
[680]83    """
[684]84    _logger = logging.getLogger('dialogueengine.DialogueProcessor')
[668]85   
[684]86    def dialogue():
87        def fget(self):
88            return self._dialogue
89       
90        def fset(self, dialogue):
91            assert isinstance(dialogue, Dialogue), \
92                '{0} does not implement Dialogue interface'.format(dialogue)
93            self._dialogue = dialogue
94       
95        return locals()
96    dialogue = property(**dialogue())
[680]97   
[684]98    def dialogue_section_stack():
99        def fget(self):
100            return self._dialogue_section_stack
[680]101       
[706]102        def fset(self, new_value):
103            assert isinstance(new_value, Sequence) and not \
104                   isinstance(new_value, basestring), \
105                   'dialogue_section_stack must be a Sequence, not {0}'\
106                   .format(new_value)
107            self._dialogue_section_stack = new_value
[680]108       
[684]109        return locals()
110    dialogue_section_stack = property(**dialogue_section_stack())
111   
112    def game_state():
113        def fget(self):
114            return self._game_state
115       
[706]116        def fset(self, new_value):
117            assert isinstance(new_value, MutableMapping),\
118                   'game_state must be a MutableMapping, not {0}'\
119                   .format(new_value)
120            self._game_state = new_value
[684]121       
122        return locals()
123    game_state = property(**game_state())
124   
125    def in_dialogue():
126        def fget(self):
127            return self._in_dialogue
128       
129        def fset(self, value):
130            assert isinstance(value, bool), '{0} is not a bool'.format(value)
131            self._in_dialogue = value
132       
133        return locals()
134    in_dialogue = property(**in_dialogue())
135   
136    def __init__(self, dialogue, game_state):
137        """
138        Initialize a new L{DialogueProcessor} instance.
139       
[680]140        @param dialogue: dialogue data to process.
141        @type dialogue: L{Dialogue}
142        @param game_state: objects defining the game state that should be made
143            available for testing L{DialogueResponse} conditions.
144        @type game_state: dict of objects
145        """
[684]146        self._dialogue_section_stack = []
147        self._dialogue = dialogue
148        self._game_state = game_state
149        self._in_dialogue = False
150   
[706]151    def getDialogueGreeting(self):
[698]152        """
153        Evaluate the L{RootDialogueSections<RootDialogueSection>} conditions
154        and return the valid L{DialogueSection} which should be displayed
155        first.
156       
157        @return: Valid root dialogue section.
158        @rtype: L{DialogueSection}
[706]159       
160        @raise: RuntimeError - evaluation of a DialogueGreeting condition fails
161            by raising an exception (e.g. due to a syntax error).
[698]162        """
163        dialogue = self.dialogue
[706]164        dialogue_greeting = None
[699]165        for greeting in dialogue.greetings:
[706]166            try:
167                condition_met = eval(greeting.condition, self.game_state)
168            except Exception as exception:
169                error_message = dedent_chomp('''
170                    exception raised in DialogueGreeting {id} condition:
171                    {exception}
172                ''').format(id=greeting.id, exception=exception)
173                self._logger.error(error_message)
174            if (condition_met):
175                dialogue_greeting = greeting
176        if (dialogue_greeting is None):
177            dialogue_greeting = dialogue.default_greeting
[698]178       
[706]179        return dialogue_greeting
[698]180   
[684]181    def initiateDialogue(self):
182        """
[698]183        Prepare the L{DialogueProcessor} to process the L{Dialogue} by pushing
184        the starting L{DialogueSection} onto the L{dialogue_section_stack}.
[685]185       
[706]186        @raise RuntimeError: Unable to determine the root L{DialogueSection}
[685]187            defined by the L{Dialogue}.
[684]188        """
[698]189        if (self.in_dialogue):
190            self.endDialogue()
[706]191        dialogue_greeting = self.getDialogueGreeting()
192        self.dialogue_section_stack.append(dialogue_greeting)
193        self.in_dialogue = True
194        self._logger.info('initiated dialogue {0}'.format(self.dialogue))
[668]195   
[684]196    def continueDialogue(self):
[680]197        """
198        Process the L{DialogueSection} at the top of the
199        L{dialogue_section_stack}, run any L{DialogueActions<DialogueActions>}
200        it contains and return a list of valid
201        L{DialogueResponses<DialogueResponses> after evaluating any response
202        conditionals.
203       
204        @returns: valid responses.
205        @rtype: list of L{DialogueResponses<DialogueResponse>}
[685]206       
207        @raise RuntimeError: Any preconditions are not met.
208       
209        @precondition: dialogue has been initiated via L{initiateDialogue}.
[680]210        """
[685]211        if (not self.in_dialogue):
[706]212            error_message = dedent_chomp('''
213                dialogue has not be initiated via initiateDialogue yet
214            ''')
215            raise RuntimeError(error_message)
[684]216        current_dialogue_section = self.getCurrentDialogueSection()
217        self.runDialogueActions(current_dialogue_section)
218        valid_responses = self.getValidResponses(current_dialogue_section)
[668]219       
220        return valid_responses
221   
[684]222    def getCurrentDialogueSection(self):
[680]223        """
224        Return the L{DialogueSection} at the top of the
225        L{dialogue_section_stack}.
226       
227        @returns: section of dialogue currently being processed.
228        @rtype: L{DialogueSection}
[685]229       
230        @raise RuntimeError: Any preconditions are not met.
231       
232        @precondition: dialogue has been initiated via L{initiateDialogue} and
233            L{dialogue_section_stack} contains at least one L{DialogueSection}.
[680]234        """
[685]235        if (not self.in_dialogue):
[706]236            error_message = dedent_chomp('''
237                getCurrentDialogueSection called but the dialogue has not been
238                initiated yet
239            ''')
240            raise RuntimeError(error_message)
[668]241        try:
[684]242            current_dialogue_section = self.dialogue_section_stack[-1]
[668]243        except IndexError:
[706]244            error_message = dedent_chomp('''
245                getCurrentDialogueSection called but no DialogueSections are in
246                the stack
247            ''')
248            raise RuntimeError(error_message)
[668]249       
250        return current_dialogue_section
251   
[684]252    def runDialogueActions(self, dialogue_node):
[680]253        """
254        Execute all L{DialogueActions<DialogueActions>} contained by a
255        L{DialogueSection} or L{DialogueResponse}.
256       
257        @param dialogue_node: section of dialogue or response containing the
258            L{DialogueActions<DialogueAction>} to execute.
259        @type dialogue_node: L{DialogueNode}
260        """
[684]261        self._logger.info('processing commands for {0}'.format(dialogue_node))
[668]262        for command in dialogue_node.actions:
263            try:
[684]264                command(self.game_state)
[685]265            except (Exception,) as error:
[684]266                self._logger.error('failed to execute DialogueAction {0}: {1}'
[685]267                                   .format(command.keyword, error))
[680]268                # TODO Technomage 2010-11-18: Undo previous actions when an
269                #     action fails to execute.
[668]270            else:
[684]271                self._logger.debug('ran {0} with arguments {1}'
[685]272                                   .format(getattr(type(command), '__name__'),
273                                           command.arguments))
[668]274   
[684]275    def getValidResponses(self, dialogue_section):
[680]276        """
277        Evaluate all L{DialogueResponse} conditions for a L{DialogueSection}
278        and return a list of valid responses.
279       
280        @param dialogue_section: section of dialogue containing the
281            L{DialogueResponses<DialogueResponse>} to process.
282        @type dialogue_section: L{DialogueSection}
283       
284        @return: responses whose conditions were met.
285        @rtype: list of L{DialogueResponses<DialogueResponse>}
286        """
[668]287        valid_responses = []
288        for dialogue_response in dialogue_section.responses:
289            condition = dialogue_response.condition
290            try:
291                condition_met = condition is None or \
[684]292                                eval(condition, self.game_state)
[706]293            except (Exception,) as exception:
294                error_message = dedent_chomp('''
295                    evaluation of condition {condition} for {response} failed
296                    with error: {exception}
297                ''').format(condition=dialogue_response.condition,
298                            response=dialogue_response, exception=exception)
299                self._logger.error(error_message)
[668]300            else:
[684]301                self._logger.debug(
[668]302                    'condition "{0}" for {1} evaluated to {2}'
303                    .format(dialogue_response.condition, dialogue_response,
304                            condition_met)
305                )
306                if (condition_met):
307                    valid_responses.append(dialogue_response)
308       
309        return valid_responses
310   
[684]311    def reply(self, dialogue_response):
[680]312        """
313        Reply with a L{DialogueResponse}, execute the
314        L{DialogueActions<DialogueAction>} it contains and push the next
315        L{DialogueSection} onto the L{dialogue_section_stack}.
316       
317        @param dialogue_response: response to reply with.
318        @type dialogue_response: L{DialogueReponse}
[685]319       
320        @raise RuntimeError: Any precondition is not met.
321       
322        @precondition: L{initiateDialogue} must be called before this method
323            is used.
[680]324        """
[685]325        if (not self.in_dialogue):
[706]326            error_message = dedent_chomp('''
327                reply cannot be called until the dialogue has been initiated
328                via initiateDialogue
329            ''')
330            raise RuntimeError(error_message)
[684]331        self._logger.info('replied with {0}'.format(dialogue_response))
[685]332        # FIXME: Technomage 2010-12-11: What happens if runDialogueActions
333        #     raises an error?
[684]334        self.runDialogueActions(dialogue_response)
[668]335        next_section_id = dialogue_response.next_section_id
336        if (next_section_id == 'back'):
[684]337            if (len(self.dialogue_section_stack) == 1):
[706]338                error_message = dedent_chomp('''
339                    attempted to run goto: back action but stack does not
340                    contain a previous DialogueSection
341                ''')
342                raise RuntimeError(error_message)
[668]343            else:
344                try:
[684]345                    self.dialogue_section_stack.pop()
[685]346                except (IndexError,):
[706]347                    error_message = dedent_chomp('''
348                        attempted to run goto: back action but the stack was
349                        empty
350                    ''')
351                    raise RuntimeError(error_message)
[668]352                else:
[684]353                    self._logger.debug(
[668]354                        'ran goto: back action, restored last DialogueSection'
355                    )
356        elif (next_section_id == 'end'):
[684]357            self.endDialogue()
358            self._logger.debug('ran goto: end action, ended dialogue')
[668]359        else:
360            try:
361                next_dialogue_section = \
[684]362                    self.dialogue.sections[next_section_id]
[668]363            except KeyError:
[706]364                error_message = dedent_chomp('''
365                    {0} is not a recognized goto: action or DialogueSection
366                    identifier
367                ''').format(next_section_id)
368                raise RuntimeError(error_message)
[668]369            else:
[684]370                self.dialogue_section_stack.append(next_dialogue_section)
[668]371   
[684]372    def endDialogue(self):
[680]373        """
374        End the current dialogue and clean up any resources in use by the
[684]375        L{DialogueProcessor}.
[680]376        """
[706]377        self.dialogue_section_stack = []
[684]378        self.in_dialogue = False
Note: See TracBrowser for help on using the repository browser.