Source code for textsmith.parser

"""
Functions for parsing the user input. Calls into the game logic layer to affect
changes and read data from the datastore.

Copyright (C) 2020 Nicholas H.Tollervey (ntoll@ntoll.org).

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.

This program 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 Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program.  If not, see <https://www.gnu.org/licenses/>
"""
import random
import html
import structlog  # type: ignore
from uuid import uuid4
from textsmith.logic import Logic
from textsmith.verbs import Verbs, UnknownVerb
from flask_babel import gettext as _  # type: ignore
from textsmith import constants


logger = structlog.get_logger()


[docs]class Parser: """ Gathers together methods to parse user input. Uses the dependency injection pattern. """ def __init__(self, logic: Logic) -> None: """ The logic object contains methods for implementing game logic and state transitions. """ self.logic = logic self.verbs = Verbs(self.logic) # Built in verbs/functions.
[docs] async def eval( self, user_id: int, connection_id: str, message: str ) -> None: """ Evaluate the user's input message. If there's an error, recover by sending the error message from the associated exception object. """ # Give new messages a message_id for debugging purposes. message_id = str(uuid4()) logger.msg( "Assigning message id.", message=message, message_id=message_id, user_id=user_id, connection_id=connection_id, ) try: # All user input is immediately cleaned so it's safe to render # client side. escaped = html.escape(message) await self.parse(user_id, connection_id, message_id, escaped) except Exception as ex: await self.handle_exception( user_id, connection_id, message_id, message, ex )
[docs] async def handle_exception( self, user_id: int, connection_id: str, message_id: str, message: str, exception: Exception, ) -> None: """ Given an exception raised in the logic or parsing layer of the game, extract the useful message which explains what the problem is, and turn it into a message back to the referenced user. """ logger.msg( "Exception.", user_id=user_id, connection_id=connection_id, message_id=message_id, message=message, exc_info=exception, ) reply: str = " ".join( [ _("Sorry. Something went wrong when processing your command."), f"id: {message_id}", ] ) await self.logic.emit_to_user( user_id, constants.SYSTEM_OUTPUT.format(reply) )
[docs] async def parse( self, user_id: int, connection_id: str, message_id: str, message: str ) -> None: """ Parse the incoming message from the referenced user. There are four special characters which, if they start the message, act as shortcuts for common communication related activities: * ``"`` - the user says whatever follows in the message. * ``!`` - make it appear like the user is shouting the message. * ``:`` - "emote" the message directly as "username " + message. * ``@`` - the user is saying something directly to another @user. Next the parser expects the first word of the message to be a verb. If this verb is one of several built-in commands, the remainder of the message is passed as a single string into the relevant function for that verb (as defined in the verbs module). These verbs are translated by Babel, so the equivalent verbs in the user's preferred locale (if supported by TextSmith) should work instead. If the verb isn't built into the game engine, then the parser breaks the raw input apart into sections that follow the following patterns:: VERB VERB DIRECT-OBJECT VERB DIRECT-OBJECT PREPOSITION INDIRECT-OBJECT Examples of these patterns are:: look take sword give big sword to andrew say "Hello there" to nicholas NOTE: English articles ("a", "the" etc) shouldn't be used in commands. Verbs that start sentences are assumed to be single words. Direct objects and indirect objects may be identified via multiple words. Anything enclosed in double-quotes (") is treated as a single entity if in the direct-object or indirect-object position. The parser will try to match objects against available aliases available in the current room's context. If there are no matches or multiple matches then the parser will retain the string representation of the direct-object or indirect-object. The following lists of reserved words are synonyms: * ``constants.USER_ALIASES`` - the user. * ``constants.ROOM_ALIASES`` - the current location. These reserved words are actually translated by Babel, so the equivalent terms in the user's preferred locale (if supported by TextSmith) should work instead. At this point the parser has identified the verb string, and the direct and indirect objects. It looks for a matching verb on the four following objects (in order or precedence): 1. The user giving the command. 2. The room the user is in (including where the verb is an exit name). 3. The direct object (if an object in the database). 4. The indirect object (if an object in the database). The game checks each object in turn and, if it finds an attribute that matches the verb it attempts to "execute" it. Mostly, the attribute's value will be returned. However, if the attribute's value is a string and that string starts with the characters defined in constants.IS_SCRIPT, then it'll attempt to evaluate the rest of the string as a script (see the script module for more detail of how this works). If such "executable" attributes are found then the associated code will be run with the following objects in scope: * ``user`` - a reference to the user who issued the command. * ``room`` - a reference to the room in which the user is situated. * ``exits`` - objects that allow the user to move out of the current room. * ``users`` - objects representing other users currently in the current room. * ``things`` - all the other objects currently in the room. * ``this`` - a reference to the object which matched the verb (the user, room or other object in scope). * ``direct_object`` - either the matching object or raw string for the direct object. This could be ``None``. * ``preposition`` - a string containing the preposition. This could be ``None``. * ``indirect_object`` - either the matching object or raw string for the indirect object. This could be ``None``. * ``raw_input`` - the raw (html escaped) string from the user that caused the script to be run. The user, room, direct_object and indirect_object objects can all be passed to a special "emit" function along with a message to display to that object (if the object is a user, it'll be sent just to them, if the object is a room, the message will be sent to all users in that room). That's it! """ # Don't do anything with empty messages. if not message.strip(): return # Check and process special "shortcut" characters. message = message.lstrip() if message.startswith('"'): # " The user is saying something to everyone in their location. return await self.verbs._say( user_id, connection_id, message_id, message[1:] ) elif message.startswith("!"): # ! The user is shouting something to everyone in their location. return await self.verbs._shout( user_id, connection_id, message_id, message[1:] ) elif message.startswith(":"): # : The user is emoting something to everyone in their location. return await self.verbs._emote( user_id, connection_id, message_id, message[1:] ) elif message.startswith("@"): # @ The user is saying something to a specific person in their # location. return await self.verbs._tell( user_id, connection_id, message_id, message[1:] ) # Check for verbs built into the game. split_input = message.split(" ", 1) verb = split_input[0] # The first word in a message is a verb. args = "" if len(split_input) == 2: # The remainder of the message contains the "arguments" to use with # the verb, and may ultimately contain the direct and indirect # objects (if needed). args = split_input[1] try: return await self.verbs( user_id, connection_id, message_id, verb, args ) except UnknownVerb: # No matching verb has been found, so pass on silently. pass # Act of last resort ~ choose a stock fun response. ;-) response = random.choice(constants.HUH) i_give_up = f'"{message}", ' + response return await self.logic.emit_to_user(user_id, i_give_up)