source: trunk/game/scripts/dialogueprocessor.py @ 685

Revision 685, 13.6 KB checked in by technomage, 9 years ago (diff)

Patch by Technomage

  • Replaced the test_dialogue.py testsuite with the test_dialogueprocessor.py testsuite and added/updated unittest TestCases? for all public methods of the DialogueProcessor? class.
  • DialogueProcessor? was refactored to be much more liberal about raising exceptions instead of silently handling errors; the code documentation has been updated to describe this new behavior.
  • Refactored exception-handling code in the dialogueparsers.py gamemodel.py modules related to the dialogue engine to deal with the changes to the DialogueProcessor? class.
  • Added a new method to the Dialogue class, getRootSection, which returns the root DialogueSection? for the a particular dialogue; also added some error checking code for inputs to the constructor.
  • Updated the run_tests.py script to import config.py and attempt to read the FIFE Python module path from it, just like run.py.
  • Property svn:mime-type set to text/plain
Line 
1#!/usr/bin/env python
2#
3#   This file is part of PARPG.
4#
5#   PARPG is free software: you can redistribute it and/or modify
6#   it under the terms of the GNU General Public License as published by
7#   the Free Software Foundation, either version 3 of the License, or
8#   (at your option) any later version.
9#
10#   PARPG is distributed in the hope that it will be useful,
11#   but WITHOUT ANY WARRANTY; without even the implied warranty of
12#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13#   GNU General Public License for more details.
14#
15#   You should have received a copy of the GNU General Public License
16#   along with PARPG.  If not, see <http://www.gnu.org/licenses/>.
17"""
18Provides the core interface to the dialogue subsystem used to process player
19L{Dialogues<Dialogue>} with NPCs.
20"""
21import logging
22
23if (__debug__):
24    from scripts.dialogue import Dialogue
25
26def setupLogging():
27    """Set various logging parameters for this module."""
28    module_logger = logging.getLogger('dialogueengine')
29    if (__debug__):
30        module_logger.setLevel(logging.DEBUG)
31setupLogging()
32
33class DialogueProcessor(object):
34    """
35    Primary interface to the dialogue subsystem used to initiate and process a
36    L{Dialogue} with an NPC.
37   
38    To begin a dialogue with an NPC a L{DialogueProcessor} must first be
39    instantiated with the dialogue data to process and a dictionary of Python
40    objects defining the game state for testing of response conditionals. The
41    L{initiateDialogue} must be called to initialized the L{DialogueProcessor},
42    and once it is initialized processing of
43    L{DialogueSections<DialogueSection>} and
44    L{DialogueResponses<DialogueResponse>} can be initiated via the
45    L{continueDialogue} and L{reply} class methods.
46   
47    The state of dialogue processing is stored via the
48    L{dialogue_section_stack} class attribute, which stores a list of
49    L{DialogueSections<DialogueSection>} that have been or are currently being
50    processed. Each time L{reply} is called with a L{DialogueResponse} its
51    next_section_id attribute is used to select a new L{DialogueSection} from
52    the L{dialogue}. The selected L{DialogueSection} is then pushed
53    onto the end of the L{dialogue_section_stack}, ready to be processed via
54    L{continueDialogue}. The exception to this rule occurs when L{reply} is
55    called with a L{DialogueResponse} whose next_section_id attribute is "end"
56    or "back". "end" terminates the dialogue as described below, while "back"
57    removes the last L{DialogueSection} on the L{dialogue_section_stack}
58    effectively going back to the previous section of dialogue.
59   
60    The L{DialogueProcessor} terminates dialogue processing once L{reply} is
61    called with a L{DialogueResponse} whose next_section_id == 'end'.
62    Processing can also be manually terminated by calling the L{endDialogue}
63    class method.
64   
65    @note: See the dialogue_demo.py script for a complete example of how the
66        L{DialogueProcessor} can be used.
67   
68    @ivar dialogue: dialogue data currently being processed.
69    @type dialogue: L{Dialogue}
70    @ivar dialogue_section_stack: sections of dialogue that have been or are
71        currently being processed.
72    @type dialogue_section_stack: list of L{DialogueSections<DialogueSection>}
73    @ivar game_state: objects defining the game state that should be made
74        available for testing L{DialogueResponse} conditionals.
75    @type game_state: dict of Python objects
76    @ivar in_dialogue: whether a dialogue has been initiated.
77    @type in_dialogue: Bool
78   
79    Usage:
80    >>> game_state = {'pc': player_character, 'quest': quest_engine}
81    >>> dialogue_processor = DialogueProcessor(dialogue, game_state)
82    >>> dialogue_processor.initiateDialogue()
83    >>> while dialogue_processor.in_dialogue:
84    ...     valid_responses = dialogue_processor.continueDialogue()
85    ...     response = choose_response(valid_responses)
86    ...     dialogue_processor.reply(response)
87    """
88    _logger = logging.getLogger('dialogueengine.DialogueProcessor')
89   
90    def dialogue():
91        def fget(self):
92            return self._dialogue
93       
94        def fset(self, dialogue):
95            assert isinstance(dialogue, Dialogue), \
96                '{0} does not implement Dialogue interface'.format(dialogue)
97            self._dialogue = dialogue
98       
99        def fdel(self):
100            self._dialogue = None
101       
102        return locals()
103    dialogue = property(**dialogue())
104   
105    def dialogue_section_stack():
106        def fget(self):
107            return self._dialogue_section_stack
108       
109        def fdel(self):
110            self._dialogue_section_stack = []
111       
112        return locals()
113    dialogue_section_stack = property(**dialogue_section_stack())
114   
115    def game_state():
116        def fget(self):
117            return self._game_state
118       
119        def fdel(self):
120            self._game_state = {}
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       
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        """
146        self._dialogue_section_stack = []
147        self._dialogue = dialogue
148        self._game_state = game_state
149        self._in_dialogue = False
150   
151    def initiateDialogue(self):
152        """
153        Prepare the L{DialogueProcessor} to process the L{Dialogue} by pushing the
154        starting L{DialogueSection} onto the L{dialogue_section_stack}.
155       
156        @raise TypeError: Unable to determine the root L{DialogueSection}
157            defined by the L{Dialogue}.
158        """
159        dialogue = self.dialogue
160        try:
161            root_dialogue_section = dialogue.getRootSection()
162        except (RuntimeError,) as error:
163            self._logger.error(str(error))
164            raise TypeError(('unable to determine start DialogueSection for '
165                             '{0}').format(dialogue))
166        else:
167            self.dialogue_section_stack.append(root_dialogue_section)
168            self.in_dialogue = True
169            self._logger.info('initiated dialogue {0}'.format(dialogue))
170   
171    def continueDialogue(self):
172        """
173        Process the L{DialogueSection} at the top of the
174        L{dialogue_section_stack}, run any L{DialogueActions<DialogueActions>}
175        it contains and return a list of valid
176        L{DialogueResponses<DialogueResponses> after evaluating any response
177        conditionals.
178       
179        @returns: valid responses.
180        @rtype: list of L{DialogueResponses<DialogueResponse>}
181       
182        @raise RuntimeError: Any preconditions are not met.
183       
184        @precondition: dialogue has been initiated via L{initiateDialogue}.
185        """
186        if (not self.in_dialogue):
187            raise RuntimeError('dialogue has not be initiated via '
188                               'initiateDialogue yet')
189        current_dialogue_section = self.getCurrentDialogueSection()
190        self.runDialogueActions(current_dialogue_section)
191        valid_responses = self.getValidResponses(current_dialogue_section)
192       
193        return valid_responses
194   
195    def getCurrentDialogueSection(self):
196        """
197        Return the L{DialogueSection} at the top of the
198        L{dialogue_section_stack}.
199       
200        @returns: section of dialogue currently being processed.
201        @rtype: L{DialogueSection}
202       
203        @raise RuntimeError: Any preconditions are not met.
204       
205        @precondition: dialogue has been initiated via L{initiateDialogue} and
206            L{dialogue_section_stack} contains at least one L{DialogueSection}.
207        """
208        if (not self.in_dialogue):
209            raise RuntimeError('getCurrentDialogueSection called but the '
210                               'dialogue has not been initiated yet')
211        try:
212            current_dialogue_section = self.dialogue_section_stack[-1]
213        except IndexError:
214            raise RuntimeError('getCurrentDialogueSection called but no '
215                               'DialogueSections are in the stack')
216       
217        return current_dialogue_section
218   
219    def runDialogueActions(self, dialogue_node):
220        """
221        Execute all L{DialogueActions<DialogueActions>} contained by a
222        L{DialogueSection} or L{DialogueResponse}.
223       
224        @param dialogue_node: section of dialogue or response containing the
225            L{DialogueActions<DialogueAction>} to execute.
226        @type dialogue_node: L{DialogueNode}
227        """
228        self._logger.info('processing commands for {0}'.format(dialogue_node))
229        for command in dialogue_node.actions:
230            try:
231                command(self.game_state)
232            except (Exception,) as error:
233                self._logger.error('failed to execute DialogueAction {0}: {1}'
234                                   .format(command.keyword, error))
235                # TODO Technomage 2010-11-18: Undo previous actions when an
236                #     action fails to execute.
237                return
238            else:
239                self._logger.debug('ran {0} with arguments {1}'
240                                   .format(getattr(type(command), '__name__'),
241                                           command.arguments))
242   
243    def getValidResponses(self, dialogue_section):
244        """
245        Evaluate all L{DialogueResponse} conditions for a L{DialogueSection}
246        and return a list of valid responses.
247       
248        @param dialogue_section: section of dialogue containing the
249            L{DialogueResponses<DialogueResponse>} to process.
250        @type dialogue_section: L{DialogueSection}
251       
252        @return: responses whose conditions were met.
253        @rtype: list of L{DialogueResponses<DialogueResponse>}
254        """
255        valid_responses = []
256        for dialogue_response in dialogue_section.responses:
257            condition = dialogue_response.condition
258            try:
259                condition_met = condition is None or \
260                                eval(condition, self.game_state)
261            except (Exception,) as error:
262                self._logger.error(
263                    ('evaluation of condition "{0}" for {1} failed with '
264                     'error: {2}').format(dialogue_response.condition,
265                                   dialogue_response, error)
266                )
267            else:
268                self._logger.debug(
269                    'condition "{0}" for {1} evaluated to {2}'
270                    .format(dialogue_response.condition, dialogue_response,
271                            condition_met)
272                )
273                if (condition_met):
274                    valid_responses.append(dialogue_response)
275       
276        return valid_responses
277   
278    def reply(self, dialogue_response):
279        """
280        Reply with a L{DialogueResponse}, execute the
281        L{DialogueActions<DialogueAction>} it contains and push the next
282        L{DialogueSection} onto the L{dialogue_section_stack}.
283       
284        @param dialogue_response: response to reply with.
285        @type dialogue_response: L{DialogueReponse}
286       
287        @raise RuntimeError: Any precondition is not met.
288       
289        @precondition: L{initiateDialogue} must be called before this method
290            is used.
291        """
292        if (not self.in_dialogue):
293            raise RuntimeError('reply cannot be called until the dialogue has '
294                               'been initiated via initiateDialogue')
295        self._logger.info('replied with {0}'.format(dialogue_response))
296        # FIXME: Technomage 2010-12-11: What happens if runDialogueActions
297        #     raises an error?
298        self.runDialogueActions(dialogue_response)
299        next_section_id = dialogue_response.next_section_id
300        if (next_section_id == 'back'):
301            if (len(self.dialogue_section_stack) == 1):
302                raise RuntimeError('attempted to run goto: back action but '
303                                   'stack does not contain a previous '
304                                   'DialogueSection')
305            else:
306                try:
307                    self.dialogue_section_stack.pop()
308                except (IndexError,):
309                    raise RuntimeError('attempted to run goto: back action '
310                                       'but the stack was empty')
311                else:
312                    self._logger.debug(
313                        'ran goto: back action, restored last DialogueSection'
314                    )
315        elif (next_section_id == 'end'):
316            self.endDialogue()
317            self._logger.debug('ran goto: end action, ended dialogue')
318        else:
319            try:
320                next_dialogue_section = \
321                    self.dialogue.sections[next_section_id]
322            except KeyError:
323                raise RuntimeError(
324                    ('"{0}" is not a recognized goto: action or '
325                     'DialogueSection identifier').format(next_section_id)
326                )
327            else:
328                self.dialogue_section_stack.append(next_dialogue_section)
329   
330    def endDialogue(self):
331        """
332        End the current dialogue and clean up any resources in use by the
333        L{DialogueProcessor}.
334        """
335        del self.dialogue_section_stack
336        self.in_dialogue = False
Note: See TracBrowser for help on using the repository browser.