Mercurial > parpg-source
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 |