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

Revision 707, 15.6 KB checked in by technomage, 8 years ago (diff)

Last commit didn't work, trying again...

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