source: trunk/game/scripts/dialogueparsers.py @ 680

Revision 680, 24.1 KB checked in by technomage, 9 years ago (diff)

Patch by Technomage

  • Updated/wrote code documentation for the core dialogue subsystem modules dialogue.py, dialogueengine.py, dialogueactions.py, and dialogueparsers.py.
  • Updated/created flowcharts and UML diagrams explaining the new DialogueEngine? and how it functions.
  • Made a few minor changes to the dialoguegui.py module to make the class logger less visible.
  • Removed the "this is a sample dialogue file" crud from the old_man.yaml test dialogue file.
  • Property svn:eol-style set to native
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"""
18Contains classes for parsing and validating L{Dialogues<Dialogue>} and other
19dialogue-related data.
20
21@TODO Technomage 2010-11-13: Exception handling + validation needs work.
22    Currently YAML files are only crudely validated - the code assumes that
23    the file contains valid dialogue data, and if that assumption is
24    violated and causes the code to raise any TypeErrors, AttributeErrors or
25    ValueErrors the code then raises a DialogueFormatError with the
26    original (and mostly unhelpful) error message.
27@TODO Technomage 2010-11-13: Support reading and writing unicode.
28"""
29import logging
30try:
31    from cStringIO import StringIO
32except ImportError:
33    from StringIO import StringIO
34from collections import Sequence
35try:
36    from collections import OrderedDict
37except ImportError:
38    # Python version 2.4-2.6 doesn't have the OrderedDict
39    from scripts.common.ordereddict import OrderedDict
40import re
41import textwrap
42
43import yaml
44
45from scripts import COPYRIGHT_HEADER
46from scripts.dialogue import Dialogue, DialogueSection, DialogueResponse
47from scripts.dialogueactions import DialogueAction
48
49def setup_logging():
50    """Set various logging parameters for this module."""
51    module_logger = logging.getLogger('dialogueparser')
52    if (__debug__):
53        module_logger.setLevel(logging.DEBUG)
54setup_logging()
55
56class DialogueFormatError(Exception):
57    """Exception thrown when the DialogueParser has encountered an error."""
58
59
60class AbstractDialogueParser(object):
61    """
62    Abstract base class defining the interface for parsers responsible for
63    constructing a L{Dialogue} from its serialized representation.
64    """
65    def load(self, stream):
66        """
67        Parse a stream and attempt to construct a new L{Dialogue} instance from
68        its serialized representation.
69       
70        @param stream: open stream containing the serialized representation of
71            a Dialogue.
72        @type stream: BufferType
73        """
74        raise NotImplementedError('AbstractDialogueParser subclasses must '
75                                  'override the load method.')
76   
77    def dump(self, dialogue, stream):
78        """
79        Serialize a L{Dialogue} instance and dump it to an open stream.
80       
81        @param dialogue: dialogue to serialize.
82        @type dialogue: L{Dialogue}
83        @param stream: open stream into which the serialized L{Dialogue} should
84            be dumped.
85        @type stream: BufferType
86        """
87        raise NotImplementedError('AbstractDialogueParser subclasses must '
88                                  'override the dump method.')
89   
90    def validate(self, stream):
91        """
92        Parse a stream and verify that it contains a valid serialization of a
93        L{Dialogue instance}.
94       
95        @param stream: stream containing the serialized representation of a
96            L{Dialogue}
97        @type stream: BufferType
98        """
99        raise NotImplementedError('AbstractDialogueParser subclasses must '
100                                  'override the validate method.')
101
102
103class YamlDialogueParser(AbstractDialogueParser):
104    """
105    L{AbstractDialogueParser} subclass responsible for parsing dialogues
106    serialized in YAML.
107    """
108    logger = logging.getLogger('dialogueparser.OldYamlDialogueParser')
109   
110    def load(self, stream, loader_class=yaml.Loader):
111        """
112        Parse a YAML stream and attempt to construct a new L{Dialogue}
113        instance.
114       
115        @param stream: stream containing the serialized YAML representation of
116            a L{Dialogue}.
117        @type stream: BufferType
118        @param loader_class: PyYAML loader class to use for reading the
119            serialization.
120        @type loader_class: yaml.BaseLoader subclass
121        """
122        loader = loader_class(stream)
123        dialogue = self._constructDialogue(loader, loader.get_single_node())
124        return dialogue
125   
126    def dump(self, dialogue, output_stream, dumper_class=yaml.Dumper):
127        """
128        Serialize a L{Dialogue} instance as YAML and dump it to an open stream.
129       
130        @param dialogue: dialogue to serialize.
131        @type dialogue: L{Dialogue}
132        @param stream: open stream into which the serialized L{Dialogue} should
133            be dumped.
134        @type stream: BufferType
135        @param dumper_class: PyYAML dumper class to use for formatting the
136            serialization.
137        @type dumper_class: yaml.BaseDumper subclass
138        """
139        intermediate_stream = StringIO()
140        # KLUDE Technomage 2010-11-16: The "width" argument seems to be broken,
141        #     as it doesn't take into about current line indentation and fails
142        #     to correctly wrap at word boundaries.
143        dumper = dumper_class(intermediate_stream, default_flow_style=False,
144                              indent=4, width=99999, line_break='\n',
145                              allow_unicode=True, explicit_start=True,
146                              explicit_end=True, tags=False)
147        dialogue_node = self._representDialogue(dumper, dialogue)
148        dumper.open()
149        dumper.serialize(dialogue_node)
150        dumper.close()
151        file_contents = intermediate_stream.getvalue()
152       
153        file_contents = re.sub(r'(\n|\r|\r\n)(\s*)(GOTO: .*)', r'\1\2\3\1\2',
154                               file_contents)
155        lines = file_contents.splitlines()
156        max_line_length = 79
157        for i in range(len(lines)):
158            line = lines[i]
159            match = re.match(
160                r'^(\s*(?:-\s+)?)(SAY|REPLY|CONDITION):\s+"(.*)"$',
161                line
162            )
163            if (match and len(line) > max_line_length):
164                # Wrap long lines for readability.
165                initial_indent = len(match.group(1))
166                subsequent_indent = initial_indent + 4
167                text_wrapper = textwrap.TextWrapper(
168                    max_line_length,
169                    subsequent_indent=' ' * subsequent_indent,
170                    break_long_words=False,
171                    break_on_hyphens=False
172                )
173                new_lines = text_wrapper.wrap(line)
174                new_lines = new_lines[:1] + [re.sub(r'^(\s*) (.*)$',
175                                                   r'\1\ \2', l) for l in
176                                             new_lines[1:]]
177                lines[i] = '\\\n'.join(new_lines)
178       
179        output_stream.write(COPYRIGHT_HEADER)
180        output_stream.write('\n'.join(lines))
181       
182   
183    def _representDialogue(self, dumper, dialogue):
184        dialogue_node = dumper.represent_dict({})
185        dialogue_dict = OrderedDict()
186        dialogue_dict['NPC_NAME'] = dialogue.npc_name
187        dialogue_dict['AVATAR_PATH'] = dialogue.avatar_path
188        dialogue_dict['START_SECTION'] = dialogue.start_section_id
189        # NOTE Technomage 2010-11-16: Dialogue stores its sections in an
190        #     OrderedDict, so a round-trip load, dump, and load will preserve
191        #     the order of DialogueSections.
192        sections_list_node = dumper.represent_list([])
193        sections_list = sections_list_node.value
194        for section in dialogue.sections.values():
195            section_node = self._representDialogueSection(dumper, section)
196            sections_list.append(section_node)
197        dialogue_dict['SECTIONS'] = sections_list_node
198       
199        for key, value in dialogue_dict.items():
200            if (isinstance(key, yaml.Node)):
201                key_node = key
202            else:
203                key_node = dumper.represent_data(key)
204            if (isinstance(value, yaml.Node)):
205                value_node = value
206            else:
207                value_node = dumper.represent_data(value)
208            dialogue_node.value.append((key_node, value_node))
209        return dialogue_node
210   
211    def _representDialogueSection(self, dumper, dialogue_section):
212        section_node = dumper.represent_dict({})
213        section_dict = OrderedDict() # OrderedDict is required to preserve
214                                     # the order of attributes.
215        section_dict['ID'] = dialogue_section.id
216        # KLUDGE Technomage 2010-11-16: Hard-coding the tag like this could be
217        #     a problem when writing unicode.
218        section_dict['SAY'] = dumper.represent_scalar('tag:yaml.org,2002:str',
219                                                      dialogue_section.text,
220                                                      style='"')
221        actions_list_node = dumper.represent_list([])
222        actions_list = actions_list_node.value
223        for action in dialogue_section.actions:
224            action_node = self._representDialogueAction(dumper, action)
225            actions_list.append(action_node)
226        if (actions_list):
227            section_dict['ACTIONS'] = actions_list_node
228        responses_list_node = dumper.represent_list([])
229        responses_list = responses_list_node.value
230        for response in dialogue_section.responses:
231            response_node = self._representDialogueResponse(dumper, response)
232            responses_list.append(response_node)
233        section_dict['RESPONSES'] = responses_list_node
234       
235        for key, value in section_dict.items():
236            if (isinstance(key, yaml.Node)):
237                key_node = key
238            else:
239                key_node = dumper.represent_data(key)
240            if (isinstance(value, yaml.Node)):
241                value_node = value
242            else:
243                value_node = dumper.represent_data(value)
244            section_node.value.append((key_node, value_node))
245        return section_node
246   
247    def _representDialogueResponse(self, dumper, dialogue_response):
248        response_node = dumper.represent_dict({})
249        response_dict = OrderedDict()
250        # KLUDGE Technomage 2010-11-16: Hard-coding the tag like this could be
251        #     a problem when writing unicode.
252        response_dict['REPLY'] = dumper.represent_scalar(
253            'tag:yaml.org,2002:str',
254            dialogue_response.text,
255            style='"')
256        if (dialogue_response.condition is not None):
257            response_dict['CONDITION']  = dumper.represent_scalar(
258                'tag:yaml.org,2002:str',
259                dialogue_response.condition,
260                style='"'
261            )
262        actions_list_node = dumper.represent_list([])
263        actions_list = actions_list_node.value
264        for action in dialogue_response.actions:
265            action_node = self._representDialogueAction(dumper, action)
266            actions_list.append(action_node)
267        if (actions_list):
268            response_dict['ACTIONS'] = actions_list_node
269        response_dict['GOTO'] = dialogue_response.next_section_id
270       
271        for key, value in response_dict.items():
272            if (isinstance(key, yaml.Node)):
273                key_node = key
274            else:
275                key_node = dumper.represent_data(key)
276            if (isinstance(value, yaml.Node)):
277                value_node = value
278            else:
279                value_node = dumper.represent_data(value)
280            response_node.value.append((key_node, value_node))
281        return response_node
282   
283    def _representDialogueAction(self, dumper, dialogue_action):
284        action_node = dumper.represent_dict({})
285        action_dict = OrderedDict()
286        args, kwargs = dialogue_action.arguments
287        if (args and not kwargs):
288            arguments = list(args)
289        elif (kwargs and not args):
290            arguments = kwargs
291        else:
292            arguments = [list(args), kwargs]
293        action_dict[dialogue_action.keyword] = arguments
294       
295        for key, value in action_dict.items():
296            if (isinstance(key, yaml.Node)):
297                key_node = key
298            else:
299                key_node = dumper.represent_data(key)
300            if (isinstance(value, yaml.Node)):
301                value_node = value
302            else:
303                value_node = dumper.represent_data(value)
304            action_node.value.append((key_node, value_node))
305        return action_node
306   
307    def _constructDialogue(self, loader, yaml_node):
308        npc_name = None
309        avatar_path = None
310        start_section_id = None
311        sections = []
312       
313        try:
314            for key_node, value_node in yaml_node.value:
315                key = key_node.value
316                if (key == u'NPC_NAME'):
317                    npc_name = loader.construct_object(value_node)
318                elif (key == u'AVATAR_PATH'):
319                    avatar_path = loader.construct_object(value_node)
320                elif (key == u'START_SECTION'):
321                    start_section_id = loader.construct_object(value_node)
322                elif (key == u'SECTIONS'):
323                    for section_node in value_node.value:
324                        dialogue_section = self._constructDialogueSection(
325                            loader,
326                            section_node
327                        )
328                        sections.append(dialogue_section)
329        except (AttributeError, TypeError, ValueError) as e:
330            raise DialogueFormatError(e)
331       
332        dialogue = Dialogue(npc_name=npc_name, avatar_path=avatar_path,
333                            start_section_id=start_section_id,
334                            dialogue_sections=sections)
335        return dialogue
336   
337    def _constructDialogueSection(self, loader, section_node):
338        id = None
339        text = None
340        responses = []
341        actions = []
342        dialogue_section = None
343       
344        try:
345            for key_node, value_node in section_node.value:
346                key = key_node.value
347                if (key == u'ID'):
348                    id = loader.construct_object(value_node)
349                elif (key == u'SAY'):
350                    text = loader.construct_object(value_node)
351                elif (key == u'RESPONSES'):
352                    for response_node in value_node.value:
353                        dialogue_response = self._constructDialogueResponse(
354                            loader,
355                            response_node
356                        )
357                        responses.append(dialogue_response)
358                elif (key == u'ACTIONS'):
359                    for action_node in value_node.value:
360                        action = self._constructDialogueAction(loader,
361                                                             action_node)
362                        actions.append(action)
363        except (AttributeError, TypeError, ValueError) as e:
364            raise DialogueFormatError(e)
365        else:
366            dialogue_section = DialogueSection(id=id, text=text,
367                                               responses=responses,
368                                               actions=actions)
369       
370        return dialogue_section
371   
372    def _constructDialogueResponse(self, loader, response_node):
373        text = None
374        next_section_id = None
375        actions = []
376        condition = None
377       
378        try:
379            for key_node, value_node in response_node.value:
380                key = key_node.value
381                if (key == u'REPLY'):
382                    text = loader.construct_object(value_node)
383                elif (key == u'ACTIONS'):
384                    for action_node in value_node.value:
385                        action = self._constructDialogueAction(loader,
386                                                             action_node)
387                        actions.append(action)
388                elif (key == u'CONDITION'):
389                    condition = loader.construct_object(value_node)
390                elif (key == u'GOTO'):
391                    next_section_id = loader.construct_object(value_node)
392        except (AttributeError, TypeError, ValueError) as e:
393            raise DialogueFormatError(e)
394       
395        dialogue_response = DialogueResponse(text=text,
396                                             next_section_id=next_section_id,
397                                             actions=actions,
398                                             condition=condition)
399        return dialogue_response
400   
401    def _constructDialogueAction(self, loader, action_node):
402        mapping = loader.construct_mapping(action_node, deep=True)
403        keyword, arguments = mapping.items()[0]
404        if (isinstance(arguments, dict)):
405            # Got a dictionary of keyword arguments.
406            args = ()
407            kwargs = arguments
408        elif (not isinstance(arguments, Sequence) or
409              isinstance(arguments, basestring)):
410            # Got a single positional argument.
411            args = (arguments)
412            kwargs = {}
413        elif (not len(arguments) == 2 or not isinstance(arguments[1], dict)):
414            # Got a list of positional arguments.
415            args = arguments
416            kwargs = {}
417        else:
418            self.logger.error(
419                '{0} is an invalid DialogueAction argument'.format(arguments)
420            )
421            return None
422       
423        action_type = DialogueAction.registered_actions.get(keyword)
424        if (action_type is None):
425            self.logger.error(
426                'no DialogueAction with keyword "{0}"'.format(keyword)
427            )
428            dialogue_action = None
429        else:
430            dialogue_action = action_type(*args, **kwargs)
431        return dialogue_action
432
433
434class OldYamlDialogueParser(YamlDialogueParser):
435    """
436    L{YAMLDialogueParser} that can read and write dialogues in the old
437    Techdemo1 dialogue file format.
438   
439    @warning: This class is deprecated and likely to be removed in a future
440        version.
441    """
442    logger = logging.getLogger('dialogueparser.OldYamlDialogueParser')
443   
444    def __init__(self):
445        self.response_actions = {}
446   
447    def load(self, stream):
448        dialogue = YamlDialogueParser.load(self, stream)
449        # Place all DialogueActions that were in DialogueSections into the
450        # DialogueResponse that led to the action's original section.
451        for section in dialogue.sections.values():
452            for response in section.responses:
453                actions = self.response_actions.get(response.next_section_id)
454                if (actions is not None):
455                    response.actions = actions
456        return dialogue
457   
458    def _constructDialogue(self, loader, yaml_node):
459        npc_name = None
460        avatar_path = None
461        start_section_id = None
462        sections = []
463       
464        try:
465            for key_node, value_node in yaml_node.value:
466                key = key_node.value
467                if (key == u'NPC'):
468                    npc_name = loader.construct_object(value_node)
469                elif (key == u'AVATAR'):
470                    avatar_path = loader.construct_object(value_node)
471                elif (key == u'START'):
472                    start_section_id = loader.construct_object(value_node)
473                elif (key == u'SECTIONS'):
474                    for id_node, section_node in value_node.value:
475                        dialogue_section = self._constructDialogueSection(
476                            loader,
477                            id_node,
478                            section_node
479                        )
480                        sections.append(dialogue_section)
481        except (AttributeError, TypeError, ValueError) as e:
482            raise DialogueFormatError(e)
483       
484        dialogue = Dialogue(npc_name=npc_name, avatar_path=avatar_path,
485                            start_section_id=start_section_id,
486                            dialogue_sections=sections)
487        return dialogue
488   
489    def _constructDialogueSection(self, loader, id_node, section_node):
490        id = loader.construct_object(id_node)
491        text = None
492        responses = []
493        actions = []
494        dialogue_section = None
495       
496        try:
497            for node in section_node.value:
498                key_node, value_node = node.value[0]
499                key = key_node.value
500                if (key == u'say'):
501                    text = loader.construct_object(value_node)
502                elif (key == u'meet'):
503                    action = self._constructDialogueAction(loader, node)
504                    actions.append(action)
505                elif (key in [u'start_quest', u'complete_quest', u'fail_quest',
506                              u'restart_quest', u'set_value',
507                              u'decrease_value', u'increase_value',
508                              u'give_stuff', u'get_stuff']):
509                    action = self._constructDialogueAction(loader, node)
510                    if (id not in self.response_actions.keys()):
511                        self.response_actions[id] = []
512                    self.response_actions[id].append(action)
513                elif (key == u'responses'):
514                    for response_node in value_node.value:
515                        dialogue_response = self._constructDialogueResponse(
516                            loader,
517                            response_node
518                        )
519                        responses.append(dialogue_response)
520        except (AttributeError, TypeError, ValueError) as e:
521            raise DialogueFormatError(e)
522        else:
523            dialogue_section = DialogueSection(id=id, text=text,
524                                               responses=responses,
525                                               actions=actions)
526       
527        return dialogue_section
528   
529    def _constructDialogueResponse(self, loader, response_node):
530        text = None
531        next_section_id = None
532        actions = []
533        condition = None
534       
535        try:
536            text = loader.construct_object(response_node.value[0])
537            next_section_id = loader.construct_object(response_node.value[1])
538            if (len(response_node.value) == 3):
539                condition = loader.construct_object(response_node.value[2])
540        except (AttributeError, TypeError, ValueError) as e:
541            raise DialogueFormatError(e)
542       
543        dialogue_response = DialogueResponse(text=text,
544                                             next_section_id=next_section_id,
545                                             actions=actions,
546                                             condition=condition)
547        return dialogue_response
548   
549    def _constructDialogueAction(self, loader, action_node):
550        mapping = loader.construct_mapping(action_node, deep=True)
551        keyword, arguments = mapping.items()[0]
552        if (keyword == 'get_stuff'):
553            # Renamed keyword in new syntax.
554            keyword = 'take_stuff'
555        elif (keyword == 'set_value'):
556            keyword = 'set_quest_value'
557        elif (keyword == 'increase_value'):
558            keyword = 'increase_quest_value'
559        elif (keyword == 'decrease_value'):
560            keyword = 'decrease_quest_value'
561        if (isinstance(arguments, dict)):
562            # Got a dictionary of keyword arguments.
563            args = ()
564            kwargs = arguments
565        elif (not isinstance(arguments, Sequence) or
566              isinstance(arguments, basestring)):
567            # Got a single positional argument.
568            args = (arguments,)
569            kwargs = {}
570        elif (not len(arguments) == 2 or not isinstance(arguments[1], dict)):
571            # Got a list of positional arguments.
572            args = arguments
573            kwargs = {}
574        else:
575            self.logger.error(
576                '{0} is an invalid DialogueAction argument'.format(arguments)
577            )
578            return None
579        action_type = DialogueAction.registered_actions.get(keyword)
580        if (action_type is None):
581            self.logger.error(
582                'no DialogueAction with keyword "{0}"'.format(keyword)
583            )
584            dialogue_action = None
585        else:
586            dialogue_action = action_type(*args, **kwargs)
587        return dialogue_action
Note: See TracBrowser for help on using the repository browser.