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

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

Patch by Technomage

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