comparison 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
comparison
equal deleted inserted replaced
-1:000000000000 0:7a89ea5404b1
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 """
16 Provides the core interface to the dialogue subsystem used to process player
17 L{Dialogues<Dialogue>} with NPCs.
18 """
19 import logging
20
21 from parpg.common.utils import dedent_chomp
22
23 if (__debug__):
24 from collections import Sequence, MutableMapping
25 from parpg.dialogue import Dialogue
26
27 logger = logging.getLogger('dialogueprocessor')
28
29 class 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