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

Revision 797, 15.4 KB checked in by aspidites, 8 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
Line 
1#   This file is part of PARPG.
2#
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.
7#
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.
12#
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/>.
15"""
16Provides the core interface to the dialogue subsystem used to process player
17L{Dialogues<Dialogue>} with NPCs.
18"""
19import logging
20
21from parpg.common.utils import dedent_chomp
22
23if (__debug__):
24    from collections import Sequence, MutableMapping
25    from parpg.dialogue import Dialogue
26
27logger = logging.getLogger('dialogueprocessor')
28
29class DialogueProcessor(object):
30    """
31    Primary interface to the dialogue subsystem used to initiate and process a
32    L{Dialogue} with an NPC.
33   
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
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
48    the L{dialogue}. The selected L{DialogueSection} is then pushed
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   
56    The L{DialogueProcessor} terminates dialogue processing once L{reply} is
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
62        L{DialogueProcessor} can be used.
63   
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
67        currently being processed.
68    @type dialogue_section_stack: list of L{DialogueSections<DialogueSection>}
69    @ivar game_state: objects defining the game state that should be made
70        available for testing L{DialogueResponse} conditionals.
71    @type game_state: dict of Python objects
72    @ivar in_dialogue: whether a dialogue has been initiated.
73    @type in_dialogue: Bool
74   
75    Usage:
76    >>> game_state = {'pc': player_character, 'quest': quest_engine}
77    >>> dialogue_processor = DialogueProcessor(dialogue, game_state)
78    >>> dialogue_processor.initiateDialogue()
79    >>> while dialogue_processor.in_dialogue:
80    ...     valid_responses = dialogue_processor.continueDialogue()
81    ...     response = choose_response(valid_responses)
82    ...     dialogue_processor.reply(response)
83    """
84    _logger = logging.getLogger('dialogueengine.DialogueProcessor')
85   
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())
97   
98    def dialogue_section_stack():
99        def fget(self):
100            return self._dialogue_section_stack
101       
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
108       
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       
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
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 getDialogueGreeting(self):
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}
159       
160        @raise: RuntimeError - evaluation of a DialogueGreeting condition fails
161            by raising an exception (e.g. due to a syntax error).
162        """
163        dialogue = self.dialogue
164        dialogue_greeting = None
165        for greeting in dialogue.greetings:
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
178       
179        return dialogue_greeting
180   
181    def initiateDialogue(self):
182        """
183        Prepare the L{DialogueProcessor} to process the L{Dialogue} by pushing
184        the starting L{DialogueSection} onto the L{dialogue_section_stack}.
185       
186        @raise RuntimeError: Unable to determine the root L{DialogueSection}
187            defined by the L{Dialogue}.
188        """
189        if (self.in_dialogue):
190            self.endDialogue()
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))
195   
196    def continueDialogue(self):
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>}
206       
207        @raise RuntimeError: Any preconditions are not met.
208       
209        @precondition: dialogue has been initiated via L{initiateDialogue}.
210        """
211        if (not self.in_dialogue):
212            error_message = dedent_chomp('''
213                dialogue has not be initiated via initiateDialogue yet
214            ''')
215            raise RuntimeError(error_message)
216        current_dialogue_section = self.getCurrentDialogueSection()
217        self.runDialogueActions(current_dialogue_section)
218        valid_responses = self.getValidResponses(current_dialogue_section)
219       
220        return valid_responses
221   
222    def getCurrentDialogueSection(self):
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}
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}.
234        """
235        if (not self.in_dialogue):
236            error_message = dedent_chomp('''
237                getCurrentDialogueSection called but the dialogue has not been
238                initiated yet
239            ''')
240            raise RuntimeError(error_message)
241        try:
242            current_dialogue_section = self.dialogue_section_stack[-1]
243        except IndexError:
244            error_message = dedent_chomp('''
245                getCurrentDialogueSection called but no DialogueSections are in
246                the stack
247            ''')
248            raise RuntimeError(error_message)
249       
250        return current_dialogue_section
251   
252    def runDialogueActions(self, dialogue_node):
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        """
261        self._logger.info('processing commands for {0}'.format(dialogue_node))
262        for command in dialogue_node.actions:
263            try:
264                command(self.game_state)
265            except (Exception,) as error:
266                self._logger.error('failed to execute DialogueAction {0}: {1}'
267                                   .format(command.keyword, error))
268                # TODO Technomage 2010-11-18: Undo previous actions when an
269                #     action fails to execute.
270            else:
271                self._logger.debug('ran {0} with arguments {1}'
272                                   .format(getattr(type(command), '__name__'),
273                                           command.arguments))
274   
275    def getValidResponses(self, dialogue_section):
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        """
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 \
292                                eval(condition, self.game_state)
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)
300            else:
301                self._logger.debug(
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   
311    def reply(self, dialogue_response):
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}
319       
320        @raise RuntimeError: Any precondition is not met.
321       
322        @precondition: L{initiateDialogue} must be called before this method
323            is used.
324        """
325        if (not self.in_dialogue):
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)
331        self._logger.info('replied with {0}'.format(dialogue_response))
332        # FIXME: Technomage 2010-12-11: What happens if runDialogueActions
333        #     raises an error?
334        self.runDialogueActions(dialogue_response)
335        next_section_id = dialogue_response.next_section_id
336        if (next_section_id == 'back'):
337            if (len(self.dialogue_section_stack) == 1):
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)
343            else:
344                try:
345                    self.dialogue_section_stack.pop()
346                except (IndexError,):
347                    error_message = dedent_chomp('''
348                        attempted to run goto: back action but the stack was
349                        empty
350                    ''')
351                    raise RuntimeError(error_message)
352                else:
353                    self._logger.debug(
354                        'ran goto: back action, restored last DialogueSection'
355                    )
356        elif (next_section_id == 'end'):
357            self.endDialogue()
358            self._logger.debug('ran goto: end action, ended dialogue')
359        else:
360            try:
361                next_dialogue_section = \
362                    self.dialogue.sections[next_section_id]
363            except KeyError:
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)
369            else:
370                self.dialogue_section_stack.append(next_dialogue_section)
371   
372    def endDialogue(self):
373        """
374        End the current dialogue and clean up any resources in use by the
375        L{DialogueProcessor}.
376        """
377        self.dialogue_section_stack = []
378        self.in_dialogue = False
Note: See TracBrowser for help on using the repository browser.