view dialogueparsers.py @ 51:d3a9caba067b

Modifications to make talking with npcs possible again. Special actions won't work yet though.
author KarstenBock@gmx.net
date Thu, 08 Sep 2011 15:18:39 +0200
parents 06145a6ee387
children
line wrap: on
line source

#   This file is part of PARPG.
#
#   PARPG is free software: you can redistribute it and/or modify
#   it under the terms of the GNU General Public License as published by
#   the Free Software Foundation, either version 3 of the License, or
#   (at your option) any later version.
#
#   PARPG is distributed in the hope that it will be useful,
#   but WITHOUT ANY WARRANTY; without even the implied warranty of
#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#   GNU General Public License for more details.
#
#   You should have received a copy of the GNU General Public License
#   along with PARPG.  If not, see <http://www.gnu.org/licenses/>.
"""
Contains classes for parsing and validating L{Dialogues<Dialogue>} and other
dialogue-related data.

@TODO Technomage 2010-11-13: Exception handling + validation needs work.
    Currently YAML files are only crudely validated - the code assumes that
    the file contains valid dialogue data, and if that assumption is
    violated and causes the code to raise any TypeErrors, AttributeErrors or
    ValueErrors the code then raises a DialogueFormatError with the
    original (and mostly unhelpful) error message.
@TODO Technomage 2010-11-13: Support reading and writing unicode.
"""
try:
    from cStringIO import StringIO
except ImportError:
    from StringIO import StringIO
from collections import Sequence
try:
    from collections import OrderedDict
except ImportError:
    # Python version 2.4-2.6 doesn't have the OrderedDict
    from parpg.common.ordereddict import OrderedDict
import re
import textwrap

import yaml

from parpg import COPYRIGHT_HEADER
from parpg.dialogue import (Dialogue, DialogueSection, DialogueResponse,
    DialogueGreeting)
from parpg.dialogueactions import DialogueAction

import logging
logger = logging.getLogger('dialogueparser')

class DialogueFormatError(Exception):
    """Exception thrown when the DialogueParser has encountered an error."""


class AbstractDialogueParser(object):
    """
    Abstract base class defining the interface for parsers responsible for
    constructing a L{Dialogue} from its serialized representation.
    """
    def load(self, stream):
        """
        Parse a stream and attempt to construct a new L{Dialogue} instance from
        its serialized representation.
        
        @param stream: open stream containing the serialized representation of
            a Dialogue.
        @type stream: BufferType
        """
        raise NotImplementedError('AbstractDialogueParser subclasses must '
                                  'override the load method.')
    
    def dump(self, dialogue, stream):
        """
        Serialize a L{Dialogue} instance and dump it to an open stream.
        
        @param dialogue: dialogue to serialize.
        @type dialogue: L{Dialogue}
        @param stream: open stream into which the serialized L{Dialogue} should
            be dumped.
        @type stream: BufferType
        """
        raise NotImplementedError('AbstractDialogueParser subclasses must '
                                  'override the dump method.')
    
    def validate(self, stream):
        """
        Parse a stream and verify that it contains a valid serialization of a
        L{Dialogue instance}.
        
        @param stream: stream containing the serialized representation of a
            L{Dialogue}
        @type stream: BufferType
        """
        raise NotImplementedError('AbstractDialogueParser subclasses must '
                                  'override the validate method.')


