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

Revision 690, 24.2 KB checked in by technomage, 9 years ago (diff)

Patch by Aspidites

  • Renamed the 'dialogue_sections' parameter in Dialogue's constructor to 'sections' for clarity, since the 'dialogue_sections' parameter is stored in Dialogue.section;
  • Updated constructor keywords for Dialogue's constructor in dialogueparsers.py;
  • Removed an extraneous 'return' statement in DialogueProcessor?.runDialogueActions;
  • 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        try:
124            dialogue = \
125                self._constructDialogue(loader, loader.get_single_node())
126        except (AssertionError,) as error:
127            raise DialogueFormatError(str(error))
128        return dialogue
129   
130    def dump(self, dialogue, output_stream, dumper_class=yaml.Dumper):
131        """
132        Serialize a L{Dialogue} instance as YAML and dump it to an open stream.
133       
134        @param dialogue: dialogue to serialize.
135        @type dialogue: L{Dialogue}
136        @param stream: open stream into which the serialized L{Dialogue} should
137            be dumped.
138        @type stream: BufferType
139        @param dumper_class: PyYAML dumper class to use for formatting the
140            serialization.
141        @type dumper_class: yaml.BaseDumper subclass
142        """
143        intermediate_stream = StringIO()
144        # KLUDE Technomage 2010-11-16: The "width" argument seems to be broken,
145        #     as it doesn't take into about current line indentation and fails
146        #     to correctly wrap at word boundaries.
147        dumper = dumper_class(intermediate_stream, default_flow_style=False,
148                              indent=4, width=99999, line_break='\n',
149                              allow_unicode=True, explicit_start=True,
150                              explicit_end=True, tags=False)
151        dialogue_node = self._representDialogue(dumper, dialogue)
152        dumper.open()
153        dumper.serialize(dialogue_node)
154        dumper.close()
155        file_contents = intermediate_stream.getvalue()
156       
157        file_contents = re.sub(r'(\n|\r|\r\n)(\s*)(GOTO: .*)', r'\1\2\3\1\2',
158                               file_contents)
159        lines = file_contents.splitlines()
160        max_line_length = 76 # 79 - 3 chars for escaping newlines
161        for i in range(len(lines)):
162            line = lines[i]
163            match = re.match(
164                r'^(\s*(?:-\s+)?)(SAY|REPLY|CONDITION):\s+"(.*)"$',
165                line
166            )
167            if (match and len(line) > max_line_length):
168                # Wrap long lines for readability.
169                initial_indent = len(match.group(1))
170                subsequent_indent = initial_indent + 4
171                text_wrapper = textwrap.TextWrapper(
172                    max_line_length,
173                    subsequent_indent=' ' * subsequent_indent,
174                    break_long_words=False,
175                    break_on_hyphens=False
176                )
177                new_lines = text_wrapper.wrap(line)
178                new_lines = (
179                    new_lines[:1] + [re.sub(r'^(\s*) (.*)$', r'\1\ \2', l)
180                                     for l in new_lines[1:]]
181                )
182                lines[i] = '\\\n'.join(new_lines)
183       
184        output_stream.write(COPYRIGHT_HEADER)
185        output_stream.write('\n'.join(lines))
186       
187   
188    def _representDialogue(self, dumper, dialogue):
189        dialogue_node = dumper.represent_dict({})
190        dialogue_dict = OrderedDict()
191        dialogue_dict['NPC_NAME'] = dialogue.npc_name
192        dialogue_dict['AVATAR_PATH'] = dialogue.avatar_path
193        dialogue_dict['START_SECTION'] = dialogue.start_section_id
194        # NOTE Technomage 2010-11-16: Dialogue stores its sections in an
195        #     OrderedDict, so a round-trip load, dump, and load will preserve
196        #     the order of DialogueSections.
197        sections_list_node = dumper.represent_list([])
198        sections_list = sections_list_node.value
199        for section in dialogue.sections.values():
200            section_node = self._representDialogueSection(dumper, section)
201            sections_list.append(section_node)
202        dialogue_dict['SECTIONS'] = sections_list_node
203       
204        for key, value in dialogue_dict.items():
205            if (isinstance(key, yaml.Node)):
206                key_node = key
207            else:
208                key_node = dumper.represent_data(key)
209            if (isinstance(value, yaml.Node)):
210                value_node = value
211            else:
212                value_node = dumper.represent_data(value)
213            dialogue_node.value.append((key_node, value_node))
214        return dialogue_node
215   
216    def _representDialogueSection(self, dumper, dialogue_section):
217        section_node = dumper.represent_dict({})
218        section_dict = OrderedDict() # OrderedDict is required to preserve
219                                     # the order of attributes.
220        section_dict['ID'] = dialogue_section.id
221        # KLUDGE Technomage 2010-11-16: Hard-coding the tag like this could be
222        #     a problem when writing unicode.
223        section_dict['SAY'] = dumper.represent_scalar('tag:yaml.org,2002:str',
224                                                      dialogue_section.text,
225                                                      style='"')
226        actions_list_node = dumper.represent_list([])
227        actions_list = actions_list_node.value
228        for action in dialogue_section.actions:
229            action_node = self._representDialogueAction(dumper, action)
230            actions_list.append(action_node)
231        if (actions_list):
232            section_dict['ACTIONS'] = actions_list_node
233        responses_list_node = dumper.represent_list([])
234        responses_list = responses_list_node.value
235        for response in dialogue_section.responses:
236            response_node = self._representDialogueResponse(dumper, response)
237            responses_list.append(response_node)
238        section_dict['RESPONSES'] = responses_list_node
239       
240        for key, value in section_dict.items():
241            if (isinstance(key, yaml.Node)):
242                key_node = key
243            else:
244                key_node = dumper.represent_data(key)
245            if (isinstance(value, yaml.Node)):
246                value_node = value
247            else:
248                value_node = dumper.represent_data(value)
249            section_node.value.append((key_node, value_node))
250        return section_node
251   
252    def _representDialogueResponse(self, dumper, dialogue_response):
253        response_node = dumper.represent_dict({})
254        response_dict = OrderedDict()
255        # KLUDGE Technomage 2010-11-16: Hard-coding the tag like this could be
256        #     a problem when writing unicode.
257        response_dict['REPLY'] = dumper.represent_scalar(
258            'tag:yaml.org,2002:str',
259            dialogue_response.text,
260            style='"')
261        if (dialogue_response.condition is not None):
262            response_dict['CONDITION']  = dumper.represent_scalar(
263                'tag:yaml.org,2002:str',
264                dialogue_response.condition,
265                style='"'
266            )
267        actions_list_node = dumper.represent_list([])
268        actions_list = actions_list_node.value
269        for action in dialogue_response.actions:
270            action_node = self._representDialogueAction(dumper, action)
271            actions_list.append(action_node)
272        if (actions_list):
273            response_dict['ACTIONS'] = actions_list_node
274        response_dict['GOTO'] = dialogue_response.next_section_id
275       
276        for key, value in response_dict.items():
277            if (isinstance(key, yaml.Node)):
278                key_node = key
279            else:
280                key_node = dumper.represent_data(key)
281            if (isinstance(value, yaml.Node)):
282                value_node = value
283            else:
284                value_node = dumper.represent_data(value)
285            response_node.value.append((key_node, value_node))
286        return response_node
287   
288    def _representDialogueAction(self, dumper, dialogue_action):
289        action_node = dumper.represent_dict({})
290        action_dict = OrderedDict()
291        args, kwargs = dialogue_action.arguments
292        if (args and not kwargs):
293            arguments = list(args)
294        elif (kwargs and not args):
295            arguments = kwargs
296        else:
297            arguments = [list(args), kwargs]
298        action_dict[dialogue_action.keyword] = arguments
299       
300        for key, value in action_dict.items():
301            if (isinstance(key, yaml.Node)):
302                key_node = key
303            else:
304                key_node = dumper.represent_data(key)
305            if (isinstance(value, yaml.Node)):
306                value_node = value
307            else:
308                value_node = dumper.represent_data(value)
309            action_node.value.append((key_node, value_node))
310        return action_node
311   
312    def _constructDialogue(self, loader, yaml_node):
313        npc_name = None
314        avatar_path = None
315        start_section_id = None
316        sections = []
317       
318        try:
319            for key_node, value_node in yaml_node.value:
320                key = key_node.value
321                if (key == u'NPC_NAME'):
322                    npc_name = loader.construct_object(value_node)
323                elif (key == u'AVATAR_PATH'):
324                    avatar_path = loader.construct_object(value_node)
325                elif (key == u'START_SECTION'):
326                    start_section_id = loader.construct_object(value_node)
327                elif (key == u'SECTIONS'):
328                    for section_node in value_node.value:
329                        dialogue_section = self._constructDialogueSection(
330                            loader,
331                            section_node
332                        )
333                        sections.append(dialogue_section)
334        except (AttributeError, TypeError, ValueError) as e:
335            raise DialogueFormatError(e)
336       
337        dialogue = Dialogue(npc_name=npc_name, avatar_path=avatar_path,
338                            start_section_id=start_section_id,
339                            sections=sections)
340        return dialogue
341   
342    def _constructDialogueSection(self, loader, section_node):
343        id = None
344        text = None
345        responses = []
346        actions = []
347        dialogue_section = None
348       
349        try:
350            for key_node, value_node in section_node.value:
351                key = key_node.value
352                if (key == u'ID'):
353                    id = loader.construct_object(value_node)
354                elif (key == u'SAY'):
355                    text = loader.construct_object(value_node)
356                elif (key == u'RESPONSES'):
357                    for response_node in value_node.value:
358                        dialogue_response = self._constructDialogueResponse(
359                            loader,
360                            response_node
361                        )
362                        responses.append(dialogue_response)
363                elif (key == u'ACTIONS'):
364                    for action_node in value_node.value:
365                        action = self._constructDialogueAction(loader,
366                                                             action_node)
367                        actions.append(action)
368        except (AttributeError, TypeError, ValueError) as e:
369            raise DialogueFormatError(e)
370        else:
371            dialogue_section = DialogueSection(id=id, text=text,
372                                               responses=responses,
373                                               actions=actions)
374       
375        return dialogue_section
376   
377    def _constructDialogueResponse(self, loader, response_node):
378        text = None
379        next_section_id = None
380        actions = []
381        condition = None
382       
383        try:
384            for key_node, value_node in response_node.value:
385                key = key_node.value
386                if (key == u'REPLY'):
387                    text = loader.construct_object(value_node)
388                elif (key == u'ACTIONS'):
389                    for action_node in value_node.value:
390                        action = self._constructDialogueAction(loader,
391                                                             action_node)
392                        actions.append(action)
393                elif (key == u'CONDITION'):
394                    condition = loader.construct_object(value_node)
395                elif (key == u'GOTO'):
396                    next_section_id = loader.construct_object(value_node)
397        except (AttributeError, TypeError, ValueError) as e:
398            raise DialogueFormatError(e)
399       
400        dialogue_response = DialogueResponse(text=text,
401                                             next_section_id=next_section_id,
402                                             actions=actions,
403                                             condition=condition)
404        return dialogue_response
405   
406    def _constructDialogueAction(self, loader, action_node):
407        mapping = loader.construct_mapping(action_node, deep=True)
408        keyword, arguments = mapping.items()[0]
409        if (isinstance(arguments, dict)):
410            # Got a dictionary of keyword arguments.
411            args = ()
412            kwargs = arguments
413        elif (not isinstance(arguments, Sequence) or
414              isinstance(arguments, basestring)):
415            # Got a single positional argument.
416            args = (arguments,)
417            kwargs = {}
418        elif (not len(arguments) == 2 or not isinstance(arguments[1], dict)):
419            # Got a list of positional arguments.
420            args = arguments
421            kwargs = {}
422        else:
423            self.logger.error(
424                '{0} is an invalid DialogueAction argument'.format(arguments)
425            )
426            return None
427       
428        action_type = DialogueAction.registered_actions.get(keyword)
429        if (action_type is None):
430            self.logger.error(
431                'no DialogueAction with keyword "{0}"'.format(keyword)
432            )
433            dialogue_action = None
434        else:
435            dialogue_action = action_type(*args, **kwargs)
436        return dialogue_action
437
438
439class OldYamlDialogueParser(YamlDialogueParser):
440    """
441    L{YAMLDialogueParser} that can read and write dialogues in the old
442    Techdemo1 dialogue file format.
443   
444    @warning: This class is deprecated and likely to be removed in a future
445        version.
446    """
447    logger = logging.getLogger('dialogueparser.OldYamlDialogueParser')
448   
449    def __init__(self):
450        self.response_actions = {}
451   
452    def load(self, stream):
453        dialogue = YamlDialogueParser.load(self, stream)
454        # Place all DialogueActions that were in DialogueSections into the
455        # DialogueResponse that led to the action's original section.
456        for section in dialogue.sections.values():
457            for response in section.responses:
458                actions = self.response_actions.get(response.next_section_id)
459                if (actions is not None):
460                    response.actions = actions
461        return dialogue
462   
463    def _constructDialogue(self, loader, yaml_node):
464        npc_name = None
465        avatar_path = None
466        start_section_id = None
467        sections = []
468       
469        try:
470            for key_node, value_node in yaml_node.value:
471                key = key_node.value
472                if (key == u'NPC'):
473                    npc_name = loader.construct_object(value_node)
474                elif (key == u'AVATAR'):
475                    avatar_path = loader.construct_object(value_node)
476                elif (key == u'START'):
477                    start_section_id = loader.construct_object(value_node)
478                elif (key == u'SECTIONS'):
479                    for id_node, section_node in value_node.value:
480                        dialogue_section = self._constructDialogueSection(
481                            loader,
482                            id_node,
483                            section_node
484                        )
485                        sections.append(dialogue_section)
486        except (AttributeError, TypeError, ValueError) as e:
487            raise DialogueFormatError(e)
488       
489        dialogue = Dialogue(npc_name=npc_name, avatar_path=avatar_path,
490                            start_section_id=start_section_id,
491                            sections=sections)
492        return dialogue
493   
494    def _constructDialogueSection(self, loader, id_node, section_node):
495        id = loader.construct_object(id_node)
496        text = None
497        responses = []
498        actions = []
499        dialogue_section = None
500       
501        try:
502            for node in section_node.value:
503                key_node, value_node = node.value[0]
504                key = key_node.value
505                if (key == u'say'):
506                    text = loader.construct_object(value_node)
507                elif (key == u'meet'):
508                    action = self._constructDialogueAction(loader, node)
509                    actions.append(action)
510                elif (key in [u'start_quest', u'complete_quest', u'fail_quest',
511                              u'restart_quest', u'set_value',
512                              u'decrease_value', u'increase_value',
513                              u'give_stuff', u'get_stuff']):
514                    action = self._constructDialogueAction(loader, node)
515                    if (id not in self.response_actions.keys()):
516                        self.response_actions[id] = []
517                    self.response_actions[id].append(action)
518                elif (key == u'responses'):
519                    for response_node in value_node.value:
520                        dialogue_response = self._constructDialogueResponse(
521                            loader,
522                            response_node
523                        )
524                        responses.append(dialogue_response)
525        except (AttributeError, TypeError, ValueError) as e:
526            raise DialogueFormatError(e)
527        else:
528            dialogue_section = DialogueSection(id=id, text=text,
529                                               responses=responses,
530                                               actions=actions)
531       
532        return dialogue_section
533   
534    def _constructDialogueResponse(self, loader, response_node):
535        text = None
536        next_section_id = None
537        actions = []
538        condition = None
539       
540        try:
541            text = loader.construct_object(response_node.value[0])
542            next_section_id = loader.construct_object(response_node.value[1])
543            if (len(response_node.value) == 3):
544                condition = loader.construct_object(response_node.value[2])
545        except (AttributeError, TypeError, ValueError) as e:
546            raise DialogueFormatError(e)
547       
548        dialogue_response = DialogueResponse(text=text,
549                                             next_section_id=next_section_id,
550                                             actions=actions,
551                                             condition=condition)
552        return dialogue_response
553   
554    def _constructDialogueAction(self, loader, action_node):
555        mapping = loader.construct_mapping(action_node, deep=True)
556        keyword, arguments = mapping.items()[0]
557        if (keyword == 'get_stuff'):
558            # Renamed keyword in new syntax.
559            keyword = 'take_stuff'
560        elif (keyword == 'set_value'):
561            keyword = 'set_quest_value'
562        elif (keyword == 'increase_value'):
563            keyword = 'increase_quest_value'
564        elif (keyword == 'decrease_value'):
565            keyword = 'decrease_quest_value'
566        if (isinstance(arguments, dict)):
567            # Got a dictionary of keyword arguments.
568            args = ()
569            kwargs = arguments
570        elif (not isinstance(arguments, Sequence) or
571              isinstance(arguments, basestring)):
572            # Got a single positional argument.
573            args = (arguments,)
574            kwargs = {}
575        elif (not len(arguments) == 2 or not isinstance(arguments[1], dict)):
576            # Got a list of positional arguments.
577            args = arguments
578            kwargs = {}
579        else:
580            self.logger.error(
581                '{0} is an invalid DialogueAction argument'.format(arguments)
582            )
583            return None
584        action_type = DialogueAction.registered_actions.get(keyword)
585        if (action_type is None):
586            self.logger.error(
587                'no DialogueAction with keyword "{0}"'.format(keyword)
588            )
589            dialogue_action = None
590        else:
591            dialogue_action = action_type(*args, **kwargs)
592        return dialogue_action
Note: See TracBrowser for help on using the repository browser.