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

Revision 699, 27.7 KB checked in by technomage, 9 years ago (diff)

Patch by Technomage

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