class YamlDialogueParser(AbstractDialogueParser):
    """
    L{AbstractDialogueParser} subclass responsible for parsing dialogues
    serialized in YAML.
    """
    logger = logging.getLogger('dialogueparser.OldYamlDialogueParser')
    
    def load(self, stream, loader_class=yaml.Loader):
        """
        Parse a YAML stream and attempt to construct a new L{Dialogue}
        instance.
        
        @param stream: stream containing the serialized YAML representation of
            a L{Dialogue}.
        @type stream: BufferType
        @param loader_class: PyYAML loader class to use for reading the
            serialization.
        @type loader_class: yaml.BaseLoader subclass
        """
        loader = loader_class(stream)
        dialogue = self._constructDialogue(loader, loader.get_single_node())
        return dialogue
    
    def dump(self, dialogue, output_stream, dumper_class=yaml.Dumper):
        """
        Serialize a L{Dialogue} instance as YAML and dump it to an open stream.
        
        @param dialogue: dialogue to serialize.
        @type dialogue: L{Dialogue}
        @param stream: open stream into which the serialized L{Dialogue} should
            be dumped.
        @type stream: BufferType
        @param dumper_class: PyYAML dumper class to use for formatting the
            serialization.
        @type dumper_class: yaml.BaseDumper subclass
        """
        intermediate_stream = StringIO()
        # KLUDE Technomage 2010-11-16: The "width" argument seems to be broken,
        #     as it doesn't take into about current line indentation and fails
        #     to correctly wrap at word boundaries.
        dumper = dumper_class(intermediate_stream, default_flow_style=False,
                              indent=4, width=99999, line_break='\n',
                              allow_unicode=True, explicit_start=True,
                              explicit_end=True, tags=False)
        dialogue_node = self._representDialogue(dumper, dialogue)
        dumper.open()
        dumper.serialize(dialogue_node)
        dumper.close()
        file_contents = intermediate_stream.getvalue()
        
        file_contents = re.sub(r'(\n|\r|\r\n)(\s*)(GOTO: .*)', r'\1\2\3\1\2',
                               file_contents)
        lines = file_contents.splitlines()
        max_line_length = 76 # 79 - 3 chars for escaping newlines
        for i in range(len(lines)):
            line = lines[i]
            match = re.match(
                r'^(\s*(?:-\s+)?)(SAY|REPLY|CONDITION):\s+"(.*)"$',
                line
            )
            if (match and len(line) > max_line_length):
                # Wrap long lines for readability.
                initial_indent = len(match.group(1))
                subsequent_indent = initial_indent + 4
                text_wrapper = textwrap.TextWrapper(
                    max_line_length,
                    subsequent_indent=' ' * subsequent_indent,
                    break_long_words=False,
                    break_on_hyphens=False
                )
                new_lines = text_wrapper.wrap(line)
                new_lines = (
                    new_lines[:1] + [re.sub(r'^(\s*) (.*)$', r'\1\ \2', l)
                                     for l in new_lines[1:]]
                )
                lines[i] = '\\\n'.join(new_lines)
        
        output_stream.write(COPYRIGHT_HEADER)
        output_stream.write('\n'.join(lines))
        
    
    def _representDialogue(self, dumper, dialogue):
        dialogue_node = dumper.represent_dict({})
        dialogue_dict = OrderedDict()
        dialogue_dict['NPC_NAME'] = dialogue.npc_name
        dialogue_dict['AVATAR_PATH'] = dialogue.avatar_path
        dialogue_dict['DEFAULT_GREETING'] = \
            self._representDialogueSection(dumper,
                                           dialogue.default_greeting)
        # NOTE Technomage 2010-11-16: Dialogue stores its sections in an
        #     OrderedDict, so a round-trip load, dump, and load will preserve
        #     the order of DialogueSections.
        if (len(dialogue.greetings) > 0):
            greetings_list_node = dumper.represent_list([])
            greetings_list = greetings_list_node.value
            for greeting in dialogue.greetings:
                greeting_node = \
                    self._representRootDialogueSection(dumper, greeting)
                greetings_list.append(greeting_node)
            dialogue_dict['GREETINGS'] = greetings_list_node
        if (len(dialogue.setions) > 0):
            sections_list_node = dumper.represent_list([])
            sections_list = sections_list_node.value
            for section in dialogue.sections.values():
                section_node = self._representDialogueSection(dumper, section)
                sections_list.append(section_node)
            dialogue_dict['SECTIONS'] = sections_list_node
        
        for key, value in dialogue_dict.items():
            if (isinstance(key, yaml.Node)):
                key_node = key
            else:
                key_node = dumper.represent_data(key)
            if (isinstance(value, yaml.Node)):
                value_node = value
            else:
                value_node = dumper.represent_data(value)
            dialogue_node.value.append((key_node, value_node))
        return dialogue_node
    
    def _representRootDialogueSection(self, dumper, greeting):
        greeting_node = dumper.represent_dict({})
        greeting_dict = OrderedDict()
        greeting_dict['ID'] = greeting.id
        greeting_dict['CONDITION'] = dumper.represent_scalar(
            'tag:yaml.org,2002:str',
            greeting.condition,
            style='"'
        )
        for key, value in greeting_dict.items():
            if (isinstance(key, yaml.Node)):
                key_node = key
            else:
                key_node = dumper.represent_data(key)
            if (isinstance(value, yaml.Node)):
                value_node = value
            else:
                value_node = dumper.represent_data(value)
            greeting_node.value.append((key_node, value_node))
        return greeting_node
    
    def _representDialogueSection(self, dumper, dialogue_section):
        section_node = dumper.represent_dict({})
        section_dict = OrderedDict() # OrderedDict is required to preserve
                                     # the order of attributes.
        section_dict['ID'] = dialogue_section.id
        # KLUDGE Technomage 2010-11-16: Hard-coding the tag like this could be
        #     a problem when writing unicode.
        section_dict['SAY'] = dumper.represent_scalar('tag:yaml.org,2002:str',
                                                      dialogue_section.text,
                                                      style='"')
        actions_list_node = dumper.represent_list([])
        actions_list = actions_list_node.value
        for action in dialogue_section.actions:
            action_node = self._representDialogueAction(dumper, action)
            actions_list.append(action_node)
        if (actions_list):
            section_dict['ACTIONS'] = actions_list_node
        responses_list_node = dumper.represent_list([])
        responses_list = responses_list_node.value
        for response in dialogue_section.responses:
            response_node = self._representDialogueResponse(dumper, response)
            responses_list.append(response_node)
        section_dict['RESPONSES'] = responses_list_node
        
        for key, value in section_dict.items():
            if (isinstance(key, yaml.Node)):
                key_node = key
            else:
                key_node = dumper.represent_data(key)
            if (isinstance(value, yaml.Node)):
                value_node = value
            else:
                value_node = dumper.represent_data(value)
            section_node.value.append((key_node, value_node))
        return section_node
    
    def _representDialogueResponse(self, dumper, dialogue_response):
        response_node = dumper.represent_dict({})
        response_dict = OrderedDict()
        # KLUDGE Technomage 2010-11-16: Hard-coding the tag like this could be
        #     a problem when writing unicode.
        response_dict['REPLY'] = dumper.represent_scalar(
            'tag:yaml.org,2002:str',
            dialogue_response.text,
            style='"')
        if (dialogue_response.condition is not None):
            response_dict['CONDITION']  = dumper.represent_scalar(
                'tag:yaml.org,2002:str',
                dialogue_response.condition,
                style='"'
            )
        actions_list_node = dumper.represent_list([])
        actions_list = actions_list_node.value
        for action in dialogue_response.actions:
            action_node = self._representDialogueAction(dumper, action)
            actions_list.append(action_node)
        if (actions_list):
            response_dict['ACTIONS'] = actions_list_node
        response_dict['GOTO'] = dialogue_response.next_section_id
        
        for key, value in response_dict.items():
            if (isinstance(key, yaml.Node)):
                key_node = key
            else:
                key_node = dumper.represent_data(key)
            if (isinstance(value, yaml.Node)):
                value_node = value
            else:
                value_node = dumper.represent_data(value)
            response_node.value.append((key_node, value_node))
        return response_node
    
    def _representDialogueAction(self, dumper, dialogue_action):
        action_node = dumper.represent_dict({})
        action_dict = OrderedDict()
        args, kwargs = dialogue_action.arguments
        if (args and not kwargs):
            arguments = list(args)
        elif (kwargs and not args):
            arguments = kwargs
        else:
            arguments = [list(args), kwargs]
        action_dict[dialogue_action.keyword] = arguments
        
        for key, value in action_dict.items():
            if (isinstance(key, yaml.Node)):
                key_node = key
            else:
                key_node = dumper.represent_data(key)
            if (isinstance(value, yaml.Node)):
                value_node = value
            else:
                value_node = dumper.represent_data(value)
            action_node.value.append((key_node, value_node))
        return action_node
    
    def _constructDialogue(self, loader, yaml_node):
        npc_name = None
        avatar_path = None
        default_greeting = None
        greetings = []
        sections = []
        
        try:
            for key_node, value_node in yaml_node.value:
                key = key_node.value
                if (key == u'NPC_NAME'):
                    npc_name = loader.construct_object(value_node)
                elif (key == u'AVATAR_PATH'):
                    avatar_path = loader.construct_object(value_node)
                elif (key == u'DEFAULT_GREETING'):
                    default_greeting = \
                        self._constructDialogueSection(loader, value_node)
                elif (key == u'GREETINGS'):
                    for greeting_node in value_node.value:
                        greeting = self._constructRootDialogueSection(
                                loader,
                                greeting_node
                        )
                        greetings.append(
                            greeting
                        )
                elif (key == u'SECTIONS'):
                    for section_node in value_node.value:
                        dialogue_section = self._constructDialogueSection(
                            loader,
                            section_node
                        )
                        sections.append(dialogue_section)
        except (AttributeError, TypeError, ValueError,
                yaml.scanner.ScannerError) as error:
            raise DialogueFormatError(error)
        
        dialogue = Dialogue(npc_name=npc_name, avatar_path=avatar_path,
                            default_greeting=default_greeting,
                            greetings=greetings,
                            sections=sections)
        return dialogue
    
    def _constructRootDialogueSection(self, loader, greeting_node):
        id = None
        text = None
        condition = None
        responses = []
        actions = []
        greeting = None
        
        try:
            for key_node, value_node in greeting_node.value:
                key = key_node.value
                if (key == u'ID'):
                    id = loader.construct_object(value_node)
                elif (key == u'SAY'):
                    text = loader.construct_object(value_node)
                elif (key == u'CONDITION'):
                    condition = loader.construct_object(value_node)
                elif (key == u'RESPONSES'):
                    for response_node in value_node.value:
                        dialogue_response = self._constructDialogueResponse(
                            loader,
                            response_node
                        )
                        responses.append(dialogue_response)
                elif (key == u'ACTIONS'):
                    for action_node in value_node.value:
                        action = self._constructDialogueAction(loader,
                                                             action_node)
                        actions.append(action)
        except (AttributeError, TypeError, ValueError) as e:
            raise DialogueFormatError(e)
        else:
            greeting = DialogueSection(id=id, text=text,
                                           condition=condition,
                                           responses=responses,
                                           actions=actions)
        
        return greeting
    
    def _constructDialogueSection(self, loader, section_node):
        id_ = None
        text = None
        responses = []
        actions = []
        dialogue_section = None
        
        try:
            for key_node, value_node in section_node.value:
                key = key_node.value
                if (key == u'ID'):
                    id_ = loader.construct_object(value_node)
                elif (key == u'SAY'):
                    text = loader.construct_object(value_node)
                elif (key == u'RESPONSES'):
                    for response_node in value_node.value:
                        dialogue_response = self._constructDialogueResponse(
                            loader,
                            response_node
                        )
                        responses.append(dialogue_response)
                elif (key == u'ACTIONS'):
                    for action_node in value_node.value:
                        action = self._constructDialogueAction(loader,
                                                             action_node)
                        actions.append(action)
        except (AttributeError, TypeError, ValueError) as e:
            raise DialogueFormatError(e)
        else:
            dialogue_section = DialogueSection(id_=id_, text=text,
                                               responses=responses,
                                               actions=actions)
        
        return dialogue_section
    
    def _constructDialogueResponse(self, loader, response_node):
        text = None
        next_section_id = None
        actions = []
        condition = None
        
        try:
            for key_node, value_node in response_node.value:
                key = key_node.value
                if (key == u'REPLY'):
                    text = loader.construct_object(value_node)
                elif (key == u'ACTIONS'):
                    for action_node in value_node.value:
                        action = self._constructDialogueAction(loader,
                                                             action_node)
                        actions.append(action)
                elif (key == u'CONDITION'):
                    condition = loader.construct_object(value_node)
                elif (key == u'GOTO'):
                    next_section_id = loader.construct_object(value_node)
        except (AttributeError, TypeError, ValueError) as e:
            raise DialogueFormatError(e)
        
        dialogue_response = DialogueResponse(text=text,
                                             next_section_id=next_section_id,
                                             actions=actions,
                                             condition=condition)
        return dialogue_response
    
    def _constructDialogueAction(self, loader, action_node):
        mapping = loader.construct_mapping(action_node, deep=True)
        keyword, arguments = mapping.items()[0]
        if (isinstance(arguments, dict)):
            # Got a dictionary of keyword arguments.
            args = ()
            kwargs = arguments
        elif (not isinstance(arguments, Sequence) or
              isinstance(arguments, basestring)):
            # Got a single positional argument.
            args = (arguments,)
            kwargs = {}
        elif (not len(arguments) == 2 or not isinstance(arguments[1], dict)):
            # Got a list of positional arguments.
            args = arguments
            kwargs = {}
        else:
            self.logger.error(
                '{0} is an invalid DialogueAction argument'.format(arguments)
            )
            return None
        
        action_type = DialogueAction.registered_actions.get(keyword)
        if (action_type is None):
            self.logger.error(
                'no DialogueAction with keyword "{0}"'.format(keyword)
            )
            dialogue_action = None
        else:
            dialogue_action = action_type(*args, **kwargs)
        return dialogue_action


class OldYamlDialogueParser(YamlDialogueParser):
    """
    L{YAMLDialogueParser} that can read and write dialogues in the old
    Techdemo1 dialogue file format.
    
    @warning: This class is deprecated and likely to be removed in a future
        version.
    """
    logger = logging.getLogger('dialogueparser.OldYamlDialogueParser')
    
    def __init__(self):
        self.response_actions = {}
    
    def load(self, stream):
        dialogue = YamlDialogueParser.load(self, stream)
        # Place all DialogueActions that were in DialogueSections into the
        # DialogueResponse that led to the action's original section.
        for section in dialogue.sections.values():
            for response in section.responses:
                actions = self.response_actions.get(response.next_section_id)
                if (actions is not None):
                    response.actions = actions
        return dialogue
    
    def _constructDialogue(self, loader, yaml_node):
        npc_name = None
        avatar_path = None
        start_section_id = None
        sections = []
        
        try:
            for key_node, value_node in yaml_node.value:
                key = key_node.value
                if (key == u'NPC'):
                    npc_name = loader.construct_object(value_node)
                elif (key == u'AVATAR'):
                    avatar_path = loader.construct_object(value_node)
                elif (key == u'START'):
                    start_section_id = loader.construct_object(value_node)
                elif (key == u'SECTIONS'):
                    for id_node, section_node in value_node.value:
                        dialogue_section = self._constructDialogueSection(
                            loader,
                            id_node,
                            section_node
                        )
                        sections.append(dialogue_section)
        except (AttributeError, TypeError, ValueError) as e:
            raise DialogueFormatError(e)
        
        dialogue = Dialogue(npc_name=npc_name, avatar_path=avatar_path,
                            start_section_id=start_section_id,
                            sections=sections)
        return dialogue
    
    def _constructDialogueSection(self, loader, id_node, section_node):
        id = loader.construct_object(id_node)
        text = None
        responses = []
        actions = []
        dialogue_section = None
        
        try:
            for node in section_node.value:
                key_node, value_node = node.value[0]
                key = key_node.value
                if (key == u'say'):
                    text = loader.construct_object(value_node)
                elif (key == u'meet'):
                    action = self._constructDialogueAction(loader, node)
                    actions.append(action)
                elif (key in [u'start_quest', u'complete_quest', u'fail_quest',
                              u'restart_quest', u'set_value',
                              u'decrease_value', u'increase_value',
                              u'give_stuff', u'get_stuff']):
                    action = self._constructDialogueAction(loader, node)
                    if (id not in self.response_actions.keys()):
                        self.response_actions[id] = []
                    self.response_actions[id].append(action)
                elif (key == u'responses'):
                    for response_node in value_node.value:
                        dialogue_response = self._constructDialogueResponse(
                            loader,
                            response_node
                        )
                        responses.append(dialogue_response)
        except (AttributeError, TypeError, ValueError) as e:
            raise DialogueFormatError(e)
        else:
            dialogue_section = DialogueSection(id=id, text=text,
                                               responses=responses,
                                               actions=actions)
        
        return dialogue_section
    
    def _constructDialogueResponse(self, loader, response_node):
        text = None
        next_section_id = None
        actions = []
        condition = None
        
        try:
            text = loader.construct_object(response_node.value[0])
            next_section_id = loader.construct_object(response_node.value[1])
            if (len(response_node.value) == 3):
                condition = loader.construct_object(response_node.value[2])
        except (AttributeError, TypeError, ValueError) as e:
            raise DialogueFormatError(e)
        
        dialogue_response = DialogueResponse(text=text,
                                             next_section_id=next_section_id,
                                             actions=actions,
                                             condition=condition)
        return dialogue_response
    
    def _constructDialogueAction(self, loader, action_node):
        mapping = loader.construct_mapping(action_node, deep=True)
        keyword, arguments = mapping.items()[0]
        if (keyword == 'get_stuff'):
            # Renamed keyword in new syntax.
            keyword = 'take_stuff'
        elif (keyword == 'set_value'):
            keyword = 'set_quest_value'
        elif (keyword == 'increase_value'):
            keyword = 'increase_quest_value'
        elif (keyword == 'decrease_value'):
            keyword = 'decrease_quest_value'
        if (isinstance(arguments, dict)):
            # Got a dictionary of keyword arguments.
            args = ()
            kwargs = arguments
        elif (not isinstance(arguments, Sequence) or
              isinstance(arguments, basestring)):
            # Got a single positional argument.
            args = (arguments,)
            kwargs = {}
        elif (not len(arguments) == 2 or not isinstance(arguments[1], dict)):
            # Got a list of positional arguments.
            args = arguments
            kwargs = {}
        else:
            self.logger.error(
                '{0} is an invalid DialogueAction argument'.format(arguments)
            )
            return None
        action_type = DialogueAction.registered_actions.get(keyword)
        if (action_type is None):
            self.logger.error(
                'no DialogueAction with keyword "{0}"'.format(keyword)
            )
            dialogue_action = None
        else:
            dialogue_action = action_type(*args, **kwargs)
        return dialogue_action